From 746899a2c3eae2d7e5623e44b63e148ccf3646a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lilian=20J=C3=B3nsd=C3=B3ttir?= Date: Mon, 11 Mar 2024 17:23:36 -0700 Subject: [PATCH] code that works --- cmd/agedit/cli.go | 177 ++++++++++++++++++++++++++++++++++++ cmd/agedit/main.go | 37 ++++++++ go.mod | 29 ++++++ go.sum | 58 ++++++++++++ internal/config/config.go | 15 +++ pkg/editor/editor.go | 70 ++++++++++++++ pkg/encrypt/encrypt.go | 98 ++++++++++++++++++++ pkg/encrypt/encrypt_test.go | 101 ++++++++++++++++++++ pkg/env/env.go | 107 ++++++++++++++++++++++ pkg/env/env_test.go | 30 ++++++ pkg/tmpfile/tmpfile.go | 47 ++++++++++ pkg/tmpfile/tmpfile_test.go | 43 +++++++++ 12 files changed, 812 insertions(+) create mode 100644 cmd/agedit/cli.go create mode 100644 cmd/agedit/main.go create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 pkg/editor/editor.go create mode 100644 pkg/encrypt/encrypt.go create mode 100644 pkg/encrypt/encrypt_test.go create mode 100644 pkg/env/env.go create mode 100644 pkg/env/env_test.go create mode 100644 pkg/tmpfile/tmpfile.go create mode 100644 pkg/tmpfile/tmpfile_test.go diff --git a/cmd/agedit/cli.go b/cmd/agedit/cli.go new file mode 100644 index 0000000..77f6d79 --- /dev/null +++ b/cmd/agedit/cli.go @@ -0,0 +1,177 @@ +package main + +import ( + "errors" + "os" + "strings" + "time" + + "git.burning.moe/celediel/agedit/internal/config" + "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 = "agedit" + usage = "Edit age encrypted files with your $EDITOR" + version = "0.0.1" + help_template = `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.StringFlag{ + Name: "identity", + Usage: "age identity file to use", + Aliases: []string{"i"}, + Action: func(ctx *cli.Context, s string) error { + if identity_file := ctx.String("identity"); identity_file != "" { + cfg.IdentityFile = identity_file + } + return nil + }, + }, + &cli.StringFlag{ + Name: "out", + Usage: "write to this file instead of the input file", + Aliases: []string{"o"}, + Action: func(ctx *cli.Context, s string) error { + output_file = ctx.String("out") + return nil + }, + }, + &cli.StringFlag{ + Name: "log", + Usage: "log level", + Value: "warn", + Aliases: []string{"l"}, + Action: func(ctx *cli.Context, s string) error { + if lvl, err := log.ParseLevel(ctx.String("log")); 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 + }, + }, + &cli.StringFlag{ + Name: "editor", + Usage: "specify the editor to use", + Aliases: []string{"e"}, + Action: func(ctx *cli.Context, s string) error { + cfg.Editor = ctx.String("editor") + 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 errors.New("no file to edit, use agedit -h for help") + } + + // do some setup + cfg = config.Defaults + cfg.Editor = env.GetEditor() + cfg_dir := env.GetConfigDir("agedit") + cfg.IdentityFile = cfg_dir + "identity.key" + configFile = cfg_dir + "agedit.yaml" + logger = log.NewWithOptions(os.Stderr, log.Options{ + ReportTimestamp: true, + TimeFormat: time.TimeOnly, + }) + + // load config from file + _, err := os.Open(configFile) + if 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 + } + } + + return nil +} + +// action does the actual thing +func action(ctx *cli.Context) error { + if _, err := os.Open(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) + } + + if _, err := os.Open(cfg.IdentityFile); os.IsNotExist(err) { + return errors.New("identity file unset, use -i or set one in the config file") + } + + if id, err := encrypt.ReadIdentityFromFile(cfg.IdentityFile); err != nil { + return err + } else { + identity = id + } + logger.Debug("read identity from file", "id", identity.Recipient()) + + decrypted, err := encrypt.Decrypt(input_file, identity) + if err != nil { + return err + } + logger.Debug("decrypted " + input_file + " sucessfully") + + edited, err := editor.EditTempFile(cfg.Editor, string(decrypted), cfg.Prefix, cfg.Suffix, cfg.RandomLength) + if err != nil { + return err + } + logger.Debug("got data back from editor") + + if string(edited) == string(decrypted) { + logger.Warn("No edits made, not writing " + output_file) + return nil + } + + err = encrypt.Encrypt(edited, output_file, identity) + if err != nil { + return err + } + logger.Debug("re-encrypted to " + output_file) + + return nil +} diff --git a/cmd/agedit/main.go b/cmd/agedit/main.go new file mode 100644 index 0000000..91d7d91 --- /dev/null +++ b/cmd/agedit/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "os" + + "git.burning.moe/celediel/agedit/internal/config" + + "filippo.io/age" + "github.com/charmbracelet/log" + "github.com/urfave/cli/v2" +) + +var ( + identity *age.X25519Identity + logger *log.Logger + cfg config.Config + configFile string + + input_file, output_file string +) + +func main() { + app := &cli.App{ + Name: name, + Usage: usage, + Version: version, + Authors: authors, + Flags: flags, + Before: before, + Action: action, + CustomAppHelpTemplate: help_template, + } + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod index 837bada..8021bbf 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,32 @@ module git.burning.moe/celediel/agedit go 1.22.0 + +require ( + filippo.io/age v1.1.1 + github.com/charmbracelet/log v0.3.1 + github.com/ilyakaznacheev/cleanenv v1.5.0 + github.com/urfave/cli/v2 v2.27.1 +) + +require ( + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v0.9.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/crypto v0.4.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.13.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0faa09e --- /dev/null +++ b/go.sum @@ -0,0 +1,58 @@ +filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg= +filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/charmbracelet/log v0.3.1 h1:TjuY4OBNbxmHWSwO3tosgqs5I3biyY8sQPny/eCMTYw= +github.com/charmbracelet/log v0.3.1/go.mod h1:OR4E1hutLsax3ZKpXbgUqPtTjQfrh1pG3zwHGWuuq8g= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= +github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= +github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8= +golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5b95c48 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,15 @@ +package config + +type Config struct { + IdentityFile string `json:"identityfile" yaml:"identityfile" toml:"identityfile"` + Editor string `json:"editor" yaml:"editor" toml:"editor"` + Prefix string `json:"randomfileprefix" yaml:"randomfileprefix" toml:"randomfileprefix"` + Suffix string `json:"randomfilesuffix" yaml:"randomfilesuffix" toml:"randomfilesuffix"` + RandomLength int `json:"randomfilenamelength" yaml:"randomfilenamelength" toml:"randomfilenamelength"` +} + +var Defaults = Config{ + Prefix: "agedit_", + Suffix: ".txt", + RandomLength: 13, +} diff --git a/pkg/editor/editor.go b/pkg/editor/editor.go new file mode 100644 index 0000000..51b9530 --- /dev/null +++ b/pkg/editor/editor.go @@ -0,0 +1,70 @@ +package editor + +import ( + "errors" + "io/fs" + "os" + "os/exec" + + "git.burning.moe/celediel/agedit/pkg/tmpfile" +) + +// EditFile opens the specified file in the configured editor +func EditFile(editor, filename string) error { + if editor == "" { + return errors.New("editor not set") + } + + // TODO: handle editors that require arguments + cmd := exec.Command(editor, filename) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return err + } + + return nil +} + +// EditTempFile creates a temporary file with a random name, opens it in the +// editor, and returns the byte slice of its contents. +func EditTempFile(editor, start, prefix, suffix string, filename_length int) ([]byte, error) { + var ( + filename string + bytes []byte + err error + file *os.File + ) + + // generator := tmpfile.NewGenerator("agedit_", ".txt", 13) + generator := tmpfile.NewGenerator(prefix, suffix, filename_length) + + filename = generator.GenerateFullPath() + if file, err = os.Create(filename); err != nil { + return nil, err + } + + if err = os.WriteFile(filename, []byte(start), fs.FileMode(0600)); err != nil { + return nil, err + } + + if err = EditFile(editor, filename); err != nil { + return nil, err + } + + if bytes, err = os.ReadFile(filename); err != nil { + return nil, err + } + + if err = file.Close(); err != nil { + return nil, err + } + + if err = os.Remove(filename); err != nil { + return nil, err + } + + return bytes, nil +} diff --git a/pkg/encrypt/encrypt.go b/pkg/encrypt/encrypt.go new file mode 100644 index 0000000..b950dd7 --- /dev/null +++ b/pkg/encrypt/encrypt.go @@ -0,0 +1,98 @@ +package encrypt + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + + "filippo.io/age" +) + +// Encrypt encrypts bytes into filename +func Encrypt(data []byte, filename string, identity *age.X25519Identity) error { + var ( + w io.WriteCloser + out = &bytes.Buffer{} + err error + ) + + if identity == nil { + return errors.New("nil identity??") + } + + if w, err = age.Encrypt(out, identity.Recipient()); err != nil { + return err + } + + io.WriteString(w, string(data)) + if err = w.Close(); err != nil { + return err + } + + os.Truncate(filename, 0) // in case it exists already + if err = os.WriteFile(filename, out.Bytes(), fs.FileMode(0600)); err != nil { + return err + } + + return nil +} + +// Decrypt decrypts bytes from filename +func Decrypt(filename string, identity *age.X25519Identity) ([]byte, error) { + var ( + f *os.File + r io.Reader + err error + out = &bytes.Buffer{} + ) + if f, err = os.Open(filename); err != nil { + return nil, err + } + + if r, err = age.Decrypt(f, identity); err != nil { + return nil, err + } + + if _, err := io.Copy(out, r); err != nil { + return nil, err + } + + return out.Bytes(), nil +} + +// NewIdentity generates a new Age identity +func NewIdentity() (*age.X25519Identity, error) { + id, err := age.GenerateX25519Identity() + if err != nil { + return nil, err + } + + return id, nil +} + +// ReadIdentityFromFile reads the identity from the supplied filename +func ReadIdentityFromFile(filename string) (*age.X25519Identity, error) { + bytes, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + id, err := age.ParseX25519Identity(string(bytes)) + if err != nil { + return nil, err + } + + return id, nil +} + +// WriteIdentityToFile writes the supplied identity to the supplied filename +func WriteIdentityToFile(id *age.X25519Identity, filename string) error { + err := os.WriteFile(filename, []byte(id.String()), fs.FileMode(0600)) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/encrypt/encrypt_test.go b/pkg/encrypt/encrypt_test.go new file mode 100644 index 0000000..e7e53f0 --- /dev/null +++ b/pkg/encrypt/encrypt_test.go @@ -0,0 +1,101 @@ +package encrypt + +import ( + "io/fs" + "os" + "testing" + + "git.burning.moe/celediel/agedit/pkg/tmpfile" +) + +var generator = tmpfile.NewGenerator("test_", ".txt", 18) + +// TestEncryptionDecryption writes a string to a file, encrypts it, then decrypts it, and reads the string. +func TestEncryptionDecryption(t *testing.T) { + var ( + strings_to_write = []string{ + "hello world", + "hola mundo", + "مرحبا بالعالم", + "こんにちは世界", + "你好世界", + "Γειά σου Κόσμε", + "Привіт Світ", + "Բարեւ աշխարհ", + "გამარჯობა მსოფლიო", + "अभिवादन पृथ्वी", + } + ) + + id, err := NewIdentity() + if err != nil { + t.Fatal(err) + } + + for _, str := range strings_to_write { + var ( + outname string = generator.GenerateFullPath() + encrypted_outname string = outname + ".age" + b []byte + err error + ) + + t.Run("testing writing "+str, func(t *testing.T) { + if err = os.WriteFile(outname, []byte(str), fs.FileMode(0600)); err != nil { + t.Fatal(err) + } + + if b, err = os.ReadFile(outname); err != nil { + t.Fatal(err) + } + + if err = Encrypt(b, encrypted_outname, id); err != nil { + t.Fatal(err) + } + + if b, err = Decrypt(encrypted_outname, id); err != nil { + t.Fatal(err) + } + + if string(b) != str { + t.Fatal(string(b) + " isn't the same as " + str) + } + + if err = os.Remove(outname); err != nil { + t.Fatal(err) + } + + if err = os.Remove(encrypted_outname); err != nil { + t.Fatal(err) + } + + }) + } +} + +// TestNewIdentity creats a new identity, writes it to file, then re-reads it back from the file. +func TestNewIdentity(t *testing.T) { + for range 1000 { + outfile := generator.GenerateFullPath() + + identity, err := NewIdentity() + if err != nil { + t.Fatal(err) + } + + err = WriteIdentityToFile(identity, outfile) + if err != nil { + t.Fatal(err) + } + + other_identity, err := ReadIdentityFromFile(outfile) + if err != nil { + t.Fatal(err) + } + + if identity.Recipient().String() != other_identity.Recipient().String() && identity.String() != other_identity.String() { + t.Fatal("Identities don't match!", identity.Recipient(), "!=", identity.Recipient()) + } + os.Remove(outfile) + } +} diff --git a/pkg/env/env.go b/pkg/env/env.go new file mode 100644 index 0000000..ebebf58 --- /dev/null +++ b/pkg/env/env.go @@ -0,0 +1,107 @@ +package env + +import ( + "os" + "regexp" + "runtime" + "strings" +) + +// GetEditor gets the configured editor by checking environmental +// variables EDITOR and VISUAL +func GetEditor() string { + var editor string + if os.Getenv("EDITOR") != "" { + editor = os.Getenv("EDITOR") + } else if os.Getenv("VISUAL") != "" { + editor = os.Getenv("VISUAL") + } /* else { + // TODO: maybe pick something based on the OS + } */ + + return editor +} + +// GetConfigDir gets a config directory based from environmental variables + the app name +// +// On Windows, %APPDATA%\agedit is used +// +// On UNIX-like systems, $XDG_CONFIG_HOME/agedit is tried, if it isn't defined, $HOME/.config/agedit is used +func GetConfigDir(appname string) string { + var configdir string + switch runtime.GOOS { + case "windows": + configdir = os.Getenv("APPDATA") + default: + fallthrough + case "darwin": + // TODO: figure out the proper Mac OS local directories + fallthrough + case "linux": + if confighome := os.Getenv("XDG_CONFIG_HOME"); confighome != "" { + configdir = confighome + } else { + configdir = make_path(os.Getenv("HOME"), ".config") + } + } + + return make_path(configdir, appname) +} + +// GetConfigDir gets a config directory based from environmental variables + the app name +// +// On Windows, %LOCALAPPDATA%\agedit is used +// +// On UNIX-like systems, $XDG_DATA_HOME/agedit is tried, if it isn't defined, $HOME/.local/share/agedit is used +func GetDataDir(appname string) string { + var datadir string + switch runtime.GOOS { + case "windows": + datadir = os.Getenv("LOCALAPPDATA") + default: + fallthrough + case "darwin": + // TODO: also here + fallthrough + case "linux": + if datahome := os.Getenv("XDG_DATA_HOME"); datahome != "" { + datadir = datahome + } else { + datadir = make_path(os.Getenv("HOME"), "local", "share") + } + } + + return make_path(datadir, appname) +} + +// GetTempDirectory returns the systems temporary directory +// +// returns %TEMP% on Windows, /tmp on UNIX-like systems +func GetTempDirectory() string { + switch runtime.GOOS { + case "windows": + return os.Getenv("TEMP") + default: + fallthrough + case "darwin": + fallthrough + case "linux": + return "/tmp" + } +} + +func make_path(paths ...string) string { + sep := string(os.PathSeparator) + output := strings.Builder{} + + // add / to the start if it's not already there and we're not on Windows + if match, err := regexp.Match("^\\w", []byte(paths[0])); err == nil && match && runtime.GOOS != "windows" { + output.WriteString(sep) + } + + for _, path := range paths { + output.WriteString(path + sep) + } + + return output.String() +} diff --git a/pkg/env/env_test.go b/pkg/env/env_test.go new file mode 100644 index 0000000..89b3074 --- /dev/null +++ b/pkg/env/env_test.go @@ -0,0 +1,30 @@ +package env + +import ( + "os" + "testing" +) + +var ( + editors = []string{"hx", "nano", "vi", "vim", "nvim", "micro", "emacs", "ed"} +) + +func clearEnvForNow() { + for _, item := range []string{"EDITOR", "VISUAL"} { + os.Setenv(item, "") + } +} + +func TestEditorFromEnv(t *testing.T) { + for _, item := range []string{"EDITOR", "VISUAL"} { + clearEnvForNow() + for _, editor := range editors { + if err := os.Setenv(item, editor); err != nil { + t.Fatal(err) + } + if got := GetEditor(); got != editor { + t.Fatal("got", got, "but wanted", editor) + } + } + } +} diff --git a/pkg/tmpfile/tmpfile.go b/pkg/tmpfile/tmpfile.go new file mode 100644 index 0000000..cd8ba75 --- /dev/null +++ b/pkg/tmpfile/tmpfile.go @@ -0,0 +1,47 @@ +package tmpfile + +import ( + "math/rand" + "os" + "strings" + + "git.burning.moe/celediel/agedit/pkg/env" +) + +const chars string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" + +type Generator struct { + Prefix, Suffix string + Length int +} + +// GenerateName generates a random temporary filename like agedit_geef0XYC30RGV +func (g *Generator) GenerateName() string { + return g.Prefix + randomString(chars, g.Length) + g.Suffix +} + +// GenerateFullPath generates a random temporary filename and appends it to the OS's temporary directory +func (g *Generator) GenerateFullPath() string { + return env.GetTempDirectory() + string(os.PathSeparator) + g.GenerateName() +} + +// NewGenerator returns a new Generator +func NewGenerator(prefix, suffix string, length int) Generator { + return Generator{ + Prefix: prefix, + Suffix: suffix, + Length: length, + } +} + +func randomString(set string, length int) string { + out := strings.Builder{} + for i := 0; i < length; i++ { + out.WriteByte(randomChar(set)) + } + return out.String() +} + +func randomChar(set string) byte { + return set[rand.Intn(len(set))] +} diff --git a/pkg/tmpfile/tmpfile_test.go b/pkg/tmpfile/tmpfile_test.go new file mode 100644 index 0000000..126f1ce --- /dev/null +++ b/pkg/tmpfile/tmpfile_test.go @@ -0,0 +1,43 @@ +package tmpfile + +import ( + "io/fs" + "os" + "testing" +) + +var generator = NewGenerator("test_", ".txt", 18) + +// TestCanCreateTmpFile tests if temporary files can be created and removed successfully +func TestCanCreateTmpFile(t *testing.T) { + b := []byte{104, 101, 108, 108, 111, 32, 116, 104, 101, 114, 101} + + for range 1000 { + outfile := generator.GenerateFullPath() + err := os.WriteFile(outfile, b, fs.FileMode(0600)) + if err != nil { + t.Fatal(err) + } + + if _, err = os.Stat(outfile); err != nil && os.IsNotExist(err) { + t.Fatal(err) + } + + if err = os.Remove(outfile); err != nil { + t.Fatal(err) + } + } +} + +// TestUniqueTmpFile generates a large number of random names to make sure they're all unique +func TestUniqueTmpFile(t *testing.T) { + var generated_names = map[string]string{} + + for range 100000 { + name := generator.GenerateName() + if val, ok := generated_names[name]; ok { + t.Fatal("Non unique name", val) + } + generated_names[name] = name + } +}