diff --git a/TODOS.md b/TODOS.md index decd465..64ce93d 100644 --- a/TODOS.md +++ b/TODOS.md @@ -56,34 +56,34 @@ Note: These todos can wait until all the subcommands have been ported. 25. Add tests for untested commands. -26. Add a global --config -c flag to use an alternate config. - -27. version --long Odin only prints version; Go also prints commit hash and build date - -28. 2 scan tests silently skip Low When fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path. +28. 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. 38. Try to do all encryption / decryption in memory - only read / write encrypted data to disk. 40. use a buffered writer where possible (mem.DEFAULT_PAGE_SIZE) +41. add --format -f flag to commands that draw tables. + +42. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate. + ## Double-check AI output - [ ] cli.odin - [ ] cli_test.odin -- [ ] cmd_backup.odin -- [ ] cmd_check.odin +- [x] cmd_backup.odin +- [x] cmd_check.odin - [ ] cmd_check_test.odin -- [ ] cmd_deps.odin +- [x] cmd_deps.odin - [ ] cmd_edit_config.odin -- [ ] cmd_init.odin -- [ ] cmd_list.odin +- [x] cmd_init.odin +- [x] cmd_list.odin - [ ] cmd_list_test.odin -- [ ] cmd_nushell_completion.odin -- [ ] cmd_nushell_completion_test.odin -- [ ] cmd_remove.odin -- [ ] cmd_restore.odin -- [ ] cmd_scan.odin -- [ ] cmd_sync.odin +- [x] cmd_nushell_completion.odin +- [x] cmd_nushell_completion_test.odin +- [x] cmd_remove.odin +- [x] cmd_restore.odin +- [x] cmd_scan.odin +- [x] cmd_sync.odin - [x] cmd_version.odin - [ ] config.odin - [ ] config_test.odin @@ -92,10 +92,10 @@ Note: These todos can wait until all the subcommands have been ported. - [ ] db.odin - [ ] db_integration_test.odin - [ ] db_test.odin -- [ ] features.odin -- [ ] features_test.odin -- [ ] main.odin -- [ ] prompt.odin +- [x] features.odin +- [x] features_test.odin +- [x] main.odin +- [x] prompt.odin - [ ] scan.odin - [ ] scan_test.odin - [ ] sodium.odin diff --git a/cli.odin b/cli.odin index e5efa2e..fbf6ef1 100644 --- a/cli.odin +++ b/cli.odin @@ -56,6 +56,15 @@ COMMANDS := []CommandInfo { }, } +delete_command :: proc(cmd: ^Command) { + delete(cmd.args) + delete(cmd.flags) + delete(cmd.bool_set) + // delete(cmd.config_path) +} + +// Caller is responsible for calling delete_command(cmd). +// FIXME: Works in kinda a wonky and awkward way. parse_args :: proc(args: []string) -> (cmd: Command, ok: bool) { if len(args) < 2 || args[1] == "--help" || args[1] == "-h" { print_usage() @@ -101,8 +110,10 @@ parse_args :: proc(args: []string) -> (cmd: Command, ok: bool) { cmd.config_path = val } else { // FIXME: Handle err - home, _ := os.user_home_dir(context.allocator) - cmd.config_path = default_config_path(home) + // TODO: Is this right? + home, _ := os.user_home_dir(context.temp_allocator) + // TODO: should we copy out of the temp_allocator? + cmd.config_path = default_config_path(home, context.temp_allocator) } if has_flag(&cmd, "help") { @@ -157,7 +168,12 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool { fmt.wprintf(w, "\n%s\n", info.long, flush = false) } - fmt.wprintf(w, "\nFlags:\n -h, --help help for %s\n -c, --config-file config file (default \"~/.envr/config.json\")\n", info.name, flush = false) + fmt.wprintf( + w, + "\nFlags:\n -h, --help help for %s\n -c, --config-file config file (default \"~/.envr/config.json\")\n", + info.name, + flush = false, + ) return true } @@ -175,6 +191,7 @@ print_command_help :: proc(name: string) { bufio.writer_flush(&bw) } +// TODO: command args should be shown in usage. write_usage :: proc(w: io.Writer) { fmt.wprintf( w, diff --git a/cli_test.odin b/cli_test.odin index ccff77f..ccad4c0 100644 --- a/cli_test.odin +++ b/cli_test.odin @@ -194,14 +194,12 @@ test_parse_args_bare_command :: proc(t: ^testing.T) { cmd, ok := parse_args([]string{"envr", "list"}) testing.expect(t, ok, "should succeed") if !ok do return - defer delete(cmd.args) - defer delete(cmd.flags) - defer delete(cmd.bool_set) + defer delete_command(&cmd) - testing.expect(t, cmd.name == "list", "name should be list") - testing.expect(t, len(cmd.args) == 0, "should have no positional args") - testing.expect(t, len(cmd.flags) == 0, "should have no flags") - testing.expect(t, len(cmd.bool_set) == 0, "should have no bool flags") + testing.expect_value(t, cmd.name, "list") + testing.expect_value(t, len(cmd.args), 0) + testing.expect_value(t, len(cmd.flags), 0) + testing.expect_value(t, len(cmd.bool_set), 0) } @(test) @@ -209,9 +207,7 @@ test_parse_args_positional :: proc(t: ^testing.T) { cmd, ok := parse_args([]string{"envr", "backup", "/project/.env"}) testing.expect(t, ok, "should succeed") if !ok do return - defer delete(cmd.args) - defer delete(cmd.flags) - defer delete(cmd.bool_set) + defer delete_command(&cmd) testing.expect(t, cmd.name == "backup") testing.expect(t, len(cmd.args) == 1) @@ -223,9 +219,7 @@ test_parse_args_long_flag_with_value :: proc(t: ^testing.T) { cmd, ok := parse_args([]string{"envr", "sync", "--config", "x.json"}) testing.expect(t, ok, "should succeed") if !ok do return - defer delete(cmd.args) - defer delete(cmd.flags) - defer delete(cmd.bool_set) + defer delete_command(&cmd) testing.expect(t, cmd.flags["config"] == "x.json") } @@ -235,9 +229,7 @@ test_parse_args_short_flag_with_value :: proc(t: ^testing.T) { cmd, ok := parse_args([]string{"envr", "sync", "-c", "x.json"}) testing.expect(t, ok, "should succeed") if !ok do return - defer delete(cmd.args) - defer delete(cmd.flags) - defer delete(cmd.bool_set) + defer delete_command(&cmd) testing.expect(t, cmd.flags["c"] == "x.json") } @@ -247,9 +239,7 @@ test_parse_args_long_bool_flag :: proc(t: ^testing.T) { cmd, ok := parse_args([]string{"envr", "init", "--force"}) testing.expect(t, ok, "should succeed") if !ok do return - defer delete(cmd.args) - defer delete(cmd.flags) - defer delete(cmd.bool_set) + defer delete_command(&cmd) testing.expect(t, cmd.bool_set["force"] == true) } @@ -259,9 +249,7 @@ test_parse_args_short_bool_flag :: proc(t: ^testing.T) { cmd, ok := parse_args([]string{"envr", "version", "-l"}) testing.expect(t, ok, "should succeed") if !ok do return - defer delete(cmd.args) - defer delete(cmd.flags) - defer delete(cmd.bool_set) + defer delete_command(&cmd) testing.expect(t, cmd.bool_set["l"] == true) } @@ -271,9 +259,7 @@ test_parse_args_multiple_positionals :: proc(t: ^testing.T) { cmd, ok := parse_args([]string{"envr", "backup", "a", "b"}) testing.expect(t, ok, "should succeed") if !ok do return - defer delete(cmd.args) - defer delete(cmd.flags) - defer delete(cmd.bool_set) + defer delete_command(&cmd) testing.expect(t, len(cmd.args) == 2) testing.expect(t, cmd.args[0] == "a") @@ -285,9 +271,7 @@ test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) { cmd, ok := parse_args([]string{"envr", "backup", "/project/.env", "--force"}) testing.expect(t, ok, "should succeed") if !ok do return - defer delete(cmd.args) - defer delete(cmd.flags) - defer delete(cmd.bool_set) + defer delete_command(&cmd) testing.expect(t, cmd.bool_set["force"] == true) testing.expect(t, len(cmd.args) == 1) @@ -305,9 +289,7 @@ test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) { cmd, ok := parse_args([]string{"envr", "backup", "a.env", "--force", "--verbose"}) testing.expect(t, ok, "should succeed") if !ok do return - defer delete(cmd.args) - defer delete(cmd.flags) - defer delete(cmd.bool_set) + defer delete_command(&cmd) testing.expect(t, cmd.bool_set["force"] == true) testing.expect(t, cmd.bool_set["verbose"] == true) @@ -320,11 +302,13 @@ test_parse_args_config_file_long_flag :: proc(t: ^testing.T) { cmd, ok := parse_args([]string{"envr", "list", "--config-file", "/custom/config.json"}) testing.expect(t, ok, "should succeed") if !ok do return - defer delete(cmd.args) - defer delete(cmd.flags) - defer delete(cmd.bool_set) + defer delete_command(&cmd) - testing.expect(t, cmd.config_path == "/custom/config.json", "config_path should be set from --config-file") + testing.expect( + t, + cmd.config_path == "/custom/config.json", + "config_path should be set from --config-file", + ) } @(test) @@ -332,11 +316,13 @@ test_parse_args_config_file_short_flag :: proc(t: ^testing.T) { cmd, ok := parse_args([]string{"envr", "list", "-c", "/custom/config.json"}) testing.expect(t, ok, "should succeed") if !ok do return - defer delete(cmd.args) - defer delete(cmd.flags) - defer delete(cmd.bool_set) + defer delete_command(&cmd) - testing.expect(t, cmd.config_path == "/custom/config.json", "config_path should be set from -c") + testing.expect( + t, + cmd.config_path == "/custom/config.json", + "config_path should be set from -c", + ) } @(test) @@ -344,9 +330,7 @@ test_parse_args_config_file_defaults :: proc(t: ^testing.T) { cmd, ok := parse_args([]string{"envr", "list"}) testing.expect(t, ok, "should succeed") if !ok do return - defer delete(cmd.args) - defer delete(cmd.flags) - defer delete(cmd.bool_set) + defer delete_command(&cmd) testing.expect(t, len(cmd.config_path) > 0, "config_path should default to non-empty path") testing.expect( diff --git a/cmd_backup.odin b/cmd_backup.odin index bcc0485..a91e24c 100644 --- a/cmd_backup.odin +++ b/cmd_backup.odin @@ -17,19 +17,16 @@ cmd_backup :: proc(cmd: ^Command) { file, ok := new_env_file(path) if !ok { - // TODO: log a message return } db, db_ok := db_open(cmd.config_path) if !db_ok { - // TODO: log a message return } defer db_close(&db) if !db_insert(&db, file) { - // TODO: log a message return } diff --git a/cmd_check.odin b/cmd_check.odin index f373a50..028e440 100644 --- a/cmd_check.odin +++ b/cmd_check.odin @@ -11,7 +11,7 @@ cmd_check :: proc(cmd: ^Command) { if len(cmd.args) > 0 { check_path = cmd.args[0] } else { - cwd, cwd_err := os.get_working_directory(context.allocator) + cwd, cwd_err := os.get_working_directory(context.temp_allocator) if cwd_err != nil { fmt.printf("Error getting current directory: %v\n", cwd_err) return diff --git a/cmd_deps.odin b/cmd_deps.odin index 0a62d33..8ae3bfd 100644 --- a/cmd_deps.odin +++ b/cmd_deps.odin @@ -1,10 +1,10 @@ package main -import "core:fmt" import "core:io" import "core:os" import "core:terminal" +// TODO: Improve table rendering cmd_deps :: proc(cmd: ^Command) { feats := check_features() diff --git a/cmd_init.odin b/cmd_init.odin index 2eb581c..8eef822 100644 --- a/cmd_init.odin +++ b/cmd_init.odin @@ -5,10 +5,14 @@ import "core:fmt" cmd_init :: proc(cmd: ^Command) { force := has_flag(cmd, "force") || has_flag(cmd, "f") + fmt.println(cmd.config_path) + _, cfg_exists := load_config(cmd.config_path) if cfg_exists && !force { - fmt.println("You have already initialized envr.") - fmt.println("Run again with the --force flag if you want to reinitialize.") + fmt.println( + `You have already initialized envr. +Run again with the --force flag if you want to reinitialize.`, + ) return } @@ -18,12 +22,13 @@ cmd_init :: proc(cmd: ^Command) { } if len(keys) == 0 { - fmt.println("No ssh-ed25519 keys found in ~/.ssh") - fmt.println("Generate one with: ssh-keygen -t ed25519") + fmt.println(`No ssh-ed25519 keys found in ~/.ssh +Generate one with: ssh-keygen -t ed25519`) return } selected, result := multi_select("Select SSH private keys:", keys[:]) + defer delete(selected) if result == .Cancel { fmt.println("\x1b[2mCancelled.\x1b[0m") return diff --git a/cmd_list.odin b/cmd_list.odin index 9f9e771..c263763 100644 --- a/cmd_list.odin +++ b/cmd_list.odin @@ -13,6 +13,8 @@ ListEntry :: struct { Path: string `json:"path"`, } +// TODO: Support --format flag +// TODO: Improve table rendering cmd_list :: proc(cmd: ^Command) { db, db_ok := db_open(cmd.config_path) if !db_ok { @@ -42,6 +44,7 @@ cmd_list :: proc(cmd: ^Command) { w := io.to_writer(os.to_writer(os.stdout)) render_table(w, headers, table_rows[:]) } else { + // TODO: Should we instead print full entries here? entries: [dynamic]ListEntry for row in rows { filename := filepath.base(row.Path) @@ -54,7 +57,7 @@ cmd_list :: proc(cmd: ^Command) { ) } - data, marshal_err := json.marshal(entries[:]) + data, marshal_err := json.marshal(entries[:], allocator = context.temp_allocator) if marshal_err != nil { fmt.printf("Error marshaling JSON: %v\n", marshal_err) return diff --git a/cmd_nushell_completion.odin b/cmd_nushell_completion.odin index a3ffcd0..df29172 100644 --- a/cmd_nushell_completion.odin +++ b/cmd_nushell_completion.odin @@ -5,5 +5,7 @@ import "core:fmt" COMPLETION_SCRIPT: string : string(#load("mod.nu")) cmd_nushell_completion :: proc(cmd: ^Command) { + // TODO: Use buffered writer? fmt.print(COMPLETION_SCRIPT) } + diff --git a/cmd_remove.odin b/cmd_remove.odin index 21eeead..8027723 100644 --- a/cmd_remove.odin +++ b/cmd_remove.odin @@ -5,38 +5,40 @@ import "core:path/filepath" import "core:strings" cmd_remove :: proc(cmd: ^Command) { - if len(cmd.args) != 1 { + if len(cmd.args) != 1 { print_command_help("remove") - return - } + return + } - path := cmd.args[0] - if len(strings.trim_space(path)) == 0 { - fmt.println("Error: No path provided") - return - } + path := cmd.args[0] + if len(strings.trim_space(path)) == 0 { + fmt.println("Error: No path provided") + return + } - abs_path: string - if filepath.is_abs(path) { - abs_path = path - } else { - resolved, abs_err := filepath.abs(path) - if abs_err != nil { - fmt.printf("Error getting absolute path: %v\n", abs_err) - return - } - abs_path = resolved - } + // TODO: Is this the best way to do it? + abs_path: string + if filepath.is_abs(path) { + abs_path = path + } else { + resolved, abs_err := filepath.abs(path) + if abs_err != nil { + fmt.printf("Error getting absolute path: %v\n", abs_err) + return + } + abs_path = resolved + } - db, db_ok := db_open(cmd.config_path) - if !db_ok { - return - } - defer db_close(&db) + db, db_ok := db_open(cmd.config_path) + if !db_ok { + return + } + defer db_close(&db) - if !db_delete(&db, abs_path) { - return - } + if !db_delete(&db, abs_path) { + return + } - fmt.printf("Removed %s from the database\n", abs_path) + fmt.printf("Removed %s from the database\n", abs_path) } + diff --git a/cmd_restore.odin b/cmd_restore.odin index 1d8f44f..2e0355d 100644 --- a/cmd_restore.odin +++ b/cmd_restore.odin @@ -6,48 +6,50 @@ import "core:path/filepath" import "core:strings" cmd_restore :: proc(cmd: ^Command) { - if len(cmd.args) != 1 { + if len(cmd.args) != 1 { print_command_help("restore") - return - } + return + } - path := cmd.args[0] - if len(strings.trim_space(path)) == 0 { - fmt.println("Error: No path provided") - return - } + path := cmd.args[0] + if len(strings.trim_space(path)) == 0 { + fmt.println("Error: No path provided") + return + } - abs_path: string - if filepath.is_abs(path) { - abs_path = path - } else { - resolved, abs_err := filepath.abs(path) - if abs_err != nil { - fmt.printf("Error getting absolute path: %v\n", abs_err) - return - } - abs_path = resolved - } + // 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 { + fmt.printf("Error getting absolute path: %v\n", abs_err) + return + } + abs_path = resolved + } - db, db_ok := db_open(cmd.config_path) - if !db_ok { - return - } - defer db_close(&db) + db, db_ok := db_open(cmd.config_path) + if !db_ok { + return + } + defer db_close(&db) - file, fetch_ok := db_fetch(&db, abs_path) - if !fetch_ok { - return - } + file, fetch_ok := db_fetch(&db, abs_path) + if !fetch_ok { + return + } - dir := filepath.dir(file.Path) - os.mkdir_all(dir) + dir := filepath.dir(file.Path) + os.mkdir_all(dir) - write_err := os.write_entire_file(file.Path, file.contents) - if write_err != nil { - fmt.printf("Error writing file: %v\n", write_err) - return - } + write_err := os.write_entire_file(file.Path, file.contents) + if write_err != nil { + fmt.printf("Error writing file: %v\n", write_err) + return + } - fmt.printf("Restored %s\n", file.Path) + fmt.printf("Restored %s\n", file.Path) } + diff --git a/cmd_scan.odin b/cmd_scan.odin index 99510c5..a9e2566 100644 --- a/cmd_scan.odin +++ b/cmd_scan.odin @@ -22,7 +22,7 @@ cmd_scan :: proc(cmd: ^Command) { search_dirs := search_paths(db.cfg) if len(search_dirs) == 0 { - fmt.println("No search paths configured. Please run `envr init` or edit your config.") + fmt.println("No search paths configured. Please run `envr init -f` or edit your config.") return } @@ -62,6 +62,7 @@ cmd_scan :: proc(cmd: ^Command) { } selected, result := multi_select("Select .env files to backup:", files[:]) + defer delete(selected) if result == .Cancel { fmt.println("\x1b[2mCancelled.\x1b[0m") return diff --git a/cmd_sync.odin b/cmd_sync.odin index d589a43..0d33c92 100644 --- a/cmd_sync.odin +++ b/cmd_sync.odin @@ -13,6 +13,7 @@ SyncEntry :: struct { } // TODO: Check for quiet failures. +// TODO: Support --format -f flags cmd_sync :: proc(cmd: ^Command) { db, db_ok := db_open(cmd.config_path) if !db_ok { @@ -26,11 +27,13 @@ cmd_sync :: proc(cmd: ^Command) { } defer delete(files) + // TODO: Set sane default size results: [dynamic]SyncEntry + defer delete(results) for &file in files { old_path: string - old_path, _ = strings.clone(file.Path) + old_path, _ = strings.clone(file.Path, context.temp_allocator) result, err_msg := db_sync(&db, &file) diff --git a/config.odin b/config.odin index 77c1e4b..67f6f73 100644 --- a/config.odin +++ b/config.odin @@ -23,9 +23,11 @@ Config :: struct { config_path: string `json:"-"`, } -default_config_path :: proc(home: string) -> string { - // FIXME: catch error - path, _ := filepath.join([]string{home, ".envr", "config.json"}) +default_config_path :: proc(home: string, allocator := context.allocator) -> string { + path, err := filepath.join([]string{home, ".envr", "config.json"}, allocator) + if err != nil { + panic("Ran out of memory when building config path") + } return path } @@ -35,8 +37,10 @@ load_config :: proc(config_path: string) -> (Config, bool) { fmt.println("No config file found. Please run `envr init` to generate one.") return Config{}, false } + defer delete(data) cfg: Config + // TODO: use json 5 err := json.unmarshal(data, &cfg) if err != nil { fmt.printf("Error parsing config: %v\n", err) @@ -47,9 +51,23 @@ load_config :: proc(config_path: string) -> (Config, bool) { return cfg, true } -delete_config :: proc(cfg: Config) { +delete_config :: proc(cfg: ^Config) { + for key in cfg.Keys { + delete(key.Private) + delete(key.Public) + } delete(cfg.Keys) + + delete(cfg.ScanConfig.Matcher) + + for exclude in cfg.ScanConfig.Exclude { + delete(exclude) + } delete(cfg.ScanConfig.Exclude) + + for include in cfg.ScanConfig.Include { + delete(include) + } delete(cfg.ScanConfig.Include) } @@ -115,21 +133,22 @@ new_config :: proc( keys := make([dynamic]SshKeyPair, 0, len(private_key_paths)) for priv in private_key_paths { // TODO: Is this bad? - pub, _ := strings.concatenate([]string{priv, ".pub"}, context.temp_allocator) - append(&keys, SshKeyPair{Private = priv, Public = pub}) + priv_key := strings.clone(priv) + pub, _ := strings.concatenate([]string{priv_key, ".pub"}) + append(&keys, SshKeyPair{Private = priv_key, Public = pub}) } exclude := make([dynamic]string, 0, 4) - append(&exclude, "*\\.envrc") - append(&exclude, "\\.local/") - append(&exclude, "node_modules") - append(&exclude, "vendor") + append(&exclude, strings.clone("*\\.envrc")) + append(&exclude, strings.clone("\\.local/")) + append(&exclude, strings.clone("node_modules")) + append(&exclude, strings.clone("vendor")) include := make([dynamic]string, 0, 1) - append(&include, "~") + append(&include, strings.clone("~")) scan_cfg := ScanConfig { - Matcher = "\\.env", + Matcher = strings.clone("\\.env"), Exclude = exclude, Include = include, } @@ -164,6 +183,7 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool { fmt.printf("Error marshaling config: %v\n", marshal_err) return false } + defer delete(data) write_err := os.write_entire_file(cfg.config_path, data) if write_err != nil { @@ -175,15 +195,18 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool { } search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) { - home, _ := os.user_home_dir(context.allocator) + // TODO: Is this okay? + // TODO: handle error + home, _ := os.user_home_dir(context.temp_allocator) for include in cfg.ScanConfig.Include { + // TODO: Do we need to manually expand ~/ in odin? expanded, _ := strings.replace(include, "~", home, 1) - cloned, _ := strings.clone(expanded) - if filepath.is_abs(cloned) { - append(&paths, cloned) + if filepath.is_abs(expanded) { + append(&paths, expanded) } else { - resolved, err := filepath.abs(cloned) + defer delete(expanded) + resolved, err := filepath.abs(expanded) if err == nil { append(&paths, resolved) } diff --git a/config_test.odin b/config_test.odin index 60086ec..a52bb76 100644 --- a/config_test.odin +++ b/config_test.odin @@ -13,7 +13,7 @@ home_mutex: sync.Mutex test_new_config_single_key :: proc(t: ^testing.T) { paths := []string{"/home/user/.ssh/id_ed25519"} cfg := new_config(paths) - defer delete_config(cfg) + defer delete_config(&cfg) testing.expect(t, len(cfg.Keys) == 1, "should have 1 key") testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519", "Private path mismatch") @@ -28,7 +28,7 @@ test_new_config_single_key :: proc(t: ^testing.T) { test_new_config_multiple_keys :: proc(t: ^testing.T) { paths := []string{"/home/user/.ssh/id_ed25519", "/home/user/.ssh/id_rsa"} cfg := new_config(paths) - defer delete_config(cfg) + defer delete_config(&cfg) testing.expect(t, len(cfg.Keys) == 2, "should have 2 keys") testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519") @@ -39,7 +39,7 @@ test_new_config_multiple_keys :: proc(t: ^testing.T) { test_new_config_empty_keys :: proc(t: ^testing.T) { paths: []string cfg := new_config(paths) - defer delete_config(cfg) + defer delete_config(&cfg) testing.expect(t, len(cfg.Keys) == 0, "should have 0 keys") } @@ -48,7 +48,7 @@ test_new_config_empty_keys :: proc(t: ^testing.T) { test_new_config_scan_defaults :: proc(t: ^testing.T) { paths := []string{"/home/user/.ssh/id_ed25519"} cfg := new_config(paths) - defer delete_config(cfg) + defer delete_config(&cfg) testing.expect(t, cfg.ScanConfig.Matcher == "\\.env", "matcher should be \\.env") testing.expect(t, len(cfg.ScanConfig.Exclude) == 4, "should have 4 exclude patterns") @@ -60,7 +60,7 @@ test_new_config_scan_defaults :: proc(t: ^testing.T) { test_new_config_exclude_patterns :: proc(t: ^testing.T) { paths := []string{"/home/user/.ssh/id_ed25519"} cfg := new_config(paths) - defer delete_config(cfg) + defer delete_config(&cfg) expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"} for i in 0 ..< len(expected) { @@ -78,14 +78,14 @@ test_save_load_config_roundtrip :: proc(t: ^testing.T) { testing.expect(t, err == nil, "cfgPath should build successfully") cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath) - defer delete_config(cfg) + defer delete_config(&cfg) testing.expect(t, save_config(cfg, force = true), "save should succeed") loaded, ok := load_config(cfg.config_path) testing.expect(t, ok, "load should succeed") if !ok do return - defer delete_config(loaded) + defer delete_config(&loaded) testing.expect(t, len(loaded.Keys) == 1, "should have 1 key") testing.expect(t, loaded.Keys[0].Private == "/home/user/.ssh/id_ed25519") @@ -112,11 +112,11 @@ test_save_config_no_clobber :: proc(t: ^testing.T) { testing.expect(t, err == nil, "cfgPath should build successfully") cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath) - defer delete_config(cfg) + defer delete_config(&cfg) testing.expect(t, save_config(cfg, force = true), "first save should succeed") cfg2 := new_config([]string{"/home/user/.ssh/key2"}, cfgPath) - defer delete_config(cfg2) + defer delete_config(&cfg2) testing.expect(t, !save_config(cfg2), "second save without force should fail") } @@ -130,17 +130,17 @@ test_save_config_force_overwrites :: proc(t: ^testing.T) { testing.expect(t, err == nil, "cfgPath should build successfully") cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath) - defer delete_config(cfg) + defer delete_config(&cfg) testing.expect(t, save_config(cfg, force = true), "first save should succeed") cfg2 := new_config([]string{"/home/user/.ssh/key2"}, cfgPath) - defer delete_config(cfg2) + defer delete_config(&cfg2) testing.expect(t, save_config(cfg2, force = true), "force save should overwrite") loaded, ok := load_config(cfgPath) testing.expect(t, ok, "load should succeed") if !ok do return - defer delete_config(loaded) + defer delete_config(&loaded) testing.expect(t, len(loaded.Keys) == 1, "should have 1 key") testing.expect( @@ -165,6 +165,7 @@ test_envr_dir :: proc(t: ^testing.T) { @(test) test_data_encrypted_path :: proc(t: ^testing.T) { p := data_encrypted_path("/tmp/envr-fake-home-datapath/config.json") + defer delete(p) testing.expectf(t, strings.has_suffix(p, "data.envr"), "should end with data.envr, got %s", p) testing.expectf(t, strings.contains(p, ".envr"), "should contain .envr dir, got %s", p) } @@ -191,6 +192,9 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) { paths := search_paths(cfg) defer delete(paths) + for path in paths { + defer delete(path) + } testing.expect(t, len(paths) == 1, "should have 1 path") if len(paths) > 0 { diff --git a/crypto_test.odin b/crypto_test.odin index b8d8a6a..5bcbe8c 100644 --- a/crypto_test.odin +++ b/crypto_test.odin @@ -25,7 +25,11 @@ test_encrypt_decrypt_roundtrip :: proc(t: ^testing.T) { testing.expect(t, dec_ok, "decryption should succeed") defer delete(decrypted) - testing.expect(t, len(decrypted) == len(original), fmt.tprintf("expected %d bytes, got %d", len(original), len(decrypted))) + testing.expect( + t, + len(decrypted) == len(original), + fmt.tprintf("expected %d bytes, got %d", len(original), len(decrypted)), + ) for i in 0 ..< len(original) { testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at index %d", i)) } @@ -50,8 +54,16 @@ test_encrypt_decrypt_multi_recipient :: proc(t: ^testing.T) { defer delete(decrypted2) for i in 0 ..< len(original) { - testing.expect(t, decrypted1[i] == original[i], fmt.tprintf("key1: byte mismatch at %d", i)) - testing.expect(t, decrypted2[i] == original[i], fmt.tprintf("key2: byte mismatch at %d", i)) + testing.expect( + t, + decrypted1[i] == original[i], + fmt.tprintf("key1: byte mismatch at %d", i), + ) + testing.expect( + t, + decrypted2[i] == original[i], + fmt.tprintf("key2: byte mismatch at %d", i), + ) } } @@ -85,6 +97,25 @@ test_encrypt_empty_plaintext :: proc(t: ^testing.T) { testing.expect(t, len(decrypted) == 0, "decrypted empty data should be empty") } +@(test) +test_recipient_can_decrypt_senders_data :: proc(t: ^testing.T) { + key1 := make_test_key_pair("test_ed25519") + key2 := make_test_key_pair("test_ed25519_second") + original := []u8{10, 20, 30, 40, 50} + + encrypted, enc_ok := encrypt(original, []SshKeyPair{key1, key2}) + testing.expect(t, enc_ok, "encryption with 2 keys should succeed") + defer delete(encrypted) + + decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key2}) + testing.expect(t, dec_ok, "second recipient should decrypt without the sender key present") + defer delete(decrypted) + + for i in 0 ..< len(original) { + testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at %d", i)) + } +} + @(test) test_ciphertext_has_magic :: proc(t: ^testing.T) { key := make_test_key_pair("test_ed25519") @@ -100,3 +131,4 @@ test_ciphertext_has_magic :: proc(t: ^testing.T) { testing.expect(t, encrypted[2] == u8('V'), "magic byte 2") testing.expect(t, encrypted[3] == u8('R'), "magic byte 3") } + diff --git a/db.odin b/db.odin index c15daf6..8674b05 100644 --- a/db.odin +++ b/db.odin @@ -27,6 +27,7 @@ SyncDirection :: enum { } Db :: struct { + // Pointer to the sqlite db db: ^rawptr, cfg: Config, changed: bool, @@ -40,10 +41,22 @@ EnvFile :: struct { contents: string, } +delete_envfile :: proc(f: ^EnvFile) { + delete(f.Path) + for &remote in f.Remotes { + delete(remote) + } + delete(f.Remotes) + delete(f.Sha256) + delete(f.contents) +} + +// TODO: Leak? make_temp_path :: proc() -> string { ts := time.time_to_unix(time.now()) b: strings.Builder strings.builder_init(&b) + defer strings.builder_destroy(&b) fmt.sbprintf(&b, "/tmp/envr-%d-%d.db", os.get_pid(), ts) return strings.to_string(b) } @@ -64,8 +77,8 @@ db_open :: proc(cfg_path: string) -> (Db, bool) { return Db{}, false } - create_sql := "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" - rc = sqlite.db_exec(db, string_to_cstring(create_sql), nil, nil, nil) + create_sql: cstring = "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" + rc = sqlite.db_exec(db, create_sql, nil, nil, nil) if rc != sqlite.OK { fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db)) sqlite.db_close(db) @@ -125,14 +138,27 @@ db_close :: proc(d: ^Db) { sqlite.db_close(d.db) } -db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) { - sql := "SELECT path, remotes, sha256, contents FROM envr_env_files" +// Caller is responsible for calling: +// ```odin +// delete(results) +// for &result in results { +// delete(&result) +// } +// ``` +db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]EnvFile, ok: bool) { stmt: ^rawptr - rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) + rc := sqlite.prepare_v2( + d.db, + "SELECT path, remotes, sha256, contents FROM envr_env_files", + -1, + &stmt, + nil, + ) if rc != sqlite.OK { fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db)) return } + defer sqlite.finalize(stmt) for { rc = sqlite.step(stmt) @@ -141,19 +167,15 @@ db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) { } if rc != sqlite.ROW { fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db)) - sqlite.finalize(stmt) return } - path := cstring_to_string(sqlite.column_text(stmt, 0)) - remotes_json := cstring_to_string(sqlite.column_text(stmt, 1)) - sha := cstring_to_string(sqlite.column_text(stmt, 2)) - contents := cstring_to_string(sqlite.column_text(stmt, 3)) - - remotes: [dynamic]string + remotes_json := string(sqlite.column_text(stmt, 1)) + remotes: [dynamic]string = --- if len(remotes_json) > 0 { - json.unmarshal_string(remotes_json, &remotes) + json.unmarshal_string(remotes_json, &remotes, allocator = allocator) } + path := clone_cstring(sqlite.column_text(stmt, 0), allocator) append( &results, @@ -161,13 +183,12 @@ db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) { Path = path, Dir = filepath.dir(path), Remotes = remotes, - Sha256 = sha, - contents = contents, + Sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator), + contents = clone_cstring(sqlite.column_text(stmt, 3), allocator), }, ) } - sqlite.finalize(stmt) ok = true return } @@ -175,9 +196,9 @@ db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) { db_vacuum_to_file :: proc(db: ^rawptr, path: string) -> bool { b: strings.Builder strings.builder_init(&b) + defer strings.builder_destroy(&b) fmt.sbprintf(&b, "VACUUM INTO '%s'", path) - sql := strings.to_string(b) - rc := sqlite.db_exec(db, string_to_cstring(sql), nil, nil, nil) + rc := sqlite.db_exec(db, to_cstring(&b), nil, nil, nil) if rc != sqlite.OK { fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(db)) return false @@ -218,10 +239,10 @@ db_restore_from_encrypted :: proc(db: ^rawptr, cfg: Config) -> bool { db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool { b: strings.Builder strings.builder_init(&b) + defer strings.builder_destroy(&b) fmt.sbprintf(&b, "ATTACH DATABASE '%s' AS source", src_path) - attach_sql := strings.to_string(b) - rc := sqlite.db_exec(mem_db, string_to_cstring(attach_sql), nil, nil, nil) + rc := sqlite.db_exec(mem_db, to_cstring(&b), nil, nil, nil) if rc != sqlite.OK { fmt.printf("Error attaching database: %s\n", sqlite.db_errmsg(mem_db)) return false @@ -250,6 +271,7 @@ get_git_remotes :: proc(dir: string) -> [dynamic]string { b: strings.Builder strings.builder_init(&b) + defer strings.builder_destroy(&b) fmt.sbprintf(&b, "%s-git-remotes", make_temp_path()) tmp_path := strings.to_string(b) tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC) @@ -279,13 +301,13 @@ get_git_remotes :: proc(dir: string) -> [dynamic]string { } data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator) + defer delete(data) os.remove(tmp_path) if read_err != nil { return remotes } - output_str := string(data) - lines := strings.split(output_str, "\n") + lines := strings.split(string(data), "\n") for &line in lines { line = strings.trim_space(line) @@ -312,27 +334,27 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) { fmt.printf("Error getting absolute path: %v\n", abs_err) return EnvFile{}, false } - cloned_path, _ := strings.clone(abs_path) - dir := filepath.dir(cloned_path) + dir := filepath.dir(abs_path) remotes := get_git_remotes(dir) - data, read_err := os.read_entire_file_from_path(cloned_path, context.allocator) + data, read_err := os.read_entire_file_from_path(abs_path, context.allocator) + defer delete(data) if read_err != nil { - fmt.printf("Error reading file %s: %v\n", cloned_path, read_err) + fmt.printf("Error reading file %s: %v\n", abs_path, read_err) return EnvFile{}, false } - digest := hash.hash_bytes(hash.Algorithm.SHA256, data) + digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator) + // TODO: Handle error hex_bytes, _ := hex.encode(digest) - sha_str := string(hex_bytes) return EnvFile { - Path = cloned_path, + Path = abs_path, Dir = dir, Remotes = remotes, - Sha256 = sha_str, + Sha256 = string(hex_bytes), contents = string(data), }, true @@ -344,20 +366,35 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool { fmt.printf("Error marshaling remotes: %v\n", marshal_err) return false } + defer delete(remotes_json) - sql := "INSERT OR REPLACE INTO envr_env_files (path, remotes, sha256, contents) VALUES (?, ?, ?, ?)" + sql: cstring = + "INSERT OR REPLACE INTO " + + "envr_env_files (path, remotes, sha256, contents) VALUES (?, ?, ?, ?)" stmt: ^rawptr - rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) + rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil) if rc != sqlite.OK { fmt.printf("Error preparing insert: %s\n", sqlite.db_errmsg(d.db)) return false } defer sqlite.finalize(stmt) - rc = sqlite.bind_text(stmt, 1, string_to_cstring(file.Path), -1, nil) - rc = sqlite.bind_text(stmt, 2, string_to_cstring(string(remotes_json)), -1, nil) - rc = sqlite.bind_text(stmt, 3, string_to_cstring(file.Sha256), -1, nil) - rc = sqlite.bind_text(stmt, 4, string_to_cstring(file.contents), -1, nil) + // TODO: deal with elsewhere? + cpath := to_cstring(file.Path) + defer delete(cpath) + rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) + + cremotes := to_cstring(string(remotes_json)) + defer delete(cremotes) + rc = sqlite.bind_text(stmt, 2, cremotes, -1, nil) + + csha := to_cstring(file.Sha256) + defer delete(csha) + rc = sqlite.bind_text(stmt, 3, csha, -1, nil) + + ccontents := to_cstring(file.contents) + defer delete(ccontents) + rc = sqlite.bind_text(stmt, 4, ccontents, -1, nil) rc = sqlite.step(stmt) if rc != sqlite.DONE { @@ -369,17 +406,19 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool { return true } -db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) { - sql := "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?" +db_fetch :: proc(d: ^Db, path: string, allocator := context.allocator) -> (EnvFile, bool) { + sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?" stmt: ^rawptr - rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) + rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil) if rc != sqlite.OK { fmt.printf("Error preparing fetch: %s\n", sqlite.db_errmsg(d.db)) return EnvFile{}, false } defer sqlite.finalize(stmt) - rc = sqlite.bind_text(stmt, 1, string_to_cstring(path), -1, nil) + cpath := to_cstring(path, allocator) + defer delete(cpath, allocator) + rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.step(stmt) if rc == sqlite.DONE { fmt.printf("No file found with path: %s\n", path) @@ -390,38 +429,37 @@ db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) { return EnvFile{}, false } - file_path := cstring_to_string(sqlite.column_text(stmt, 0)) - remotes_json := cstring_to_string(sqlite.column_text(stmt, 1)) - sha := cstring_to_string(sqlite.column_text(stmt, 2)) - contents := cstring_to_string(sqlite.column_text(stmt, 3)) - - remotes: [dynamic]string + remotes_json := string(sqlite.column_text(stmt, 1)) + remotes: [dynamic]string = --- if len(remotes_json) > 0 { - json.unmarshal_string(remotes_json, &remotes) + json.unmarshal_string(remotes_json, &remotes, allocator = allocator) } - cloned_path, _ := strings.clone(file_path) + file_path := clone_cstring(sqlite.column_text(stmt, 0)) + return EnvFile { - Path = cloned_path, - Dir = filepath.dir(cloned_path), + Path = file_path, + Dir = filepath.dir(file_path), Remotes = remotes, - Sha256 = sha, - contents = contents, + Sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator), + contents = clone_cstring(sqlite.column_text(stmt, 3), allocator), }, true } db_delete :: proc(d: ^Db, path: string) -> bool { - sql := "DELETE FROM envr_env_files WHERE path = ?" + sql: cstring = "DELETE FROM envr_env_files WHERE path = ?" stmt: ^rawptr - rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) + rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil) if rc != sqlite.OK { fmt.printf("Error preparing delete: %s\n", sqlite.db_errmsg(d.db)) return false } defer sqlite.finalize(stmt) - rc = sqlite.bind_text(stmt, 1, string_to_cstring(path), -1, nil) + cpath := to_cstring(path) + defer delete(cpath) + rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.step(stmt) if rc != sqlite.DONE { fmt.printf("Error deleting: %s\n", sqlite.db_errmsg(d.db)) @@ -437,19 +475,31 @@ db_delete :: proc(d: ^Db, path: string) -> bool { return true } -cstring_to_string :: proc(cs: cstring) -> string { - if cs == nil { - return "" - } - s, _ := strings.clone_from_cstring(cs) - return s +to_cstring :: proc { + string_to_cstring, + strings.to_cstring, } -string_to_cstring :: proc(s: string) -> cstring { - cs, _ := strings.clone_to_cstring(s) +string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring { + cs, err := strings.clone_to_cstring(s, allocator) + if err != nil { + fmt.printf("Failed to convert string to cstring: %v\n", err) + panic("Allocation Exception") + } return cs } +clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string { + str, err := strings.clone_from_cstring(c, allocator) + if err != nil { + fmt.printf("Failed to convert string to cstring: %v\n", err) + delete(str) + panic("Allocation Exception") + } + + return str +} + db_update_required :: proc(status: SyncFlag) -> bool { return .BackedUp in status || .DirUpdated in status } @@ -496,20 +546,11 @@ find_moved_dirs :: proc(d: ^Db, f: ^EnvFile) -> ([dynamic]string, bool) { return moved, true } -env_file_backup :: proc(f: ^EnvFile) -> bool { - data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) - if read_err != nil { - fmt.printf("Error reading file %s: %v\n", f.Path, read_err) - return false - } - - f.contents = string(data) - digest := hash.hash_bytes(hash.Algorithm.SHA256, data) - hex_bytes, _ := hex.encode(digest) - f.Sha256 = string(hex_bytes) - return true +db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, string) { + return env_file_sync(f, .TrustFilesystem, d) } +// If SyncFlag is .BackedUp, Caller is responsible for calling delete on f.contents and f.Sha256 env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, string) { result: SyncFlag = {} @@ -555,6 +596,7 @@ env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, str } digest := hash.hash_bytes(hash.Algorithm.SHA256, data) + // TODO: Handle error hex_bytes, _ := hex.encode(digest) current_sha := string(hex_bytes) @@ -580,7 +622,24 @@ env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, str return result, "" } -db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, string) { - return env_file_sync(f, .TrustFilesystem, d) +// Loads the contents of the the file at f.Path into f.contents +// +// Caller is responsible for calling delete on f.contents and f.Sha256 +env_file_backup :: proc(f: ^EnvFile) -> bool { + data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) + if read_err != nil { + fmt.printf("Error reading file %s: %v\n", f.Path, read_err) + return false + } + + f.contents = string(data) + digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator) + hex_bytes, alloc_err := hex.encode(digest) + if alloc_err != nil { + fmt.printf("Error generating hash for file %s: %v\n", f.Path, alloc_err) + return false + } + f.Sha256 = string(hex_bytes) + return true } diff --git a/db_integration_test.odin b/db_integration_test.odin index 62efdc2..691a70c 100644 --- a/db_integration_test.odin +++ b/db_integration_test.odin @@ -180,16 +180,16 @@ test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) { } defer sqlite.db_close(mem_db) - create_sql := "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" - rc = sqlite.db_exec(mem_db, string_to_cstring(create_sql), nil, nil, nil) + create_sql: cstring = "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" + rc = sqlite.db_exec(mem_db, create_sql, nil, nil, nil) testing.expect(t, rc == sqlite.OK, "failed to create table") attach_ok := db_attach_and_copy(mem_db, tmp_db_path) testing.expect(t, attach_ok, "failed to attach and copy") - sql := "SELECT path FROM envr_env_files" + sql: cstring = "SELECT path FROM envr_env_files" stmt: ^rawptr - rc = sqlite.prepare_v2(mem_db, string_to_cstring(sql), -1, &stmt, nil) + rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil) testing.expect(t, rc == sqlite.OK, "prepare failed") if rc != sqlite.OK { return @@ -199,7 +199,7 @@ test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) { rc = sqlite.step(stmt) testing.expect(t, rc == sqlite.ROW, "expected at least one row") if rc == sqlite.ROW { - path := cstring_to_string(sqlite.column_text(stmt, 0)) + path := string(sqlite.column_text(stmt, 0)) testing.expect(t, len(path) > 0, "path should not be empty") } } @@ -207,9 +207,7 @@ test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) { @(test) test_full_db_cycle :: proc(t: ^testing.T) { cfg := fixture_config() - defer { - delete(cfg.Keys) - } + defer delete(cfg.Keys) db_path := fixture_db_path() original_data, read_err := os.read_entire_file_from_path(db_path, context.allocator) @@ -230,6 +228,7 @@ test_full_db_cycle :: proc(t: ^testing.T) { os.mkdir_all(envr_dir_path) data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"}) + defer delete(data_path) write_err := os.write_entire_file(data_path, encrypted) testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err) if write_err != nil { diff --git a/db_test.odin b/db_test.odin index 1a98b4a..6077e0a 100644 --- a/db_test.odin +++ b/db_test.odin @@ -15,8 +15,8 @@ make_test_db :: proc() -> (Db, bool) { return Db{}, false } - create_sql := "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" - rc = sqlite.db_exec(db, string_to_cstring(create_sql), nil, nil, nil) + create_sql: cstring = "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" + rc = sqlite.db_exec(db, create_sql, nil, nil, nil) if rc != sqlite.OK { sqlite.db_close(db) return Db{}, false @@ -46,26 +46,25 @@ test_db_insert_and_fetch :: proc(t: ^testing.T) { if !ok do return defer sqlite.db_close(d.db) - f := make_test_env_file( - "/project/.env", - "abc123", - "SECRET=value", - []string{"git@github.com:user/repo.git"}, - ) + path := "/project/.env" + sha := "abc123" + contents := "SECRET=value" + + f := make_test_env_file(path, sha, contents, []string{"git@github.com:user/repo.git"}) defer delete(f.Remotes) testing.expect(t, db_insert(&d, f), "insert should succeed") fetched, fetch_ok := db_fetch(&d, "/project/.env") + defer delete_envfile(&fetched) testing.expect(t, fetch_ok, "fetch should succeed") if !fetch_ok do return - defer delete(fetched.Remotes) - testing.expect(t, fetched.Path == "/project/.env", "path mismatch") - testing.expect(t, fetched.Sha256 == "abc123", "sha mismatch") - testing.expect(t, fetched.contents == "SECRET=value", "contents mismatch") - testing.expect(t, len(fetched.Remotes) == 1, "remotes count mismatch") - testing.expect(t, fetched.Remotes[0] == "git@github.com:user/repo.git", "remote mismatch") + testing.expect_value(t, fetched.Path, path) + testing.expect_value(t, fetched.Sha256, sha) + testing.expect_value(t, fetched.contents, contents) + testing.expect_value(t, len(fetched.Remotes), 1) + testing.expect_value(t, fetched.Remotes[0], "git@github.com:user/repo.git") } @(test) @@ -98,16 +97,19 @@ test_db_insert_or_replace :: proc(t: ^testing.T) { testing.expect(t, list_ok, "list should succeed") if !list_ok do return defer delete(results) + for &result in results { + defer delete_envfile(&result) + } testing.expect(t, len(results) == 1, "should have 1 row, not 2") fetched, fetch_ok := db_fetch(&d, "/project/.env") testing.expect(t, fetch_ok, "fetch should succeed") if !fetch_ok do return - defer delete(fetched.Remotes) + defer delete_envfile(&fetched) - testing.expect(t, fetched.contents == "KEY=new", "contents should be updated") - testing.expect(t, fetched.Sha256 == "sha2", "sha should be updated") + testing.expect_value(t, fetched.contents, "KEY=new") + testing.expect_value(t, fetched.Sha256, "sha2") } @(test) @@ -145,11 +147,10 @@ test_db_list_multiple :: proc(t: ^testing.T) { defer sqlite.db_close(d.db) f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"}) - f2 := make_test_env_file("/proj2/.env", "sha2", "B=2", []string{"git@github.com:b/repo.git"}) - f3 := make_test_env_file("/proj3/.env", "sha3", "C=3") defer delete(f1.Remotes) + f2 := make_test_env_file("/proj2/.env", "sha2", "B=2", []string{"git@github.com:b/repo.git"}) defer delete(f2.Remotes) - defer delete(f3.Remotes) + f3 := make_test_env_file("/proj3/.env", "sha3", "C=3") db_insert(&d, f1) db_insert(&d, f2) @@ -159,8 +160,13 @@ test_db_list_multiple :: proc(t: ^testing.T) { testing.expect(t, list_ok, "list should succeed") if !list_ok do return defer delete(results) + defer { + for &result in results { + delete_envfile(&result) + } + } - testing.expect(t, len(results) == 3, "should have 3 rows") + testing.expect_value(t, len(results), 3) } @(test) @@ -224,13 +230,12 @@ test_db_vacuum_to_file :: proc(t: ^testing.T) { testing.expect(t, db_vacuum_to_file(d.db, vacuum_path), "vacuum should succeed") - _, stat_err := os.stat(vacuum_path, context.allocator) + info, stat_err := os.stat(vacuum_path, context.allocator) + defer os.file_info_delete(info, context.allocator) testing.expect(t, stat_err == nil, "vacuumed file should exist") - if stat_err != nil do return data, read_err := os.read_entire_file_from_path(vacuum_path, context.allocator) testing.expect(t, read_err == nil, "should read vacuumed file") - if read_err != nil do return defer delete(data) testing.expect(t, len(data) > 0, "vacuumed file should be non-empty") @@ -342,6 +347,8 @@ test_new_env_file :: proc(t: ^testing.T) { testing.expect(t, ok, "new_env_file should succeed") if !ok do return defer delete(file.Remotes) + defer delete(file.Sha256) + defer delete(file.Path) 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") @@ -368,9 +375,11 @@ test_env_file_backup :: proc(t: ^testing.T) { f := EnvFile { Path = env_path, } + defer delete(f.contents) + defer delete(f.Sha256) testing.expect(t, env_file_backup(&f), "backup should succeed") - testing.expect(t, f.contents == "KEY=12345\n", "contents should be populated") - testing.expect(t, len(f.Sha256) == 64, "sha256 should be 64 hex chars") + testing.expect_value(t, f.contents, "KEY=12345\n") + testing.expect_value(t, len(f.Sha256), 64) } @(test) @@ -388,11 +397,11 @@ test_update_dir :: proc(t: ^testing.T) { Dir = "/old/project", Remotes = make([dynamic]string, 0), } - defer delete(f.Remotes) + defer delete_envfile(&f) update_dir(&f, "/new/location") - testing.expect(t, f.Dir == "/new/location", "dir should be updated") - testing.expect(t, f.Path == "/new/location/.env", "path should be updated") + testing.expect_value(t, f.Dir, "/new/location") + testing.expect_value(t, f.Path, "/new/location/.env") } diff --git a/prompt.odin b/prompt.odin index 4bce000..9c6cb4d 100644 --- a/prompt.odin +++ b/prompt.odin @@ -113,10 +113,14 @@ MultiSelect_Result :: enum { MAX_VISIBLE :: 7 +// Caller is responsible for deleting the responses. multi_select :: proc( prompt: string, options: []string, -) -> (selected: [dynamic]bool, result: MultiSelect_Result) { +) -> ( + selected: [dynamic]bool, + result: MultiSelect_Result, +) { if len(options) == 0 { return } @@ -166,18 +170,21 @@ multi_select :: proc( } } -render_options :: proc(prompt: string, options: []string, selected: []bool, cursor: int, scroll_offset: int) -> int { - fmt.printf( - "\x1b[1;36m%s\x1b[0m (↑/↓ move, space select, enter confirm)\r\n", - prompt, - ) +render_options :: proc( + prompt: string, + options: []string, + selected: []bool, + cursor: int, + scroll_offset: int, +) -> int { + fmt.printf("\x1b[1;36m%s\x1b[0m (↑/↓ move, space select, enter confirm)\r\n", prompt) end := scroll_offset + MAX_VISIBLE if end > len(options) { end = len(options) } - for i in scroll_offset.. (lines: []string, ok: bool) { tmp_path := next_fd_tmp_path() tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC) if tmp_err != nil { + // TODO: Log a message here return }