code that works

This commit is contained in:
Lilian Jónsdóttir 2024-03-11 17:23:36 -07:00
parent 8de3930a00
commit 746899a2c3
12 changed files with 812 additions and 0 deletions

177
cmd/agedit/cli.go Normal file
View file

@ -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
}

37
cmd/agedit/main.go Normal file
View file

@ -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)
}
}

29
go.mod
View file

@ -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
)

58
go.sum Normal file
View file

@ -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=

15
internal/config/config.go Normal file
View file

@ -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,
}

70
pkg/editor/editor.go Normal file
View file

@ -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
}

98
pkg/encrypt/encrypt.go Normal file
View file

@ -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
}

101
pkg/encrypt/encrypt_test.go Normal file
View file

@ -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)
}
}

107
pkg/env/env.go vendored Normal file
View file

@ -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()
}

30
pkg/env/env_test.go vendored Normal file
View file

@ -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)
}
}
}
}

47
pkg/tmpfile/tmpfile.go Normal file
View file

@ -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))]
}

View file

@ -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
}
}