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

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