agedit/cmd/agedit/cli.go
Lilian Jónsdóttir 7d24d5e70b version bump - v0.2.1
- error if editor undetected
- less verbose error if identities unset
2024-04-03 17:18:56 -07:00

280 lines
7.2 KiB
Go

package main
import (
"errors"
"fmt"
"os"
"strings"
"time"
"filippo.io/age"
"git.burning.moe/celediel/agedit/internal/config"
"git.burning.moe/celediel/agedit/pkg/decrypt"
"git.burning.moe/celediel/agedit/pkg/editor"
"git.burning.moe/celediel/agedit/pkg/encrypt"
"git.burning.moe/celediel/agedit/pkg/env"
"github.com/charmbracelet/log"
"github.com/ilyakaznacheev/cleanenv"
"github.com/urfave/cli/v2"
)
const (
name string = "agedit"
usage string = "Edit age encrypted files with your $EDITOR"
version string = "0.2.1"
help_template string = `NAME:
{{.Name}} {{if .Version}}v{{.Version}}{{end}} - {{.Usage}}
USAGE:
{{.HelpName}} {{if .VisibleFlags}}[flags]{{end}} [filename]
{{if len .Authors}}
AUTHOR:
{{range .Authors}}{{ . }}{{end}}
{{end}}{{if .Commands}}
FLAGS:
{{range .VisibleFlags}}{{.}}
{{end}}{{end}}{{if .Copyright }}
COPYRIGHT:
{{.Copyright}}
{{end}}
`
)
var (
authors = []*cli.Author{{
Name: "Lilian Jónsdóttir",
Email: "lilian.jonsdottir@gmail.com",
}}
flags = []cli.Flag{
&cli.StringSliceFlag{
Name: "identity",
Usage: "age `identity` (or identities) to decrypt with",
Aliases: []string{"I"},
Action: func(ctx *cli.Context, inputs []string) error {
for _, input := range inputs {
id, err := age.ParseX25519Identity(input)
if err != nil {
return err
}
identities = append(identities, id)
}
gave_identities = true
return nil
},
},
&cli.PathFlag{ // I dunno why PathFlag exists because cli.Path is just string
Name: "identity-file",
Usage: "read identity from `FILE`",
Aliases: []string{"i"},
Action: func(ctx *cli.Context, identity_file cli.Path) error {
if identity_file != "" {
cfg.IdentityFile = identity_file
}
return nil
},
},
&cli.StringSliceFlag{
Name: "recipient",
Usage: "age `recipient`s to encrypt to",
Aliases: []string{"R"},
Action: func(ctx *cli.Context, inputs []string) error {
for _, input := range inputs {
logger.Debugf("parsing public key from string %s", input)
r, err := age.ParseX25519Recipient(input)
if err != nil {
return err
}
recipients = append(recipients, r)
}
gave_recipients = true
return nil
},
},
&cli.PathFlag{
Name: "recipient-file",
Usage: "read recipients from `FILE`",
Aliases: []string{"r"},
Action: func(ctx *cli.Context, recipient_file cli.Path) error {
if recipient_file != "" {
cfg.RecipientFile = recipient_file
}
return nil
},
},
&cli.StringFlag{
Name: "out",
Usage: "write to `FILE` instead of the input file",
Aliases: []string{"o"},
Action: func(ctx *cli.Context, out string) error {
output_file = out
return nil
},
},
&cli.StringFlag{
Name: "editor",
Usage: "edit with specified `EDITOR` instead of $EDITOR",
Aliases: []string{"e"},
Action: func(ctx *cli.Context, editor string) error {
cfg.Editor = editor
return nil
},
},
&cli.StringSliceFlag{
Name: "editor-args",
Usage: "`arg`uments to send to the editor",
Action: func(ctx *cli.Context, args []string) error {
cfg.EditorArgs = args
return nil
},
},
&cli.BoolFlag{
Name: "force",
Usage: "re-encrypt the file even if no changes have been made",
Aliases: []string{"f"},
DisableDefaultText: true,
Action: func(ctx *cli.Context, b bool) error {
force_overwrite = b
return nil
},
},
&cli.StringFlag{
Name: "log",
Usage: "log `level`",
Value: "warn",
Action: func(ctx *cli.Context, s string) error {
if lvl, err := log.ParseLevel(s); err == nil {
logger.SetLevel(lvl)
// Some extra info for debug level
if logger.GetLevel() == log.DebugLevel {
logger.SetReportCaller(true)
}
} else {
logger.SetLevel(log.WarnLevel)
}
return nil
},
},
}
)
// before validates input, does some setup, and loads config info from file
func before(ctx *cli.Context) error {
// check input
if input_file = strings.Join(ctx.Args().Slice(), " "); input_file == "" {
return fmt.Errorf("no file to edit, use " + name + " -h for help")
}
// set some defaults
cfg = config.Defaults
cfg.Editor = env.GetEditor()
cfg_dir := env.GetConfigDir(name)
cfg.IdentityFile = cfg_dir + "identity.key"
configFile = cfg_dir + name + ".yaml"
logger = log.NewWithOptions(os.Stderr, log.Options{
ReportTimestamp: true,
TimeFormat: time.TimeOnly,
})
// load config from file
if _, err := os.Stat(configFile); err != nil && errors.Is(err, os.ErrNotExist) {
// or not
logger.Debug("couldn't load config file", "file", configFile)
} else {
err = cleanenv.ReadConfig(configFile, &cfg)
if err != nil {
return err
}
}
// setup editor with loaded config options
edt = editor.New(cfg.Editor, cfg.EditorArgs, cfg.Prefix, cfg.Suffix, cfg.RandomLength)
return nil
}
// action does the actual thing
func action(ctx *cli.Context) error {
// make sure input file exists
if _, err := os.Stat(input_file); os.IsNotExist(err) {
return err
}
if output_file == "" {
output_file = input_file
logger.Debug("out file not specified, using input", "outfile", output_file)
}
// read from identity file if exists and no identities have been supplied
if !gave_identities {
if _, err := os.Stat(cfg.IdentityFile); os.IsNotExist(err) {
return fmt.Errorf("identity file unset and no identities supplied")
} else {
f, err := os.Open(cfg.IdentityFile)
if err != nil {
return fmt.Errorf("couldn't open identity file: %v", err)
}
if ids, err := age.ParseIdentities(f); err != nil {
return fmt.Errorf("couldn't parse identities: %v", err)
} else {
identities = append(identities, ids...)
}
}
}
// read from recipient file if it exists and no recipients have been supplied
if !gave_recipients && cfg.RecipientFile != "" {
if _, err := os.Stat(cfg.RecipientFile); os.IsNotExist(err) {
return fmt.Errorf("recipient file doesn't exist")
} else {
f, err := os.Open(cfg.RecipientFile)
if err != nil {
return fmt.Errorf("couldn't open recipient file: %v", err)
}
if rs, err := age.ParseRecipients(f); err != nil {
return fmt.Errorf("couldn't parse recipients: %v", err)
} else {
recipients = append(recipients, rs...)
}
}
}
// get recipients from specified identities
for _, id := range identities {
if actual_id, ok := id.(*age.X25519Identity); ok {
recipients = append(recipients, actual_id.Recipient())
}
}
// try to decrypt the file
decrypted, err := decrypt.Decrypt(input_file, identities...)
if err != nil {
return err
}
logger.Debug("decrypted " + input_file + " sucessfully")
// open decrypted data in the editor
edited, err := edt.EditTempFile(string(decrypted))
if err != nil {
return err
}
logger.Debug("got data back from editor")
// don't overwrite same data, unless specified
if string(edited) == string(decrypted) && !force_overwrite {
logger.Warn("No edits made, not writing " + output_file)
return nil
}
// actually re-encrypt the data
err = encrypt.Encrypt(edited, output_file, recipients...)
if err != nil {
return err
}
logger.Debug("re-encrypted to " + output_file)
return nil
}