13 Commits

19 changed files with 346 additions and 317 deletions

View File

@@ -2,59 +2,41 @@
1. Commands are still leaking. 1. Commands are still leaking.
2. **db.odin** — Inconsistencies in how struct vs sqlite are named. 2. Add color flag and support non colored output.
3. Add color flag and support non colored output. 3. Rewrite `write_command_help` to use text/tables
4. Use text/tables for command output 4. Generate md and man pages again.
5. Generate md and man pages again. 5. Json may be an expensive encoding for remotes. Confirm with spall, and use null terminated strings if necessary.
6. **db.odin:324-327** — Map iteration (`remote_set`) is non-deterministic. Same file can produce different JSON on each backup, causing spurious DB diffs. Sort remotes before storing. 6. Make sure official path separators are used when appropriate, rather than '/'.
7. Make sure official path separators are used when appropriate, rather than '/'. 7. Consistently ignore allocator errors
8. **cmd_restore.odin:20-30 & cmd_remove.odin:19-29** — Identical path-resolution block copy-pasted. `is_abs` guard is redundant since `filepath.abs` is a no-op on absolute paths. Extract a helper. 8. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
9. **cmd_restore.odin:44**`os.mkdir_all` error silently discarded. Subsequent write failure will be confusing. 9. Add a text filter to the multi_select.
10. **config.odin:178**`search_paths` silently ignores `os.user_home_dir` error. If home is empty, `~` isn't expanded. Same class of bug as issue 3. 10. Add tests for untested commands.
11. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data. 11. add --format -f flag to commands that draw tables.
12. **db.odin:352-353**`hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice. 12. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate.
13. **cmd_sync.odin:80, cmd_list.odin:33**`make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass. 13. procedures should be ordered by use, main at the top, then in the order they are called from main.
14. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`. 14. Shell completion
15. Add a text filter to the multi_select. 15. Bring back windows support / cross-compilation.
16. Add tests for untested commands. 16. Test all cmds / terminal branches.
17. 2 scan tests silently skip when fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path. 17. Fix error messages to use fmt.eprintf (stderr) instead of fmt.printf (stdout)
18. add --format -f flag to commands that draw tables. 18. Pass allocator to findr?
19. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate. 19. Update `read_wire_string` to use a slice.
20. Change struct field names from PascalCase to snake_case.
21. procedures should be ordered by use, main at the top, then in the order they are called from main.
22. Shell completion
23. Bring back windows support / cross-compilation.
24. Test all cmds / terminal branches.
25. Replace `fmt.tprintf("/tmp/envr-test-...-%d", os.get_pid())` + `os.mkdir_all` in test files with `os.mkdir_temp` (race-free, honors `$TMPDIR`, matches `findr/test_env.odin` pattern).
26. Adopt `core:log` across `db.odin`, `crypto.odin`, `config.odin`, `ssh.odin` — replace ~30 scattered `fmt.printf("Error ...")` calls with leveled logging for consistent stderr routing and source locations.
27. "Encryption failed" in tests.
28. Pass allocator to findr?
## Double-check AI output ## Double-check AI output

View File

@@ -5,6 +5,7 @@ import "core:fmt"
import "core:io" import "core:io"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import "core:text/table"
Command :: struct { Command :: struct {
name: string, name: string,
@@ -253,56 +254,47 @@ at before, restore your backup with:
%senvr%s [command] %senvr%s [command]
%sAvailable Commands:%s
`, `,
COLOR_HEADINGS, COLOR_HEADINGS,
ANSI_RESET, ANSI_RESET,
COLOR_FLAGS, COLOR_FLAGS,
ANSI_RESET, ANSI_RESET,
COLOR_HEADINGS,
ANSI_RESET,
flush = false, flush = false,
) )
tbl: table.Table
table.init(&tbl, context.temp_allocator, context.temp_allocator)
table.padding(&tbl, 2, 0)
table.caption(&tbl, "Available Commands:")
for c in COMMANDS { for c in COMMANDS {
name_start := len(c.name) name := c.name
fmt.wprintf(w, " %s%s", COLOR_COMMANDS, c.name, flush = false) // TODO: Can we do better?
for a in c.aliases { for a in c.aliases {
fmt.wprintf(w, ", %s", a, flush = false) name = strings.join([]string{name, a}, ", ", tbl.format_allocator)
name_start += len(a) + 2
} }
fmt.wprint(w, ANSI_RESET) table.row(&tbl, table.format(&tbl, "%s%s%s", COLOR_COMMANDS, name, ANSI_RESET), c.short)
padding := 20 - name_start
if padding > 0 {
for _ in 0 ..< padding {
io.write_byte(w, ' ')
}
}
fmt.wprintf(w, " %s\n", c.short, flush = false)
} }
write_borderless_table(w, &tbl)
table_reset(&tbl)
table.caption(&tbl, "Flags:")
table.row(&tbl, COLOR_FLAGS + "-h, --help" + ANSI_RESET, `show this documentation`)
table.row(
&tbl,
COLOR_FLAGS + "-c, --config-file" + ANSI_RESET + " <path>",
`config file (default "~/.envr/config.json")`,
)
write_borderless_table(w, &tbl)
fmt.wprintf( fmt.wprintf(
w, w,
"\n" + `Use "%senvr%s [command] --help" for more information about a command.`,
COLOR_HEADINGS + COLOR_FLAGS,
"Flags:" + ANSI_RESET,
ANSI_RESET +
"\n\n " +
COLOR_FLAGS +
"-h, --help" +
ANSI_RESET +
" help for envr\n" +
COLOR_FLAGS +
` -c, --config-file` +
ANSI_RESET +
` <path> config file (default "~/.envr/config.json")
Use "` +
COLOR_FLAGS +
"envr" +
ANSI_RESET +
` [command] --help" for more information about a command.
`,
flush = false, flush = false,
) )
} }

View File

@@ -15,7 +15,10 @@ cmd_backup :: proc(cmd: ^Command) {
return return
} }
// TODO: allow new_env_file to accept allocator?
// TODO: Write a test that covers this leak
file, ok := new_env_file(path) file, ok := new_env_file(path)
defer delete_envfile(&file)
if !ok { if !ok {
return return
} }

View File

@@ -7,29 +7,22 @@ import "core:path/filepath"
// TODO: What happens if you pass a non existent path to cmd_check? // TODO: What happens if you pass a non existent path to cmd_check?
// TODO: UX could be improved, so "run envr add ." if file not exists. // TODO: UX could be improved, so "run envr add ." if file not exists.
cmd_check :: proc(cmd: ^Command) { cmd_check :: proc(cmd: ^Command) {
check_path: string _check_path: string
if len(cmd.args) > 0 { if len(cmd.args) > 0 {
check_path = cmd.args[0] _check_path = cmd.args[0]
} else { } else {
cwd, cwd_err := os.get_working_directory(context.temp_allocator) cwd, cwd_err := os.get_working_directory(context.temp_allocator)
if cwd_err != nil { if cwd_err != nil {
fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false) fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false)
return return
} }
check_path = cwd _check_path = cwd
} }
check_path, abs_err := filepath.abs(_check_path, context.temp_allocator)
abs_path: string
if filepath.is_abs(check_path) {
abs_path = check_path
} else {
resolved, abs_err := filepath.abs(check_path)
if abs_err != nil { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved
}
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
@@ -37,20 +30,20 @@ cmd_check :: proc(cmd: ^Command) {
} }
defer db_close(&db) defer db_close(&db)
is_dir := os.is_directory(abs_path) is_dir := os.is_directory(check_path)
// TODO: set a reasonable default // TODO: set a reasonable default
files_in_path := make([dynamic]string, context.temp_allocator) files_in_path := make([dynamic]string, context.temp_allocator)
if is_dir { if is_dir {
scanned, scan_ok := scan_path(abs_path, db.cfg) scanned, scan_ok := scan_path(check_path, db.cfg)
if !scan_ok { if !scan_ok {
fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false) fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false)
return return
} }
files_in_path = scanned files_in_path = scanned
} else { } else {
append(&files_in_path, abs_path) append(&files_in_path, check_path)
} }
db_files, list_ok := db_list(&db) db_files, list_ok := db_list(&db)

View File

@@ -7,7 +7,7 @@ import "core:testing"
@(test) @(test)
test_find_unbacked_finds_missing :: proc(t: ^testing.T) { test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env", "/c/.env"} local := []string{"/a/.env", "/b/.env", "/c/.env"}
db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}} db := []EnvFile{{path = "/a/.env"}, {path = "/b/.env"}}
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 1, fmt.tprintf("expected 1 unbacked, got %d", len(result))) testing.expect(t, len(result) == 1, fmt.tprintf("expected 1 unbacked, got %d", len(result)))
@@ -23,7 +23,7 @@ test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
@(test) @(test)
test_find_unbacked_all_backed :: proc(t: ^testing.T) { test_find_unbacked_all_backed :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env"} local := []string{"/a/.env", "/b/.env"}
db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}} db := []EnvFile{{path = "/a/.env"}, {path = "/b/.env"}}
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result))) testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result)))
@@ -32,7 +32,7 @@ test_find_unbacked_all_backed :: proc(t: ^testing.T) {
@(test) @(test)
test_find_unbacked_no_local :: proc(t: ^testing.T) { test_find_unbacked_no_local :: proc(t: ^testing.T) {
local: []string local: []string
db := []EnvFile{{Path = "/a/.env"}} db := []EnvFile{{path = "/a/.env"}}
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result))) testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result)))

View File

@@ -9,8 +9,8 @@ import "core:terminal"
import "core:text/table" import "core:text/table"
ListEntry :: struct { ListEntry :: struct {
Directory: string `json:"directory"`, dir: string `json:"directory"`,
Path: string `json:"path"`, path: string `json:"path"`,
} }
// TODO: Support --format flag // TODO: Support --format flag
@@ -40,10 +40,10 @@ cmd_list :: proc(cmd: ^Command) {
for row in rows { for row in rows {
dir_str := strings.concatenate( dir_str := strings.concatenate(
{row.Dir, os.Path_Separator_String}, {row.dir, os.Path_Separator_String},
context.temp_allocator, context.temp_allocator,
) )
filename := filepath.base(row.Path) filename := filepath.base(row.path)
table.row(&t, dir_str, filename) table.row(&t, dir_str, filename)
} }
@@ -53,12 +53,12 @@ cmd_list :: proc(cmd: ^Command) {
// TODO: Should we instead print full entries here? // TODO: Should we instead print full entries here?
entries: [dynamic]ListEntry entries: [dynamic]ListEntry
for row in rows { for row in rows {
filename := filepath.base(row.Path) filename := filepath.base(row.path)
append( append(
&entries, &entries,
ListEntry { ListEntry {
Directory = strings.concatenate({row.Dir, "/"}, context.temp_allocator), dir = strings.concatenate({row.dir, "/"}, context.temp_allocator),
Path = filename, path = filename,
}, },
) )
} }

View File

@@ -16,18 +16,11 @@ cmd_remove :: proc(cmd: ^Command) {
return return
} }
// TODO: Is this the best way to do it? abs_path, abs_err := filepath.abs(path, context.temp_allocator)
abs_path: string
if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved
}
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {

View File

@@ -16,19 +16,12 @@ cmd_restore :: proc(cmd: ^Command) {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false) fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return return
} }
abs_path, abs_err := filepath.abs(path, context.temp_allocator)
// TODO: Is this the right way to handle this?
abs_path: string
if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved
}
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
@@ -41,15 +34,20 @@ cmd_restore :: proc(cmd: ^Command) {
return return
} }
dir := filepath.dir(file.Path) dir := filepath.dir(file.path)
os.mkdir_all(dir) if err := os.mkdir_all(dir); err != nil {
fmt.wprintf(cmd.err, "Failed to create directory: %v\n", err, flush = false)
write_err := os.write_entire_file(file.Path, file.contents)
if write_err != nil {
fmt.wprintf(cmd.err, "Error writing file: %v\n", write_err, flush = false)
return return
} }
fmt.wprintf(cmd.out, "Restored %s\n", file.Path, flush = false) write_err := os.write_entire_file(file.path, file.contents)
if write_err != nil {
fmt.wprintf(cmd.err, "Error writing file: %v\n", write_err, flush = false)
return
}
fmt.wprintf(cmd.out, "Restored %s\n", file.path, flush = false)
} }

View File

@@ -72,7 +72,11 @@ cmd_scan :: proc(cmd: ^Command) {
selected, result := multi_select("Select .env files to backup:", files[:]) selected, result := multi_select("Select .env files to backup:", files[:])
defer delete(selected) defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.wprintln(cmd.out, ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET, flush = false) fmt.wprintln(
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET,
flush = false,
)
return return
} }
@@ -81,7 +85,9 @@ cmd_scan :: proc(cmd: ^Command) {
if !selected[i] { if !selected[i] {
continue continue
} }
// TODO: Test cover this leak
env_file, ok := new_env_file(files[i]) env_file, ok := new_env_file(files[i])
defer delete_envfile(&env_file)
if !ok { if !ok {
fmt.wprintf(cmd.err, "Error reading %s\n", files[i], flush = false) fmt.wprintf(cmd.err, "Error reading %s\n", files[i], flush = false)
continue continue
@@ -96,12 +102,23 @@ cmd_scan :: proc(cmd: ^Command) {
if added_count > 0 { if added_count > 0 {
fmt.wprintf( fmt.wprintf(
cmd.out, cmd.out,
ansi.CSI + ansi.BOLD + ";" + ansi.FG_GREEN + ansi.SGR + "Successfully added %d file(s) to backup." + ANSI_RESET + "\n", ansi.CSI +
ansi.BOLD +
";" +
ansi.FG_GREEN +
ansi.SGR +
"Successfully added %d file(s) to backup." +
ANSI_RESET +
"\n",
added_count, added_count,
flush = false, flush = false,
) )
} else { } else {
fmt.wprintln(cmd.out, ansi.CSI + ansi.FAINT + ansi.SGR + "No files were added." + ANSI_RESET, flush = false) fmt.wprintln(
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "No files were added." + ANSI_RESET,
flush = false,
)
} }
} }

View File

@@ -7,8 +7,8 @@ import "core:terminal"
import "core:text/table" import "core:text/table"
SyncEntry :: struct { SyncEntry :: struct {
Path: string `json:"path"`, path: string `json:"path"`,
Status: string `json:"status"`, status: string `json:"status"`,
} }
// TODO: Check for quiet failures. // TODO: Check for quiet failures.
@@ -44,8 +44,8 @@ cmd_sync :: proc(cmd: ^Command) {
} }
results[i] = SyncEntry { results[i] = SyncEntry {
Path = file.Path, path = file.path,
Status = status, status = status,
} }
} }
@@ -62,7 +62,7 @@ cmd_sync :: proc(cmd: ^Command) {
) )
for res in results { for res in results {
table.row(&t, res.Path, res.Status) table.row(&t, res.path, res.status)
} }
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width) table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width)

View File

@@ -208,17 +208,19 @@ find_git_roots :: proc(
} }
search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]string { search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]string {
// TODO: handle error home, err := os.user_home_dir(context.temp_allocator)
home, _ := os.user_home_dir(context.temp_allocator) if err != nil {
panic("Failed to find home directory")
}
paths, _ := new_clone(cfg.scan_config.include, allocator) paths := new_clone(cfg.scan_config.include, allocator)
for &include in paths { for &include in paths {
// TODO: Do we need to manually expand ~/ in odin?
expanded, _ := strings.replace(include, "~", home, 1, allocator) expanded, _ := strings.replace(include, "~", home, 1, allocator)
if filepath.is_abs(expanded) { if filepath.is_abs(expanded) {
include = expanded include = expanded
} else { } else {
// TODO: show errors?
resolved, err := filepath.abs(expanded, allocator) resolved, err := filepath.abs(expanded, allocator)
if err == nil { if err == nil {
include = resolved include = resolved

View File

@@ -71,8 +71,7 @@ test_new_config_exclude_patterns :: proc(t: ^testing.T) {
@(test) @(test)
test_save_load_config_roundtrip :: proc(t: ^testing.T) { test_save_load_config_roundtrip :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-cfg-rt-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-cfg-rt-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
@@ -105,8 +104,7 @@ test_load_config_missing :: proc(t: ^testing.T) {
@(test) @(test)
test_save_config_no_clobber :: proc(t: ^testing.T) { test_save_config_no_clobber :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-cfg-noclobber-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-cfg-noclobber-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
@@ -123,8 +121,7 @@ test_save_config_no_clobber :: proc(t: ^testing.T) {
@(test) @(test)
test_save_config_force_overwrites :: proc(t: ^testing.T) { test_save_config_force_overwrites :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-cfg-force-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-cfg-force-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)

124
db.odin
View File

@@ -1,5 +1,6 @@
package main package main
import "base:runtime"
import "core:crypto/hash" import "core:crypto/hash"
import "core:encoding/hex" import "core:encoding/hex"
import "core:encoding/ini" import "core:encoding/ini"
@@ -38,21 +39,21 @@ Db :: struct {
} }
EnvFile :: struct { EnvFile :: struct {
Path: string, path: string,
Dir: string, dir: string,
Remotes: [dynamic]string, remotes: [dynamic]string,
Sha256: string, sha256: string,
contents: string, contents: string,
} }
@(deprecated = "call db_close to clean up EnvFiles") @(deprecated = "call db_close to clean up EnvFiles")
delete_envfile :: proc(f: ^EnvFile) { delete_envfile :: proc(f: ^EnvFile) {
delete(f.Path) delete(f.path)
for &remote in f.Remotes { for &remote in f.remotes {
delete(remote) delete(remote)
} }
delete(f.Remotes) delete(f.remotes)
delete(f.Sha256) delete(f.sha256)
delete(f.contents) delete(f.contents)
} }
@@ -60,6 +61,18 @@ db_open :: proc(cfg_path: string) -> (db: Db, ok: bool) {
db = db_init() or_return db = db_init() or_return
db.cfg = load_config(cfg_path, db_allocator(&db)) or_return db.cfg = load_config(cfg_path, db_allocator(&db)) or_return
if len(db.cfg.keys) == 0 {
fmt.eprintf("Error: no SSH keys configured in %s\n", cfg_path)
db_close(&db)
return db, false
}
_, keys_ok := ssh_to_x25519(db.cfg.keys[:], context.temp_allocator)
if !keys_ok {
db_close(&db)
return db, false
}
// TODO: Use different allocators? // TODO: Use different allocators?
data_path := data_path(db.cfg.config_path, context.temp_allocator) data_path := data_path(db.cfg.config_path, context.temp_allocator)
if os.exists(data_path) { if os.exists(data_path) {
@@ -138,6 +151,8 @@ db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
return true return true
} }
// db_close will fail silently if cfg.keys is empty. If you want to save the
// Db, be sure to use db_open rather than db_init
db_close :: proc(db: ^Db) { db_close :: proc(db: ^Db) {
allocator := db_allocator(db) allocator := db_allocator(db)
@@ -149,7 +164,7 @@ db_close :: proc(db: ^Db) {
mem.dynamic_arena_destroy(&db.arena) mem.dynamic_arena_destroy(&db.arena)
} }
if db.changed { if db.changed && len(db.cfg.keys) > 0 {
rc := sqlite.exec(db.conn, "VACUUM", nil, nil, nil) rc := sqlite.exec(db.conn, "VACUUM", nil, nil, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error vacuuming database: %s\n", sqlite.errmsg(db.conn)) fmt.printf("Error vacuuming database: %s\n", sqlite.errmsg(db.conn))
@@ -168,7 +183,8 @@ db_close :: proc(db: ^Db) {
// TODO: PAss allocator chain // TODO: PAss allocator chain
encrypted, enc_ok := encrypt(sqlite_data, db.cfg.keys[:]) encrypted, enc_ok := encrypt(sqlite_data, db.cfg.keys[:])
if !enc_ok { if !enc_ok {
fmt.println("Error: encryption failed") fmt.eprintln("Database encryption failed")
return return
} }
@@ -219,17 +235,20 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
remotes_json := string(sqlite.column_text(stmt, 1)) remotes_json := string(sqlite.column_text(stmt, 1))
remotes: [dynamic]string = --- remotes: [dynamic]string = ---
if len(remotes_json) > 0 { if len(remotes_json) > 0 {
json.unmarshal_string(remotes_json, &remotes, allocator = allocator) err := json.unmarshal_string(remotes_json, &remotes, allocator = allocator)
if err != nil {
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err)
}
} }
path := clone_cstring(sqlite.column_text(stmt, 0), allocator) path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
append( append(
&results, &results,
EnvFile { EnvFile {
Path = path, path = path,
Dir = filepath.dir(path), dir = filepath.dir(path),
Remotes = remotes, remotes = remotes,
Sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator), sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator),
contents = clone_cstring(sqlite.column_text(stmt, 3), allocator), contents = clone_cstring(sqlite.column_text(stmt, 3), allocator),
}, },
) )
@@ -240,7 +259,7 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
// TODO: Should we use context.temp_allocator for proc scoped lifetimes? // TODO: Should we use context.temp_allocator for proc scoped lifetimes?
db_insert :: proc(db: ^Db, file: EnvFile) -> bool { db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
remotes_json, marshal_err := json.marshal(file.Remotes, allocator = context.temp_allocator) remotes_json, marshal_err := json.marshal(file.remotes, allocator = context.temp_allocator)
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling remotes: %v\n", marshal_err) fmt.printf("Error marshaling remotes: %v\n", marshal_err)
return false return false
@@ -258,7 +277,7 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
// TODO: deal with elsewhere? // TODO: deal with elsewhere?
cpath := to_cstring(file.Path) cpath := to_cstring(file.path)
defer delete(cpath) defer delete(cpath)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
@@ -274,7 +293,7 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
return false return false
} }
csha := to_cstring(file.Sha256) csha := to_cstring(file.sha256)
defer delete(csha) defer delete(csha)
rc = sqlite.bind_text(stmt, 3, csha, -1, nil) rc = sqlite.bind_text(stmt, 3, csha, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
@@ -301,7 +320,11 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
} }
// Result will be freed when `db_close` is called. // Result will be freed when `db_close` is called.
//
// Expects an absolute path
db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) { db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
assert(os.is_absolute_path(path))
sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?" sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?"
stmt: sqlite.Stmt stmt: sqlite.Stmt
rc := sqlite.prepare_v2(db.conn, sql, -1, &stmt, nil) rc := sqlite.prepare_v2(db.conn, sql, -1, &stmt, nil)
@@ -333,16 +356,19 @@ db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
remotes_json := string(sqlite.column_text(stmt, 1)) remotes_json := string(sqlite.column_text(stmt, 1))
remotes: [dynamic]string = --- remotes: [dynamic]string = ---
if len(remotes_json) > 0 { if len(remotes_json) > 0 {
json.unmarshal_string(remotes_json, &remotes, allocator = allocator) err := json.unmarshal_string(remotes_json, &remotes, allocator = allocator)
if err != nil {
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err)
}
} }
file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator) file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
return EnvFile { return EnvFile {
Path = file_path, path = file_path,
Dir = filepath.dir(file_path), dir = filepath.dir(file_path),
Remotes = remotes, remotes = remotes,
Sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator), sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator),
contents = clone_cstring(sqlite.column_text(stmt, 3), allocator), contents = clone_cstring(sqlite.column_text(stmt, 3), allocator),
}, },
true true
@@ -380,6 +406,7 @@ db_delete :: proc(db: ^Db, path: string) -> bool {
return true return true
} }
// Caller is responsible for the returned memory
new_env_file :: proc(path: string) -> (EnvFile, bool) { new_env_file :: proc(path: string) -> (EnvFile, bool) {
abs_path, abs_err := filepath.abs(path) abs_path, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
@@ -393,21 +420,18 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
remotes := get_git_remotes(dir, context.allocator) remotes := get_git_remotes(dir, context.allocator)
data, read_err := os.read_entire_file_from_path(abs_path, context.allocator) data, read_err := os.read_entire_file_from_path(abs_path, context.allocator)
defer delete(data)
if read_err != nil { if read_err != nil {
fmt.printf("Error reading file %s: %v\n", abs_path, read_err) fmt.printf("Error reading file %s: %v\n", abs_path, read_err)
return EnvFile{}, false return EnvFile{}, false
} }
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator) digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
// TODO: Handle error hex_bytes := hex.encode(digest, context.allocator)
hex_bytes, _ := hex.encode(digest)
return EnvFile { return EnvFile {
Path = abs_path, path = abs_path,
Dir = dir, dir = dir,
Remotes = remotes, remotes = remotes,
Sha256 = string(hex_bytes), sha256 = string(hex_bytes),
contents = string(data), contents = string(data),
}, },
true true
@@ -417,9 +441,9 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) { db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
allocator := db_allocator(db) allocator := db_allocator(db)
result: SyncFlag = {} result: SyncFlag = {}
old_path := f.Path old_path := f.path
if !os.exists(f.Dir) { if !os.exists(f.dir) {
moved, err := try_move_dir(db, f, allocator) moved, err := try_move_dir(db, f, allocator)
if !moved { if !moved {
return {}, err return {}, err
@@ -427,10 +451,10 @@ db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
result += {.DirUpdated} result += {.DirUpdated}
} }
if !os.exists(f.Path) { if !os.exists(f.path) {
write_err := os.write_entire_file(f.Path, f.contents) write_err := os.write_entire_file(f.path, f.contents)
if write_err != nil { if write_err != nil {
fmt.eprintf("db_sync: failed to write %s: %v\n", f.Path, write_err) fmt.eprintf("db_sync: failed to write %s: %v\n", f.path, write_err)
return result, .WriteFailed return result, .WriteFailed
} }
@@ -440,21 +464,17 @@ db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
return result + {.Restored}, .None return result + {.Restored}, .None
} }
data, read_err := os.read_entire_file_from_path(f.Path, allocator) data, read_err := os.read_entire_file_from_path(f.path, allocator)
if read_err != nil { if read_err != nil {
fmt.eprintf("db_sync: failed to read %s: %v\n", f.Path, read_err) fmt.eprintf("db_sync: failed to read %s: %v\n", f.path, read_err)
return result, .ReadFailed return result, .ReadFailed
} }
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator) digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
hex_bytes, hex_err := hex.encode(digest, allocator) hex_bytes := hex.encode(digest, allocator)
if hex_err != nil {
fmt.eprintf("db_sync: failed to encode hash for %s: %v\n", f.Path, hex_err)
return result, .ReadFailed
}
current_sha := string(hex_bytes) current_sha := string(hex_bytes)
if current_sha == f.Sha256 { if current_sha == f.sha256 {
if !db_persist(db, f, old_path) { if !db_persist(db, f, old_path) {
return result, .DbFailed return result, .DbFailed
} }
@@ -462,7 +482,7 @@ db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
} }
f.contents = string(data) f.contents = string(data)
f.Sha256 = current_sha f.sha256 = current_sha
if !db_persist(db, f, old_path) { if !db_persist(db, f, old_path) {
return result, .DbFailed return result, .DbFailed
} }
@@ -470,7 +490,7 @@ db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
} }
db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool { db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool {
if f.Path != old_path { if f.path != old_path {
if !db_delete(db, old_path) { if !db_delete(db, old_path) {
return false return false
} }
@@ -504,11 +524,11 @@ try_move_dir :: proc(db: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, S
case 0: case 0:
return false, .DirMissing return false, .DirMissing
case 1: case 1:
f.Dir, _ = strings.clone(matched_dir, allocator) f.dir, _ = strings.clone(matched_dir, allocator)
base := filepath.base(f.Path) base := filepath.base(f.path)
new_path, _ := filepath.join({f.Dir, base}, allocator) new_path, _ := filepath.join({f.dir, base}, allocator)
f.Path = new_path f.path = new_path
f.Remotes = get_git_remotes(f.Dir, allocator) f.remotes = get_git_remotes(f.dir, allocator)
return true, .None return true, .None
case: case:
return false, .MultipleDirs return false, .MultipleDirs
@@ -516,7 +536,7 @@ try_move_dir :: proc(db: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, S
} }
shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool { shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
for r1 in f.Remotes { for r1 in f.remotes {
for r2 in remotes { for r2 in remotes {
if r1 == r2 { if r1 == r2 {
return true return true

View File

@@ -11,6 +11,14 @@ import "sqlite"
FIXTURES :: "fixtures" FIXTURES :: "fixtures"
test_temp_dir :: proc(t: ^testing.T, prefix: string) -> string {
dir, err := os.mkdir_temp("", prefix, context.temp_allocator)
if err != nil {
testing.fail_now(t, fmt.tprintf("Failed to create temp dir: %v", err))
}
return dir
}
fixture_key :: proc() -> SshKeyPair { fixture_key :: proc() -> SshKeyPair {
priv, _ := strings.concatenate( priv, _ := strings.concatenate(
[]string{FIXTURES, "/keys/insecure-test-key"}, []string{FIXTURES, "/keys/insecure-test-key"},
@@ -111,13 +119,14 @@ test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
} }
defer delete(encrypted) defer delete(encrypted)
tmp_enc_path := fmt.tprintf("/tmp/envr-test-ewrd-%d.envr", os.get_pid()) ewrd_dir := test_temp_dir(t, "envr-test-ewrd-*")
defer os.remove_all(ewrd_dir)
tmp_enc_path, _ := filepath.join([]string{ewrd_dir, "data.envr"}, context.temp_allocator)
write_err := os.write_entire_file(tmp_enc_path, encrypted) write_err := os.write_entire_file(tmp_enc_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write encrypted file: %v", write_err) testing.expectf(t, write_err == nil, "failed to write encrypted file: %v", write_err)
if write_err != nil { if write_err != nil {
return return
} }
defer os.remove(tmp_enc_path)
read_back, rb_err := os.read_entire_file_from_path(tmp_enc_path, context.allocator) read_back, rb_err := os.read_entire_file_from_path(tmp_enc_path, context.allocator)
testing.expectf(t, rb_err == nil, "failed to read back encrypted file: %v", rb_err) testing.expectf(t, rb_err == nil, "failed to read back encrypted file: %v", rb_err)
@@ -223,11 +232,15 @@ test_full_db_cycle :: proc(t: ^testing.T) {
} }
defer delete(encrypted) defer delete(encrypted)
envr_dir_path := fmt.tprintf("/tmp/envr-test-cycle-%d/.envr", os.get_pid()) cycle_dir := test_temp_dir(t, "envr-test-cycle-*")
os.mkdir_all(envr_dir_path) defer os.remove_all(cycle_dir)
envr_dir_path, _ := filepath.join([]string{cycle_dir, ".envr"}, context.temp_allocator)
{
err := os.mkdir_all(envr_dir_path)
testing.expect_value(t, err, nil)
}
data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"}) data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"}, context.temp_allocator)
defer delete(data_path)
write_err := os.write_entire_file(data_path, encrypted) write_err := os.write_entire_file(data_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err) testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err)
if write_err != nil { if write_err != nil {

View File

@@ -13,14 +13,14 @@ import "sqlite"
make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {}) -> EnvFile { make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {}) -> EnvFile {
f := EnvFile { f := EnvFile {
Path = path, path = path,
Dir = "", dir = "",
Sha256 = sha, sha256 = sha,
contents = contents, contents = contents,
Remotes = make([dynamic]string, 0, len(remotes), context.temp_allocator), remotes = make([dynamic]string, 0, len(remotes), context.temp_allocator),
} }
for r in remotes { for r in remotes {
append(&f.Remotes, r) append(&f.remotes, r)
} }
return f return f
} }
@@ -37,7 +37,7 @@ test_db_insert_and_fetch :: proc(t: ^testing.T) {
contents := "SECRET=value" contents := "SECRET=value"
f := make_test_env_file(path, sha, contents, []string{"git@github.com:user/repo.git"}) f := make_test_env_file(path, sha, contents, []string{"git@github.com:user/repo.git"})
defer delete(f.Remotes) defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed") testing.expect(t, db_insert(&db, f), "insert should succeed")
@@ -46,11 +46,11 @@ test_db_insert_and_fetch :: proc(t: ^testing.T) {
testing.expect(t, fetch_ok, "fetch should succeed") testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return if !fetch_ok do return
testing.expect_value(t, fetched.Path, path) testing.expect_value(t, fetched.path, path)
testing.expect_value(t, fetched.Sha256, sha) testing.expect_value(t, fetched.sha256, sha)
testing.expect_value(t, fetched.contents, contents) testing.expect_value(t, fetched.contents, contents)
testing.expect_value(t, len(fetched.Remotes), 1) testing.expect_value(t, len(fetched.remotes), 1)
testing.expect_value(t, fetched.Remotes[0], "git@github.com:user/repo.git") testing.expect_value(t, fetched.remotes[0], "git@github.com:user/repo.git")
} }
@(test) @(test)
@@ -71,11 +71,11 @@ test_db_insert_or_replace :: proc(t: ^testing.T) {
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
f1 := make_test_env_file("/project/.env", "sha1", "KEY=old") f1 := make_test_env_file("/project/.env", "sha1", "KEY=old")
defer delete(f1.Remotes) defer delete(f1.remotes)
testing.expect(t, db_insert(&db, f1), "first insert should succeed") testing.expect(t, db_insert(&db, f1), "first insert should succeed")
f2 := make_test_env_file("/project/.env", "sha2", "KEY=new") f2 := make_test_env_file("/project/.env", "sha2", "KEY=new")
defer delete(f2.Remotes) defer delete(f2.remotes)
testing.expect(t, db_insert(&db, f2), "second insert should succeed") testing.expect(t, db_insert(&db, f2), "second insert should succeed")
results, list_ok := db_list(&db) results, list_ok := db_list(&db)
@@ -89,7 +89,7 @@ test_db_insert_or_replace :: proc(t: ^testing.T) {
// defer delete_envfile(&fetched) // defer delete_envfile(&fetched)
testing.expect_value(t, fetched.contents, "KEY=new") testing.expect_value(t, fetched.contents, "KEY=new")
testing.expect_value(t, fetched.Sha256, "sha2") testing.expect_value(t, fetched.sha256, "sha2")
} }
@(test) @(test)
@@ -100,7 +100,7 @@ test_db_delete_existing :: proc(t: ^testing.T) {
defer db_close(&db) defer db_close(&db)
f := make_test_env_file("/project/.env", "sha", "KEY=val") f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes) defer delete(f.remotes)
db_insert(&db, f) db_insert(&db, f)
testing.expect(t, db_delete(&db, "/project/.env"), "delete should return true") testing.expect(t, db_delete(&db, "/project/.env"), "delete should return true")
@@ -126,9 +126,9 @@ test_db_list_multiple :: proc(t: ^testing.T) {
defer db_close(&db) defer db_close(&db)
f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"}) f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"})
defer delete(f1.Remotes) defer delete(f1.remotes)
f2 := make_test_env_file("/proj2/.env", "sha2", "B=2", []string{"git@github.com:b/repo.git"}) f2 := make_test_env_file("/proj2/.env", "sha2", "B=2", []string{"git@github.com:b/repo.git"})
defer delete(f2.Remotes) defer delete(f2.remotes)
f3 := make_test_env_file("/proj3/.env", "sha3", "C=3") f3 := make_test_env_file("/proj3/.env", "sha3", "C=3")
db_insert(&db, f1) db_insert(&db, f1)
@@ -162,7 +162,7 @@ test_db_insert_sets_changed :: proc(t: ^testing.T) {
testing.expect(t, !db.changed, "changed should start false") testing.expect(t, !db.changed, "changed should start false")
f := make_test_env_file("/project/.env", "sha", "KEY=val") f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes) defer delete(f.remotes)
db_insert(&db, f) db_insert(&db, f)
testing.expect(t, db.changed, "changed should be true after insert") testing.expect(t, db.changed, "changed should be true after insert")
@@ -176,7 +176,7 @@ test_db_delete_sets_changed :: proc(t: ^testing.T) {
defer db_close(&db) defer db_close(&db)
f := make_test_env_file("/project/.env", "sha", "KEY=val") f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes) defer delete(f.remotes)
db_insert(&db, f) db_insert(&db, f)
db.changed = false db.changed = false
@@ -192,7 +192,7 @@ test_db_serialize :: proc(t: ^testing.T) {
defer db_close(&db) defer db_close(&db)
f := make_test_env_file("/project/.env", "sha", "KEY=val") f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes) defer delete(f.remotes)
db_insert(&db, f) db_insert(&db, f)
sz: i64 sz: i64
@@ -207,10 +207,10 @@ test_db_serialize :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_overlap :: proc(t: ^testing.T) { test_shares_remote_overlap :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
Remotes = make([dynamic]string, 2, context.temp_allocator), remotes = make([dynamic]string, 2, context.temp_allocator),
} }
append(&f.Remotes, "git@github.com:user/repo.git") append(&f.remotes, "git@github.com:user/repo.git")
append(&f.Remotes, "git@gitlab.com:user/repo.git") append(&f.remotes, "git@gitlab.com:user/repo.git")
remotes := []string{"git@github.com:user/repo.git"} remotes := []string{"git@github.com:user/repo.git"}
testing.expect(t, shares_remote(&f, remotes), "should share remote") testing.expect(t, shares_remote(&f, remotes), "should share remote")
@@ -219,9 +219,9 @@ test_shares_remote_overlap :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_no_overlap :: proc(t: ^testing.T) { test_shares_remote_no_overlap :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
Remotes = make([dynamic]string, 1, context.temp_allocator), remotes = make([dynamic]string, 1, context.temp_allocator),
} }
append(&f.Remotes, "git@github.com:user/repo.git") append(&f.remotes, "git@github.com:user/repo.git")
remotes := []string{"git@github.com:other/repo.git"} remotes := []string{"git@github.com:other/repo.git"}
testing.expect(t, !shares_remote(&f, remotes), "should not share remote") testing.expect(t, !shares_remote(&f, remotes), "should not share remote")
@@ -230,7 +230,7 @@ test_shares_remote_no_overlap :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) { test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
Remotes = make([dynamic]string, 0, context.temp_allocator), remotes = make([dynamic]string, 0, context.temp_allocator),
} }
remotes := []string{"git@github.com:user/repo.git"} remotes := []string{"git@github.com:user/repo.git"}
@@ -240,9 +240,9 @@ test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) { test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
Remotes = make([dynamic]string, 1, context.temp_allocator), remotes = make([dynamic]string, 1, context.temp_allocator),
} }
append(&f.Remotes, "git@github.com:user/repo.git") append(&f.remotes, "git@github.com:user/repo.git")
remotes: []string remotes: []string
testing.expect(t, !shares_remote(&f, remotes), "empty check remotes should not share") testing.expect(t, !shares_remote(&f, remotes), "empty check remotes should not share")
@@ -251,7 +251,7 @@ test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_both_empty :: proc(t: ^testing.T) { test_shares_remote_both_empty :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
Remotes = make([dynamic]string, 0), remotes = make([dynamic]string, 0),
} }
remotes: []string remotes: []string
@@ -267,8 +267,7 @@ delete_remotes :: proc(remotes: [dynamic]string) {
@(test) @(test)
test_get_git_remotes_single :: proc(t: ^testing.T) { test_get_git_remotes_single :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-remotes-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-remotes-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base) git_dir := fmt.tprintf("%s/.git", base)
@@ -288,8 +287,7 @@ test_get_git_remotes_single :: proc(t: ^testing.T) {
@(test) @(test)
test_get_git_remotes_multiple :: proc(t: ^testing.T) { test_get_git_remotes_multiple :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-remotes-multi-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-remotes-multi-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base) git_dir := fmt.tprintf("%s/.git", base)
@@ -307,8 +305,7 @@ test_get_git_remotes_multiple :: proc(t: ^testing.T) {
@(test) @(test)
test_get_git_remotes_no_config :: proc(t: ^testing.T) { test_get_git_remotes_no_config :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-remotes-none-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-remotes-none-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base, context.temp_allocator)
@@ -318,8 +315,7 @@ test_get_git_remotes_no_config :: proc(t: ^testing.T) {
@(test) @(test)
test_get_git_remotes_no_remotes :: proc(t: ^testing.T) { test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-remotes-empty-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-remotes-empty-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base) git_dir := fmt.tprintf("%s/.git", base)
@@ -337,8 +333,7 @@ test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
@(test) @(test)
test_new_env_file :: proc(t: ^testing.T) { test_new_env_file :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-envfile-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-envfile-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base) env_path := fmt.tprintf("%s/.env", base)
@@ -348,14 +343,15 @@ test_new_env_file :: proc(t: ^testing.T) {
file, ok := new_env_file(env_path) file, ok := new_env_file(env_path)
testing.expect(t, ok, "new_env_file should succeed") testing.expect(t, ok, "new_env_file should succeed")
if !ok do return if !ok do return
defer delete(file.Remotes) defer delete(file.contents)
defer delete(file.Sha256) defer delete(file.remotes)
defer delete(file.Path) defer delete(file.sha256)
defer delete(file.path)
testing.expect(t, filepath.is_abs(file.Path), "path should be absolute") testing.expect(t, filepath.is_abs(file.path), "path should be absolute")
testing.expect(t, strings.has_suffix(file.Path, "/.env"), "path should end with /.env") testing.expect(t, strings.has_suffix(file.path, "/.env"), "path should end with /.env")
testing.expect(t, file.contents == "SECRET=value\n", "contents mismatch") testing.expect(t, file.contents == "SECRET=value\n", "contents mismatch")
testing.expect(t, len(file.Sha256) == 64, "sha256 should be 64 hex chars") testing.expect(t, len(file.sha256) == 64, "sha256 should be 64 hex chars")
} }
@(test) @(test)
@@ -366,8 +362,7 @@ test_new_env_file_missing :: proc(t: ^testing.T) {
@(test) @(test)
test_closing_db_has_no_leaks :: proc(t: ^testing.T) { test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-leak-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-leak-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
@@ -386,8 +381,7 @@ test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
@(test) @(test)
test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) { test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-leak-existing-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-leak-existing-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
@@ -409,7 +403,7 @@ test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
"SECRET=value", "SECRET=value",
[]string{"git@github.com:user/repo.git"}, []string{"git@github.com:user/repo.git"},
) )
defer delete(f.Remotes) defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed") testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db) db_close(&db)
@@ -422,8 +416,7 @@ test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
@(test) @(test)
test_db_sync_noop :: proc(t: ^testing.T) { test_db_sync_noop :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-sync-noop-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-sync-noop-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base) env_path := fmt.tprintf("%s/.env", base)
@@ -436,7 +429,7 @@ test_db_sync_noop :: proc(t: ^testing.T) {
transmute([]u8)content, transmute([]u8)content,
context.temp_allocator, context.temp_allocator,
) )
hex_bytes, _ := hex.encode(digest, context.temp_allocator) hex_bytes := hex.encode(digest, context.temp_allocator)
sha := string(hex_bytes) sha := string(hex_bytes)
db, ok := db_init() db, ok := db_init()
@@ -444,7 +437,7 @@ test_db_sync_noop :: proc(t: ^testing.T) {
defer db_close(&db) defer db_close(&db)
f := make_test_env_file(env_path, sha, content) f := make_test_env_file(env_path, sha, content)
f.Dir = base f.dir = base
db_insert(&db, f) db_insert(&db, f)
result, sync_err := db_sync(&db, &f) result, sync_err := db_sync(&db, &f)
@@ -454,8 +447,7 @@ test_db_sync_noop :: proc(t: ^testing.T) {
@(test) @(test)
test_db_sync_backed_up :: proc(t: ^testing.T) { test_db_sync_backed_up :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-sync-backup-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-sync-backup-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base) env_path := fmt.tprintf("%s/.env", base)
@@ -468,7 +460,7 @@ test_db_sync_backed_up :: proc(t: ^testing.T) {
defer db_close(&db) defer db_close(&db)
f := make_test_env_file(env_path, "old_sha", "KEY=original") f := make_test_env_file(env_path, "old_sha", "KEY=original")
f.Dir = base f.dir = base
db_insert(&db, f) db_insert(&db, f)
result, sync_err := db_sync(&db, &f) result, sync_err := db_sync(&db, &f)
@@ -478,8 +470,7 @@ test_db_sync_backed_up :: proc(t: ^testing.T) {
@(test) @(test)
test_db_sync_restored :: proc(t: ^testing.T) { test_db_sync_restored :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-sync-restore-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-sync-restore-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base) env_path := fmt.tprintf("%s/.env", base)
@@ -489,8 +480,8 @@ test_db_sync_restored :: proc(t: ^testing.T) {
defer db_close(&db) defer db_close(&db)
f := make_test_env_file(env_path, "some_sha", "SECRET=value") f := make_test_env_file(env_path, "some_sha", "SECRET=value")
f.Dir = base f.dir = base
defer delete(f.Remotes) defer delete(f.remotes)
db_insert(&db, f) db_insert(&db, f)
result, err := db_sync(&db, &f) result, err := db_sync(&db, &f)
@@ -520,7 +511,7 @@ test_db_sync_dir_missing :: proc(t: ^testing.T) {
@(test) @(test)
test_db_sync_moved :: proc(t: ^testing.T) { test_db_sync_moved :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-sync-moved-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-sync-moved-*")
search_root := fmt.tprintf("%s/search", base) search_root := fmt.tprintf("%s/search", base)
repo_dir := fmt.tprintf("%s/myproject", search_root) repo_dir := fmt.tprintf("%s/myproject", search_root)
git_dir := fmt.tprintf("%s/.git", repo_dir) git_dir := fmt.tprintf("%s/.git", repo_dir)
@@ -555,8 +546,8 @@ test_db_sync_moved :: proc(t: ^testing.T) {
testing.expect(t, .Restored in result, "should have Restored flag") testing.expect(t, .Restored in result, "should have Restored flag")
expected_path := fmt.tprintf("%s/.env", repo_dir) expected_path := fmt.tprintf("%s/.env", repo_dir)
testing.expect_value(t, f.Path, expected_path) testing.expect_value(t, f.path, expected_path)
testing.expect_value(t, f.Dir, repo_dir) testing.expect_value(t, f.dir, repo_dir)
_, old_exists := db_fetch(&db, "/old/nonexistent/path/.env") _, old_exists := db_fetch(&db, "/old/nonexistent/path/.env")
testing.expect(t, !old_exists, "old path should be deleted from db") testing.expect(t, !old_exists, "old path should be deleted from db")

View File

@@ -19,7 +19,7 @@ scan_path :: proc(search_path: string, cfg: Config) -> (paths: [dynamic]string,
find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> []string { find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> []string {
backed_set := make(map[string]bool, len(db_files), context.temp_allocator) backed_set := make(map[string]bool, len(db_files), context.temp_allocator)
for file in db_files { for file in db_files {
backed_set[file.Path] = true backed_set[file.path] = true
} }
unbacked := make([dynamic]string, 0, len(db_files) / 2, context.temp_allocator) unbacked := make([dynamic]string, 0, len(db_files) / 2, context.temp_allocator)

View File

@@ -8,8 +8,7 @@ import "core:testing"
@(test) @(test)
test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) { test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-scan-test-%d", os.get_pid()) base := test_temp_dir(t, "envr-scan-test-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
git_init := os.Process_Desc { git_init := os.Process_Desc {
@@ -19,20 +18,23 @@ test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
stderr = os.stderr, stderr = os.stderr,
} }
p, err := os.process_start(git_init) p, err := os.process_start(git_init)
if err != nil { testing.expectf(t, err == nil, "Failed to run git: %v", err)
return if err != nil do return
} state, wait_err := os.process_wait(p)
_, wait_err := os.process_wait(p) testing.expectf(t, wait_err == nil, "Failed to wait: %v", wait_err)
if wait_err != nil { if wait_err != nil do return
return testing.expect(t, state.success, "command should succeed")
}
gitignore_path := fmt.tprintf("%s/.gitignore", base) gitignore_path := fmt.tprintf("%s/.gitignore", base)
_ = os.write_entire_file(gitignore_path, ".env*\n") err = os.write_entire_file(gitignore_path, ".env*\n")
testing.expectf(t, err == nil, "Failed: %v", err)
_ = os.write_entire_file(fmt.tprintf("%s/.env", base), "SECRET=1") err = os.write_entire_file(fmt.tprintf("%s/.env", base), "SECRET=1")
_ = os.write_entire_file(fmt.tprintf("%s/.env.testing", base), "TEST=1") testing.expectf(t, err == nil, "Failed: %v", err)
_ = os.write_entire_file(fmt.tprintf("%s/config.yaml", base), "key: value") err = os.write_entire_file(fmt.tprintf("%s/.env.testing", base), "TEST=1")
testing.expectf(t, err == nil, "Failed: %v", err)
err = os.write_entire_file(fmt.tprintf("%s/config.yaml", base), "key: value")
testing.expectf(t, err == nil, "Failed: %v", err)
cfg := Config { cfg := Config {
scan_config = ScanConfig{matcher = "\\.env"}, scan_config = ScanConfig{matcher = "\\.env"},
@@ -71,8 +73,7 @@ test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
@(test) @(test)
test_scan_path_empty_dir :: proc(t: ^testing.T) { test_scan_path_empty_dir :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-scan-empty-%d", os.get_pid()) base := test_temp_dir(t, "envr-scan-empty-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfg := Config { cfg := Config {

View File

@@ -2,7 +2,9 @@ package main
import "base:runtime" import "base:runtime"
import "core:encoding/base64" import "core:encoding/base64"
import "core:encoding/endian"
import "core:fmt" import "core:fmt"
import "core:mem"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
@@ -44,9 +46,7 @@ parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) {
return return
} }
for i in 0 ..< 32 { mem.copy_non_overlapping(&pub[0], raw_data(pk_data), 32)
pub[i] = pk_data[i]
}
ok = true ok = true
return return
@@ -86,15 +86,10 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
return return
} }
magic := "openssh-key-v1\x00" magic :: "openssh-key-v1\x00"
if len(decoded) < len(magic) { if !strings.has_prefix(string(decoded), magic) {
return return
} }
for i in 0 ..< len(magic) {
if decoded[i] != u8(magic[i]) {
return
}
}
offset := len(magic) offset := len(magic)
@@ -116,11 +111,8 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
if offset + 4 > len(decoded) { if offset + 4 > len(decoded) {
return return
} }
num_keys :=
u32(decoded[offset]) << 24 | num_keys := endian.get_u32(decoded[offset:offset + 4], .Big) or_return
u32(decoded[offset + 1]) << 16 |
u32(decoded[offset + 2]) << 8 |
u32(decoded[offset + 3])
offset += 4 offset += 4
if num_keys != 1 { if num_keys != 1 {
@@ -141,17 +133,16 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
if inner_offset + 8 > len(priv_blob) { if inner_offset + 8 > len(priv_blob) {
return return
} }
check1 :=
u32(priv_blob[inner_offset]) << 24 | check1 := endian.get_u32(
u32(priv_blob[inner_offset + 1]) << 16 | transmute([]u8)(priv_blob)[inner_offset:inner_offset + 4],
u32(priv_blob[inner_offset + 2]) << 8 | .Big,
u32(priv_blob[inner_offset + 3]) ) or_return
inner_offset += 4 inner_offset += 4
check2 := check2 := endian.get_u32(
u32(priv_blob[inner_offset]) << 24 | transmute([]u8)(priv_blob)[inner_offset:inner_offset + 4],
u32(priv_blob[inner_offset + 1]) << 16 | .Big,
u32(priv_blob[inner_offset + 2]) << 8 | ) or_return
u32(priv_blob[inner_offset + 3])
inner_offset += 4 inner_offset += 4
if check1 != check2 { if check1 != check2 {
@@ -167,17 +158,14 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
if !pub_ok || len(pub_wire) != 32 { if !pub_ok || len(pub_wire) != 32 {
return return
} }
for i in 0 ..< 32 { mem.copy_non_overlapping(&kp.Public[0], raw_data(pub_wire), 32)
kp.Public[i] = pub_wire[i]
}
priv_wire, priv_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset) priv_wire, priv_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !priv_ok || len(priv_wire) != 64 { if !priv_ok || len(priv_wire) != 64 {
return return
} }
for i in 0 ..< 32 {
kp.Private[i] = priv_wire[i] mem.copy_non_overlapping(&kp.Private[0], raw_data(priv_wire), 32)
}
ok = true ok = true
return return
@@ -198,11 +186,7 @@ read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) {
if offset^ + 4 > len(data) { if offset^ + 4 > len(data) {
return return
} }
length := length := endian.get_u32(data[offset^:offset^ + 4], .Big) or_return
u32(data[offset^]) << 24 |
u32(data[offset^ + 1]) << 16 |
u32(data[offset^ + 2]) << 8 |
u32(data[offset^ + 3])
offset^ += 4 offset^ += 4
if offset^ + int(length) > len(data) { if offset^ + int(length) > len(data) {

View File

@@ -1,5 +1,7 @@
package main package main
import "core:fmt"
import "core:io"
import "core:text/table" import "core:text/table"
import "core:unicode/utf8" import "core:unicode/utf8"
@@ -34,3 +36,44 @@ ansi_aware_width :: proc(str: string) -> int {
return width return width
} }
write_borderless_table :: proc(w: io.Writer, t: ^table.Table) {
table.build(t, ansi_aware_width)
write_table_separator :: proc(w: io.Writer, tbl: ^table.Table) {
io.write_byte(w, '\n')
}
if t.caption != "" {
table.write_text_align(
w,
fmt.tprintf("%s%s%s", COLOR_HEADINGS, t.caption, ANSI_RESET),
.Left,
0, //t.lpad,
0, //t.rpad,
t.tblw + t.nr_cols - 1 - ansi_aware_width(t.caption) - t.lpad - t.rpad,
)
io.write_byte(w, '\n')
}
write_table_separator(w, t)
for row in 0 ..< t.nr_rows {
for col in 0 ..< t.nr_cols {
table.write_table_cell(w, t, row, col)
}
io.write_byte(w, '\n')
if t.has_header_row && row == table.header_row(t) {
write_table_separator(w, t)
}
}
write_table_separator(w, t)
}
table_reset :: proc(t: ^table.Table) {
clear(&t.cells)
clear(&t.colw)
t.caption = ""
t.tblw = 0
t.nr_cols = 0
t.nr_rows = 0
}