6 Commits

21 changed files with 417 additions and 263 deletions

View File

@@ -1,6 +1,6 @@
# TODOs # TODOs
1. Commands are still leaking. 1. Commands are still leaking. (Write tests for everything first)
2. Add color flag and support non colored output. 2. Add color flag and support non colored output.
@@ -8,33 +8,27 @@
4. Generate md and man pages again. 4. 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. 5. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
6. Consistently ignore allocator errors 6. Add a text filter to the multi_select.
7. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`. 7. Add tests for untested commands.
8. Add a text filter to the multi_select. 8. add --format -f flag to commands that draw tables.
9. Add tests for untested commands. 9. procedures should be ordered by use, main at the top, then in the order they are called from main.
10. add --format -f flag to commands that draw tables. 10. Shell completion
11. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate. 11. Bring back windows support / cross-compilation.
12. procedures should be ordered by use, main at the top, then in the order they are called from main. 12. Test all cmds / terminal branches.
13. Shell completion 13. Pass allocator to findr?
14. Bring back windows support / cross-compilation. 14. Update `read_wire_string` to use a slice.
15. Test all cmds / terminal branches. 15. `-h` short flag seems to fail, at least with `envr list`
16. Fix error messages to use fmt.eprintf (stderr) instead of fmt.printf (stdout)
17. Pass allocator to findr?
18. Update `read_wire_string` to use a slice.
## 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:terminal"
import "core:text/table" import "core:text/table"
Command :: struct { Command :: struct {
@@ -18,6 +19,11 @@ Command :: struct {
err: io.Writer, err: io.Writer,
} }
Output_Format :: enum {
Table,
JSON,
}
CommandInfo :: struct { CommandInfo :: struct {
name: string, name: string,
usage: string, usage: string,
@@ -288,6 +294,11 @@ at before, restore your backup with:
COLOR_FLAGS + "-c, --config-file" + ANSI_RESET + " <path>", COLOR_FLAGS + "-c, --config-file" + ANSI_RESET + " <path>",
`config file (default "~/.envr/config.json")`, `config file (default "~/.envr/config.json")`,
) )
table.row(
&tbl,
COLOR_FLAGS + "-f, --format" + ANSI_RESET + " 'json'|'table'",
`the format of output data. (default 'table', unless piping)`,
)
write_borderless_table(w, &tbl) write_borderless_table(w, &tbl)
fmt.wprintf( fmt.wprintf(
@@ -303,6 +314,22 @@ has_flag :: proc(cmd: ^Command, name: string) -> bool {
return name in cmd.flags || name in cmd.bool_set return name in cmd.flags || name in cmd.bool_set
} }
get_format :: proc(cmd: ^Command) -> Output_Format {
flags :: []string{"format", "f"}
for name in flags {
if val, ok := cmd.flags[name]; ok {
switch val {
case "json":
return .JSON
case "table":
return .Table
}
}
}
return terminal.is_terminal(os.stdout) ? .Table : .JSON
}
delete_command :: proc(cmd: ^Command) { delete_command :: proc(cmd: ^Command) {
bufio.writer_flush(cmd.out_buf) bufio.writer_flush(cmd.out_buf)
delete(cmd.args) delete(cmd.args)

View File

@@ -123,7 +123,7 @@ test_command_help_unknown :: proc(t: ^testing.T) {
text := strings.to_string(b) text := strings.to_string(b)
testing.expect_value(t, len(text), 0) testing.expect_value(t, len(text), 0)
} }
@(test) @(test)
test_command_help_version :: proc(t: ^testing.T) { test_command_help_version :: proc(t: ^testing.T) {
@@ -236,9 +236,9 @@ test_parse_args_positional :: proc(t: ^testing.T) {
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
testing.expect_value(t, cmd.name, "backup") testing.expect_value(t, cmd.name, "backup")
testing.expect(t, len(cmd.args) == 1) testing.expect_value(t, len(cmd.args), 1)
testing.expect(t, cmd.args[0] == "/project/.env") testing.expect_value(t, cmd.args[0], "/project/.env")
} }
@(test) @(test)
test_parse_args_long_flag_with_value :: proc(t: ^testing.T) { test_parse_args_long_flag_with_value :: proc(t: ^testing.T) {
@@ -248,7 +248,7 @@ test_parse_args_long_flag_with_value :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.flags["config"], "x.json") testing.expect_value(t, cmd.flags["config"], "x.json")
} }
@(test) @(test)
test_parse_args_short_flag_with_value :: proc(t: ^testing.T) { test_parse_args_short_flag_with_value :: proc(t: ^testing.T) {
@@ -258,7 +258,7 @@ test_parse_args_short_flag_with_value :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.flags["c"], "x.json") testing.expect_value(t, cmd.flags["c"], "x.json")
} }
@(test) @(test)
test_parse_args_long_bool_flag :: proc(t: ^testing.T) { test_parse_args_long_bool_flag :: proc(t: ^testing.T) {
@@ -268,7 +268,7 @@ test_parse_args_long_bool_flag :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.bool_set["force"], true) testing.expect_value(t, cmd.bool_set["force"], true)
} }
@(test) @(test)
test_parse_args_short_bool_flag :: proc(t: ^testing.T) { test_parse_args_short_bool_flag :: proc(t: ^testing.T) {
@@ -278,7 +278,7 @@ test_parse_args_short_bool_flag :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.bool_set["l"], true) testing.expect_value(t, cmd.bool_set["l"], true)
} }
@(test) @(test)
test_parse_args_multiple_positionals :: proc(t: ^testing.T) { test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
@@ -288,9 +288,9 @@ test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, len(cmd.args), 2) testing.expect_value(t, len(cmd.args), 2)
testing.expect(t, cmd.args[0] == "a") testing.expect_value(t, cmd.args[0], "a")
testing.expect(t, cmd.args[1] == "b") testing.expect_value(t, cmd.args[1], "b")
} }
@(test) @(test)
test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) { test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
@@ -300,9 +300,9 @@ test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.bool_set["force"], true) testing.expect_value(t, cmd.bool_set["force"], true)
testing.expect(t, len(cmd.args) == 1) testing.expect_value(t, len(cmd.args), 1)
testing.expect(t, cmd.args[0] == "/project/.env") testing.expect_value(t, cmd.args[0], "/project/.env")
} }
@(test) @(test)
test_parse_args_no_args :: proc(t: ^testing.T) { test_parse_args_no_args :: proc(t: ^testing.T) {
@@ -318,10 +318,10 @@ test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) {
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
testing.expect_value(t, cmd.bool_set["force"], true) testing.expect_value(t, cmd.bool_set["force"], true)
testing.expect(t, cmd.bool_set["verbose"] == true) testing.expect_value(t, cmd.bool_set["verbose"], true)
testing.expect(t, len(cmd.args) == 1) testing.expect_value(t, len(cmd.args), 1)
testing.expect(t, cmd.args[0] == "a.env") testing.expect_value(t, cmd.args[0], "a.env")
} }
@(test) @(test)
test_parse_args_config_file_long_flag :: proc(t: ^testing.T) { test_parse_args_config_file_long_flag :: proc(t: ^testing.T) {
@@ -333,11 +333,7 @@ test_parse_args_config_file_long_flag :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.config_path, "/custom/config.json") testing.expect_value(t, cmd.config_path, "/custom/config.json")
t, }
cmd.config_path == "/custom/config.json",
"config_path should be set from --config-file",
)
}
@(test) @(test)
test_parse_args_config_file_short_flag :: proc(t: ^testing.T) { test_parse_args_config_file_short_flag :: proc(t: ^testing.T) {
@@ -347,11 +343,7 @@ test_parse_args_config_file_short_flag :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.config_path, "/custom/config.json") testing.expect_value(t, cmd.config_path, "/custom/config.json")
t, }
cmd.config_path == "/custom/config.json",
"config_path should be set from -c",
)
}
@(test) @(test)
test_parse_args_config_file_defaults :: proc(t: ^testing.T) { test_parse_args_config_file_defaults :: proc(t: ^testing.T) {
@@ -369,3 +361,43 @@ test_parse_args_config_file_defaults :: proc(t: ^testing.T) {
} }
@(test) @(test)
test_get_format_long_json :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--format", "json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, get_format(&cmd), Output_Format.JSON)
}
@(test)
test_get_format_short_json :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-f", "json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, get_format(&cmd), Output_Format.JSON)
}
@(test)
test_get_format_long_table :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--format", "table"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, get_format(&cmd), Output_Format.Table)
}
@(test)
test_get_format_short_table :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-f", "table"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, get_format(&cmd), Output_Format.Table)
}

View File

@@ -1,7 +1,6 @@
#+test #+test
package main package main
import "core:fmt"
import "core:testing" import "core:testing"
@(test) @(test)
@@ -10,13 +9,9 @@ test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
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_value(t, len(result), 1)
if len(result) > 0 { if len(result) > 0 {
testing.expect( testing.expect_value(t, result[0], "/c/.env")
t,
result[0] == "/c/.env",
fmt.tprintf("expected /c/.env, got %s", result[0]),
)
} }
} }
@@ -26,7 +21,7 @@ test_find_unbacked_all_backed :: proc(t: ^testing.T) {
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_value(t, len(result), 0)
} }
@(test) @(test)
@@ -35,7 +30,7 @@ test_find_unbacked_no_local :: proc(t: ^testing.T) {
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_value(t, len(result), 0)
} }
@(test) @(test)
@@ -44,6 +39,6 @@ test_find_unbacked_none_backed :: proc(t: ^testing.T) {
db: []EnvFile db: []EnvFile
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 2, fmt.tprintf("expected 2 unbacked, got %d", len(result))) testing.expect_value(t, len(result), 2)
} }

View File

@@ -5,7 +5,6 @@ import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
import "core:terminal"
import "core:text/table" import "core:text/table"
ListEntry :: struct { ListEntry :: struct {
@@ -13,7 +12,6 @@ ListEntry :: struct {
path: string `json:"path"`, path: string `json:"path"`,
} }
// TODO: Support --format flag
// TODO: Improve table rendering // TODO: Improve table rendering
cmd_list :: proc(cmd: ^Command) { cmd_list :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.config_path)
@@ -27,7 +25,7 @@ cmd_list :: proc(cmd: ^Command) {
return return
} }
if terminal.is_terminal(os.stdout) { if get_format(cmd) == .Table {
t: table.Table t: table.Table
table.init(&t, context.temp_allocator, context.temp_allocator) table.init(&t, context.temp_allocator, context.temp_allocator)
table.padding(&t, 1, 1) table.padding(&t, 1, 1)
@@ -51,7 +49,7 @@ cmd_list :: proc(cmd: ^Command) {
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width) table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width)
} else { } else {
// TODO: Should we instead print full entries here? // TODO: Should we instead print full entries here?
entries: [dynamic]ListEntry entries := make([dynamic]ListEntry, 0, len(rows), context.temp_allocator)
for row in rows { for row in rows {
filename := filepath.base(row.path) filename := filepath.base(row.path)
append( append(

View File

@@ -1,7 +1,11 @@
#+feature dynamic-literals
#+test #+test
package main package main
import "core:bufio"
import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings"
import "core:testing" import "core:testing"
@(test) @(test)
@@ -11,9 +15,101 @@ test_filepath_base_equals_rel :: proc(t: ^testing.T) {
for path in cases { for path in cases {
dir := filepath.dir(path) dir := filepath.dir(path)
rel, rel_err := filepath.rel(dir, path, context.temp_allocator) rel, rel_err := filepath.rel(dir, path, context.temp_allocator)
testing.expect(t, rel_err == nil, "filepath.rel returned an error") testing.expect_value(t, rel_err, nil)
base := filepath.base(path) base := filepath.base(path)
testing.expect(t, rel == base, "filepath.rel(dir, path) should equal filepath.base(path)") testing.expect_value(t, rel, base)
} }
} }
@(test)
test_cmd_list_format_json :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-list-json-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "list", "--format", "json", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_list(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "["), "json output should contain '['")
testing.expect(
t,
strings.contains(output, "\"directory\""),
"json output should contain directory key",
)
}
@(test)
test_cmd_list_format_table :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-list-table-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "list", "--format", "table", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_list(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "│"), "table output should contain border chars")
testing.expect(
t,
strings.contains(output, "Directory"),
"table output should contain Directory header",
)
}

View File

@@ -2,8 +2,6 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os"
import "core:terminal"
import "core:text/table" import "core:text/table"
SyncEntry :: struct { SyncEntry :: struct {
@@ -12,7 +10,6 @@ SyncEntry :: struct {
} }
// TODO: Check for quiet failures. // TODO: Check for quiet failures.
// TODO: Support --format -f flags
cmd_sync :: proc(cmd: ^Command) { cmd_sync :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
@@ -49,7 +46,7 @@ cmd_sync :: proc(cmd: ^Command) {
} }
} }
if terminal.is_terminal(os.stdout) { if get_format(cmd) == .Table {
t: table.Table t: table.Table
table.init(&t, context.temp_allocator, context.temp_allocator) table.init(&t, context.temp_allocator, context.temp_allocator)
table.padding(&t, 1, 1) table.padding(&t, 1, 1)

View File

@@ -30,14 +30,14 @@ load_config :: proc(config_path: string, allocator := context.allocator) -> (Con
// TODO: Should we use context.allocator + defer delete()? // TODO: Should we use context.allocator + defer delete()?
data, read_err := os.read_entire_file_from_path(config_path, context.temp_allocator) data, read_err := os.read_entire_file_from_path(config_path, context.temp_allocator)
if read_err != nil { if read_err != nil {
fmt.println("No config file found. Please run `envr init` to generate one.") fmt.eprintln("No config file found. Please run `envr init` to generate one.")
return Config{}, false return Config{}, false
} }
cfg: Config cfg: Config
err := json.unmarshal(data, &cfg, .JSON5, allocator) err := json.unmarshal(data, &cfg, .JSON5, allocator)
if err != nil { if err != nil {
fmt.printf("Error parsing config: %v\n", err) fmt.eprintf("Error parsing config: %v\n", err)
return Config{}, false return Config{}, false
} }
cfg.config_path = config_path cfg.config_path = config_path
@@ -79,7 +79,7 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
if !os.exists(config_dir) { if !os.exists(config_dir) {
mkdir_err := os.make_directory(config_dir) mkdir_err := os.make_directory(config_dir)
if mkdir_err != nil { if mkdir_err != nil {
fmt.printf("Error creating %s directory: %v\n", config_dir, mkdir_err) fmt.eprintf("Error creating %s directory: %v\n", config_dir, mkdir_err)
return false return false
} }
} }
@@ -89,7 +89,7 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
if stat_err == nil { if stat_err == nil {
defer os.file_info_delete(info, context.temp_allocator) defer os.file_info_delete(info, context.temp_allocator)
if info.size > 0 { if info.size > 0 {
fmt.println("Config file already exists. Run again with --force to reinitialize.") fmt.eprintln("Config file already exists. Run again with --force to reinitialize.")
return false return false
} }
} }
@@ -101,13 +101,13 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
context.temp_allocator, context.temp_allocator,
) )
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling config: %v\n", marshal_err) fmt.eprintf("Error marshaling config: %v\n", marshal_err)
return false return false
} }
write_err := os.write_entire_file(cfg.config_path, data) write_err := os.write_entire_file(cfg.config_path, data)
if write_err != nil { if write_err != nil {
fmt.printf("Error writing config: %v\n", write_err) fmt.eprintf("Error writing config: %v\n", write_err)
return false return false
} }
@@ -123,7 +123,7 @@ new_config :: proc(
for priv in private_key_paths { for priv in private_key_paths {
// TODO: Is this bad? // TODO: Is this bad?
priv_key := strings.clone(priv) priv_key := strings.clone(priv)
pub, _ := strings.concatenate([]string{priv_key, ".pub"}) pub := strings.concatenate([]string{priv_key, ".pub"})
append(&keys, SshKeyPair{private = priv_key, public = pub}) append(&keys, SshKeyPair{private = priv_key, public = pub})
} }
@@ -150,19 +150,19 @@ new_config :: proc(
find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) { find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
home, home_err := os.user_home_dir(context.allocator) home, home_err := os.user_home_dir(context.allocator)
if home_err != nil { if home_err != nil {
fmt.printf("Error getting home dir: %v\n", home_err) fmt.eprintf("Error getting home dir: %v\n", home_err)
return return
} }
ssh_dir, join_err := filepath.join([]string{home, ".ssh"}) ssh_dir, join_err := filepath.join([]string{home, ".ssh"})
if join_err != nil { if join_err != nil {
fmt.printf("Error building ssh path: %v\n", join_err) fmt.eprintf("Error building ssh path: %v\n", join_err)
return return
} }
entries, dir_err := os.read_all_directory_by_path(ssh_dir, context.allocator) entries, dir_err := os.read_all_directory_by_path(ssh_dir, context.allocator)
if dir_err != nil { if dir_err != nil {
fmt.printf("Could not read ~/.ssh directory: %v\n", dir_err) fmt.eprintf("Could not read ~/.ssh directory: %v\n", dir_err)
return return
} }
defer os.file_info_slice_delete(entries, context.allocator) defer os.file_info_slice_delete(entries, context.allocator)

View File

@@ -16,13 +16,9 @@ test_new_config_single_key :: proc(t: ^testing.T) {
cfg := new_config(paths) 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_value(t, len(cfg.keys), 1)
testing.expect(t, cfg.keys[0].private == "/home/user/.ssh/id_ed25519", "Private path mismatch") testing.expect_value(t, cfg.keys[0].private, "/home/user/.ssh/id_ed25519")
testing.expect( testing.expect_value(t, cfg.keys[0].public, "/home/user/.ssh/id_ed25519.pub")
t,
cfg.keys[0].public == "/home/user/.ssh/id_ed25519.pub",
"Public path mismatch",
)
} }
@(test) @(test)
@@ -31,9 +27,9 @@ test_new_config_multiple_keys :: proc(t: ^testing.T) {
cfg := new_config(paths) 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_value(t, len(cfg.keys), 2)
testing.expect(t, cfg.keys[0].private == "/home/user/.ssh/id_ed25519") testing.expect_value(t, cfg.keys[0].private, "/home/user/.ssh/id_ed25519")
testing.expect(t, cfg.keys[1].private == "/home/user/.ssh/id_rsa") testing.expect_value(t, cfg.keys[1].private, "/home/user/.ssh/id_rsa")
} }
@(test) @(test)
@@ -42,7 +38,7 @@ test_new_config_empty_keys :: proc(t: ^testing.T) {
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(&cfg) defer delete_config(&cfg)
testing.expect(t, len(cfg.keys) == 0, "should have 0 keys") testing.expect_value(t, len(cfg.keys), 0)
} }
@(test) @(test)
@@ -51,10 +47,10 @@ test_new_config_scan_defaults :: proc(t: ^testing.T) {
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(&cfg) defer delete_config(&cfg)
testing.expect(t, cfg.scan_config.matcher == "\\.env", "matcher should be \\.env") testing.expect_value(t, cfg.scan_config.matcher, "\\.env")
testing.expect(t, len(cfg.scan_config.exclude) == 4, "should have 4 exclude patterns") testing.expect_value(t, len(cfg.scan_config.exclude), 4)
testing.expect(t, len(cfg.scan_config.include) == 1, "should have 1 include path") testing.expect_value(t, len(cfg.scan_config.include), 1)
testing.expect(t, cfg.scan_config.include[0] == "~", "include should be ~") testing.expect_value(t, cfg.scan_config.include[0], "~")
} }
@(test) @(test)
@@ -65,7 +61,7 @@ test_new_config_exclude_patterns :: proc(t: ^testing.T) {
expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"} expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"}
for i in 0 ..< len(expected) { for i in 0 ..< len(expected) {
testing.expect(t, cfg.scan_config.exclude[i] == expected[i]) testing.expect_value(t, cfg.scan_config.exclude[i], expected[i])
} }
} }
@@ -75,7 +71,7 @@ test_save_load_config_roundtrip :: proc(t: ^testing.T) {
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)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect_value(t, err, nil)
cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath) cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath)
defer delete_config(&cfg) defer delete_config(&cfg)
@@ -87,13 +83,13 @@ test_save_load_config_roundtrip :: proc(t: ^testing.T) {
if !ok do return 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_value(t, len(loaded.keys), 1)
testing.expect(t, loaded.keys[0].private == "/home/user/.ssh/id_ed25519") testing.expect_value(t, loaded.keys[0].private, "/home/user/.ssh/id_ed25519")
testing.expect(t, loaded.keys[0].public == "/home/user/.ssh/id_ed25519.pub") testing.expect_value(t, loaded.keys[0].public, "/home/user/.ssh/id_ed25519.pub")
testing.expect(t, loaded.scan_config.matcher == "\\.env") testing.expect_value(t, loaded.scan_config.matcher, "\\.env")
testing.expect(t, len(loaded.scan_config.exclude) == 4) testing.expect_value(t, len(loaded.scan_config.exclude), 4)
testing.expect(t, len(loaded.scan_config.include) == 1) testing.expect_value(t, len(loaded.scan_config.include), 1)
testing.expect(t, loaded.scan_config.include[0] == "~") testing.expect_value(t, loaded.scan_config.include[0], "~")
} }
@(test) @(test)
@@ -108,7 +104,7 @@ test_save_config_no_clobber :: proc(t: ^testing.T) {
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)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect_value(t, err, nil)
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath) cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(&cfg) defer delete_config(&cfg)
@@ -125,7 +121,7 @@ test_save_config_force_overwrites :: proc(t: ^testing.T) {
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)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect_value(t, err, nil)
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath) cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(&cfg) defer delete_config(&cfg)
@@ -140,12 +136,8 @@ test_save_config_force_overwrites :: proc(t: ^testing.T) {
if !ok do return 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_value(t, len(loaded.keys), 1)
testing.expect( testing.expect_value(t, loaded.keys[0].private, "/home/user/.ssh/key2")
t,
loaded.keys[0].private == "/home/user/.ssh/key2",
"should be the overwritten key",
)
} }
@(test) @(test)
@@ -190,7 +182,7 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) {
paths := search_paths(cfg, context.temp_allocator) paths := search_paths(cfg, context.temp_allocator)
testing.expect(t, len(paths) == 1, "should have 1 path") testing.expect_value(t, len(paths), 1)
if len(paths) > 0 { if len(paths) > 0 {
testing.expectf( testing.expectf(
t, t,

View File

@@ -60,7 +60,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
&sym_key[0], &sym_key[0],
) )
if rc != 0 { if rc != 0 {
fmt.println("Error: symmetric encryption failed") fmt.eprintln("Error: symmetric encryption failed")
delete(secret_ct) delete(secret_ct)
return return
} }
@@ -84,7 +84,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
&x25519_pairs[0].Private[0], &x25519_pairs[0].Private[0],
) )
if rc != 0 { if rc != 0 {
fmt.printf("Error: failed to encrypt for recipient %d\n", i) fmt.eprintf("Error: failed to encrypt for recipient %d\n", i)
delete(entries) delete(entries)
delete(secret_ct) delete(secret_ct)
return return
@@ -132,13 +132,13 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: bool) { decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: bool) {
if len(ciphertext) < HEADER_SIZE { if len(ciphertext) < HEADER_SIZE {
fmt.println("Error: ciphertext too short (header)") fmt.eprintln("Error: ciphertext too short (header)")
return return
} }
for i in 0 ..< 4 { for i in 0 ..< 4 {
if ciphertext[i] != MAGIC_BYTES[i] { if ciphertext[i] != MAGIC_BYTES[i] {
fmt.println("Error: invalid magic bytes") fmt.eprintln("Error: invalid magic bytes")
return return
} }
} }
@@ -166,7 +166,7 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
recipients_end := offset + int(num_recipients) * RECIPIENT_ENTRY_SIZE recipients_end := offset + int(num_recipients) * RECIPIENT_ENTRY_SIZE
if recipients_end > len(ciphertext) { if recipients_end > len(ciphertext) {
fmt.println("Error: ciphertext too short (recipient data)") fmt.eprintln("Error: ciphertext too short (recipient data)")
return return
} }
@@ -222,7 +222,7 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
} }
if !found { if !found {
fmt.println("Error: no matching recipient found") fmt.eprintln("Error: no matching recipient found")
return return
} }
@@ -236,14 +236,14 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
&x25519_pairs[matched_pi].Private[0], &x25519_pairs[matched_pi].Private[0],
) )
if rc != 0 { if rc != 0 {
fmt.println("Error: failed to decrypt symmetric key") fmt.eprintln("Error: failed to decrypt symmetric key")
return return
} }
ct_data := ciphertext[recipients_end:] ct_data := ciphertext[recipients_end:]
pt_len := len(ct_data) - CRYPTO_SECRETBOX_MAC_BYTES pt_len := len(ct_data) - CRYPTO_SECRETBOX_MAC_BYTES
if pt_len < 0 { if pt_len < 0 {
fmt.println("Error: ciphertext too short (no encrypted data)") fmt.eprintln("Error: ciphertext too short (no encrypted data)")
return return
} }
@@ -260,7 +260,7 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
&sym_key[0], &sym_key[0],
) )
if rc != 0 { if rc != 0 {
fmt.println("Error: symmetric decryption failed") fmt.eprintln("Error: symmetric decryption failed")
delete(plaintext) delete(plaintext)
return return
} }
@@ -285,21 +285,21 @@ ssh_to_x25519 :: proc(
for i in 0 ..< len(keys) { for i in 0 ..< len(keys) {
ssh_kp, parse_ok := parse_ssh_private_key(keys[i].private) ssh_kp, parse_ok := parse_ssh_private_key(keys[i].private)
if !parse_ok { if !parse_ok {
fmt.printf("Error: failed to parse SSH private key: %s\n", keys[i].private) fmt.eprintf("Error: failed to parse SSH private key: %s\n", keys[i].private)
delete(pairs) delete(pairs)
return pairs, false return pairs, false
} }
ssh_pub, pub_ok := parse_ssh_public_key(keys[i].public) ssh_pub, pub_ok := parse_ssh_public_key(keys[i].public)
if !pub_ok { if !pub_ok {
fmt.printf("Error: failed to parse SSH public key: %s\n", keys[i].public) fmt.eprintf("Error: failed to parse SSH public key: %s\n", keys[i].public)
delete(pairs) delete(pairs)
return pairs, false return pairs, false
} }
pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0]) pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0])
if pk_rc != 0 { if pk_rc != 0 {
fmt.println("Error: failed to convert ed25519 public key to curve25519") fmt.eprintln("Error: failed to convert ed25519 public key to curve25519")
delete(pairs) delete(pairs)
return pairs, false return pairs, false
} }
@@ -314,7 +314,7 @@ ssh_to_x25519 :: proc(
sk_rc := crypto_sign_ed25519_sk_to_curve25519(&pairs[i].Private[0], &ed25519_sk[0]) sk_rc := crypto_sign_ed25519_sk_to_curve25519(&pairs[i].Private[0], &ed25519_sk[0])
if sk_rc != 0 { if sk_rc != 0 {
fmt.println("Error: failed to convert ed25519 private key to curve25519") fmt.eprintln("Error: failed to convert ed25519 private key to curve25519")
delete(pairs) delete(pairs)
return pairs, false return pairs, false
} }

View File

@@ -27,13 +27,10 @@ test_encrypt_decrypt_roundtrip :: proc(t: ^testing.T) {
testing.expect(t, dec_ok, "decryption should succeed") testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted) defer delete(decrypted)
testing.expect( testing.expect_value(t, len(decrypted), len(original))
t, // TODO: Should this be a loop?
len(decrypted) == len(original),
fmt.tprintf("expected %d bytes, got %d", len(original), len(decrypted)),
)
for i in 0 ..< len(original) { for i in 0 ..< len(original) {
testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at index %d", i)) testing.expect_value(t, decrypted[i], original[i])
} }
} }
@@ -56,16 +53,8 @@ test_encrypt_decrypt_multi_recipient :: proc(t: ^testing.T) {
defer delete(decrypted2) defer delete(decrypted2)
for i in 0 ..< len(original) { for i in 0 ..< len(original) {
testing.expect( testing.expect_value(t, decrypted1[i], original[i])
t, testing.expect_value(t, decrypted2[i], original[i])
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),
)
} }
} }
@@ -96,7 +85,7 @@ test_encrypt_empty_plaintext :: proc(t: ^testing.T) {
testing.expect(t, dec_ok, "decryption should succeed") testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted) defer delete(decrypted)
testing.expect(t, len(decrypted) == 0, "decrypted empty data should be empty") testing.expect_value(t, len(decrypted), 0)
} }
@(test) @(test)
@@ -113,8 +102,9 @@ test_recipient_can_decrypt_senders_data :: proc(t: ^testing.T) {
testing.expect(t, dec_ok, "second recipient should decrypt without the sender key present") testing.expect(t, dec_ok, "second recipient should decrypt without the sender key present")
defer delete(decrypted) defer delete(decrypted)
// TODO: Should this be a loop?
for i in 0 ..< len(original) { for i in 0 ..< len(original) {
testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at %d", i)) testing.expect_value(t, decrypted[i], original[i])
} }
} }
@@ -128,9 +118,9 @@ test_ciphertext_has_magic :: proc(t: ^testing.T) {
defer delete(encrypted) defer delete(encrypted)
testing.expect(t, len(encrypted) >= 4, "ciphertext should have at least 4 bytes") testing.expect(t, len(encrypted) >= 4, "ciphertext should have at least 4 bytes")
testing.expect(t, encrypted[0] == u8('E'), "magic byte 0") testing.expect_value(t, encrypted[0], u8('E'))
testing.expect(t, encrypted[1] == u8('N'), "magic byte 1") testing.expect_value(t, encrypted[1], u8('N'))
testing.expect(t, encrypted[2] == u8('V'), "magic byte 2") testing.expect_value(t, encrypted[2], u8('V'))
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3") testing.expect_value(t, encrypted[3], u8('R'))
} }

142
db.odin
View File

@@ -94,14 +94,14 @@ db_init :: proc() -> (db: Db, ok: bool) {
conn: sqlite.Db conn: sqlite.Db
rc := sqlite.open(":memory:", &conn) rc := sqlite.open(":memory:", &conn)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error opening in-memory database: %s\n", sqlite.errmsg(conn)) fmt.eprintf("Error opening in-memory database: %s\n", sqlite.errmsg(conn))
return return
} }
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)" 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.exec(conn, create_sql, nil, nil, nil) rc = sqlite.exec(conn, create_sql, nil, nil, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error creating table: %s\n", sqlite.errmsg(conn)) fmt.eprintf("Error creating table: %s\n", sqlite.errmsg(conn))
sqlite.close(conn) sqlite.close(conn)
return return
} }
@@ -119,14 +119,14 @@ db_allocator :: proc(db: ^Db) -> mem.Allocator {
db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool { db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator) encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator)
if read_err != nil { if read_err != nil {
fmt.printf("Error reading encrypted database: %v\n", read_err) fmt.eprintf("Error reading encrypted database: %v\n", read_err)
return false return false
} }
// TODO: Use context.temp_allocator // TODO: Use context.temp_allocator
plaintext, dec_ok := decrypt(encrypted_data, db.cfg.keys[:]) plaintext, dec_ok := decrypt(encrypted_data, db.cfg.keys[:])
if !dec_ok { if !dec_ok {
fmt.println("Error: decryption failed") fmt.eprintln("Error: decryption failed")
return false return false
} }
defer delete(plaintext) defer delete(plaintext)
@@ -134,7 +134,7 @@ db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
n := i64(len(plaintext)) n := i64(len(plaintext))
buf := sqlite.malloc64(n) buf := sqlite.malloc64(n)
if buf == nil { if buf == nil {
fmt.println("Error: failed to allocate buffer for deserialization") fmt.eprintln("Error: failed to allocate buffer for deserialization")
return false return false
} }
copy(buf[:len(plaintext)], plaintext) copy(buf[:len(plaintext)], plaintext)
@@ -144,7 +144,7 @@ db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
rc := sqlite.deserialize(db.conn, "main", buf, n, n, flags) rc := sqlite.deserialize(db.conn, "main", buf, n, n, flags)
if rc != sqlite.OK { if rc != sqlite.OK {
sqlite.free(buf) sqlite.free(buf)
fmt.printf("Error deserializing database: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error deserializing database: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
@@ -167,14 +167,14 @@ db_close :: proc(db: ^Db) {
if db.changed && len(db.cfg.keys) > 0 { 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.eprintf("Error vacuuming database: %s\n", sqlite.errmsg(db.conn))
return return
} }
sz: i64 sz: i64
data := sqlite.serialize(db.conn, "main", &sz, 0) data := sqlite.serialize(db.conn, "main", &sz, 0)
if data == nil { if data == nil {
fmt.println("Error: failed to serialize database") fmt.eprintln("Error: failed to serialize database")
return return
} }
defer sqlite.free(data) defer sqlite.free(data)
@@ -195,7 +195,7 @@ db_close :: proc(db: ^Db) {
write_err := os.write_entire_file(data_path, encrypted) write_err := os.write_entire_file(data_path, encrypted)
delete(encrypted) delete(encrypted)
if write_err != nil { if write_err != nil {
fmt.printf("Error writing encrypted database: %v\n", write_err) fmt.eprintf("Error writing encrypted database: %v\n", write_err)
return return
} }
@@ -214,7 +214,7 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
nil, nil,
) )
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing query: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error preparing query: %s\n", sqlite.errmsg(db.conn))
return []EnvFile{}, false return []EnvFile{}, false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
@@ -222,22 +222,33 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
allocator := db_allocator(db) allocator := db_allocator(db)
results := make([dynamic]EnvFile, 0, 10, allocator) results := make([dynamic]EnvFile, 0, 10, allocator)
migrate := false
for { for {
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc == sqlite.DONE { if rc == sqlite.DONE {
break break
} }
if rc != sqlite.ROW { if rc != sqlite.ROW {
fmt.printf("Error stepping query: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error stepping query: %s\n", sqlite.errmsg(db.conn))
#no_bounds_check return results[:], false #no_bounds_check return results[:], false
} }
remotes_json := string(sqlite.column_text(stmt, 1)) // TODO: Remove json support after next major release
remotes: [dynamic]string = --- remotes: [dynamic]string = ---
if len(remotes_json) > 0 { remotes_raw := string(sqlite.column_text(stmt, 1))
err := json.unmarshal_string(remotes_json, &remotes, allocator = allocator) if len(remotes_raw) > 0 {
if err != nil { if remotes_raw[0] == '[' {
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err) err := json.unmarshal_string(remotes_raw, &remotes, allocator = allocator)
if err != nil {
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err)
}
migrate = true
} else {
split := strings.split_lines(remotes_raw, context.temp_allocator)
remotes = make([dynamic]string, 0, len(split), allocator = allocator)
for s in split {
append(&remotes, strings.clone(s, allocator))
}
} }
} }
path := clone_cstring(sqlite.column_text(stmt, 0), allocator) path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
@@ -254,16 +265,16 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
) )
} }
if migrate {
migrate_remotes(db)
}
#no_bounds_check return results[:], true #no_bounds_check return results[:], true
} }
// 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 := strings.join(file.remotes[:], "\n", allocator = context.temp_allocator)
if marshal_err != nil {
fmt.printf("Error marshaling remotes: %v\n", marshal_err)
return false
}
sql: cstring = sql: cstring =
"INSERT OR REPLACE INTO " + "INSERT OR REPLACE INTO " +
@@ -271,7 +282,7 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
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)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing insert: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error preparing insert: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
@@ -281,15 +292,15 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
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 {
fmt.printf("Error binding path: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error binding path: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
cremotes := to_cstring(string(remotes_json)) cremotes := to_cstring(remotes)
defer delete(cremotes) defer delete(cremotes)
rc = sqlite.bind_text(stmt, 2, cremotes, -1, nil) rc = sqlite.bind_text(stmt, 2, cremotes, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding remotes: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error binding remotes: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
@@ -297,7 +308,7 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
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 {
fmt.printf("Error binding sha256: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error binding sha256: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
@@ -305,13 +316,13 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
defer delete(ccontents) defer delete(ccontents)
rc = sqlite.bind_text(stmt, 4, ccontents, -1, nil) rc = sqlite.bind_text(stmt, 4, ccontents, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding contents: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error binding contents: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc != sqlite.DONE { if rc != sqlite.DONE {
fmt.printf("Error inserting: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error inserting: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
@@ -329,7 +340,7 @@ db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
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)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing fetch: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error preparing fetch: %s\n", sqlite.errmsg(db.conn))
return EnvFile{}, false return EnvFile{}, false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
@@ -340,30 +351,46 @@ db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
defer delete(cpath, allocator) defer delete(cpath, allocator)
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 {
fmt.printf("Error binding path: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error binding path: %s\n", sqlite.errmsg(db.conn))
return EnvFile{}, false return EnvFile{}, false
} }
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc == sqlite.DONE { if rc == sqlite.DONE {
fmt.printf("No file found with path: %s\n", path) fmt.eprintf("No file found with path: %s\n", path)
return EnvFile{}, false return EnvFile{}, false
} }
if rc != sqlite.ROW { if rc != sqlite.ROW {
fmt.printf("Error fetching: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error fetching: %s\n", sqlite.errmsg(db.conn))
return EnvFile{}, false return EnvFile{}, false
} }
remotes_json := string(sqlite.column_text(stmt, 1)) // TODO: Remove json support after next major release
migrate := false
remotes: [dynamic]string = --- remotes: [dynamic]string = ---
if len(remotes_json) > 0 { remotes_raw := string(sqlite.column_text(stmt, 1))
err := json.unmarshal_string(remotes_json, &remotes, allocator = allocator) if len(remotes_raw) > 0 {
if err != nil { if remotes_raw[0] == '[' {
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err) err := json.unmarshal_string(remotes_raw, &remotes, allocator = allocator)
if err != nil {
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err)
}
migrate = true
} else {
split := strings.split_lines(remotes_raw, context.temp_allocator)
remotes = make([dynamic]string, 0, len(split), allocator = allocator)
for s in split {
append(&remotes, strings.clone(s, allocator))
}
} }
} }
file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator) file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
if migrate {
migrate_remotes(db)
}
return EnvFile { return EnvFile {
path = file_path, path = file_path,
dir = filepath.dir(file_path), dir = filepath.dir(file_path),
@@ -379,7 +406,7 @@ db_delete :: proc(db: ^Db, path: string) -> bool {
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)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing delete: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error preparing delete: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
@@ -388,17 +415,17 @@ db_delete :: proc(db: ^Db, path: string) -> bool {
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 {
fmt.printf("Error binding path: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error binding path: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc != sqlite.DONE { if rc != sqlite.DONE {
fmt.printf("Error deleting: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error deleting: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
if sqlite.changes(db.conn) == 0 { if sqlite.changes(db.conn) == 0 {
fmt.printf("No file found with path: %s\n", path) fmt.eprintf("No file found with path: %s\n", path)
return false return false
} }
@@ -410,7 +437,7 @@ db_delete :: proc(db: ^Db, path: string) -> bool {
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 {
fmt.printf("Error getting absolute path: %v\n", abs_err) fmt.eprintf("Error getting absolute path: %v\n", abs_err)
return EnvFile{}, false return EnvFile{}, false
} }
@@ -421,7 +448,7 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
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)
if read_err != nil { if read_err != nil {
fmt.printf("Error reading file %s: %v\n", abs_path, read_err) fmt.eprintf("Error reading file %s: %v\n", abs_path, read_err)
return EnvFile{}, false return EnvFile{}, false
} }
@@ -498,6 +525,27 @@ db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool {
return db_insert(db, f^) return db_insert(db, f^)
} }
// TODO: Remove after the next major release
migrate_remotes :: proc(db: ^Db) {
sql ::
"UPDATE envr_env_files " +
"SET remotes = COALESCE((" +
" SELECT group_concat(atom, char(10)) " +
" FROM json_each(envr_env_files.remotes)" +
"), '') " +
"WHERE remotes LIKE '[%'"
rc := sqlite.exec(db.conn, sql, nil, nil, nil)
if rc != sqlite.OK {
fmt.eprintf("Warning: failed to migrate remotes: %s\n", sqlite.errmsg(db.conn))
return
}
if sqlite.changes(db.conn) > 0 {
db.changed = true
}
}
try_move_dir :: proc(db: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, SyncError) { try_move_dir :: proc(db: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, SyncError) {
roots, ok := find_git_roots(db.cfg) roots, ok := find_git_roots(db.cfg)
if !ok { if !ok {
@@ -524,7 +572,7 @@ 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
@@ -565,7 +613,7 @@ get_git_remotes :: proc(dir: string, allocator: mem.Allocator) -> [dynamic]strin
} }
if !found { if !found {
// FIXME: Currently leaks when adding a file with envr scan // FIXME: Currently leaks when adding a file with envr scan
cloned, _ := strings.clone(url, allocator) cloned := strings.clone(url, allocator)
append(&remotes, cloned) append(&remotes, cloned)
} }
} }
@@ -583,7 +631,7 @@ to_cstring :: proc {
string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring { string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring {
cs, err := strings.clone_to_cstring(s, allocator) cs, err := strings.clone_to_cstring(s, allocator)
if err != nil { if err != nil {
fmt.printf("Failed to convert string to cstring: %v\n", err) fmt.eprintf("Failed to convert string to cstring: %v\n", err)
panic("Allocation Exception") panic("Allocation Exception")
} }
return cs return cs
@@ -593,7 +641,7 @@ string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring
clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string { clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
str, err := strings.clone_from_cstring(c, allocator) str, err := strings.clone_from_cstring(c, allocator)
if err != nil { if err != nil {
fmt.printf("Failed to convert string to cstring: %v\n", err) fmt.eprintf("Failed to convert string to cstring: %v\n", err)
delete(str) delete(str)
panic("Allocation Exception") panic("Allocation Exception")
} }

View File

@@ -67,10 +67,10 @@ test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
defer delete(encrypted) defer delete(encrypted)
testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header") testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header")
testing.expect(t, encrypted[0] == u8('E'), "magic byte 0") testing.expect_value(t, encrypted[0], u8('E'))
testing.expect(t, encrypted[1] == u8('N'), "magic byte 1") testing.expect_value(t, encrypted[1], u8('N'))
testing.expect(t, encrypted[2] == u8('V'), "magic byte 2") testing.expect_value(t, encrypted[2], u8('V'))
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3") testing.expect_value(t, encrypted[3], u8('R'))
plaintext, dec_ok := decrypt(encrypted, cfg.keys[:]) plaintext, dec_ok := decrypt(encrypted, cfg.keys[:])
testing.expect(t, dec_ok, "decryption should succeed") testing.expect(t, dec_ok, "decryption should succeed")
@@ -142,7 +142,7 @@ test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
} }
defer delete(plaintext) defer delete(plaintext)
testing.expect(t, len(plaintext) == len(sqlite_data), "size mismatch after file round-trip") testing.expect_value(t, len(plaintext), len(sqlite_data))
} }
@(test) @(test)
@@ -189,7 +189,7 @@ test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
copy(buf[:len(plaintext)], plaintext) copy(buf[:len(plaintext)], plaintext)
rc = sqlite.deserialize(mem_db, "main", buf, n, n, {.FREEONCLOSE, .RESIZEABLE}) rc = sqlite.deserialize(mem_db, "main", buf, n, n, {.FREEONCLOSE, .RESIZEABLE})
testing.expect(t, rc == sqlite.OK, "deserialize should succeed") testing.expect_value(t, rc, sqlite.OK)
if rc != sqlite.OK { if rc != sqlite.OK {
sqlite.free(buf) sqlite.free(buf)
return return
@@ -198,14 +198,14 @@ test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
sql: cstring = "SELECT path FROM envr_env_files" sql: cstring = "SELECT path FROM envr_env_files"
stmt: sqlite.Stmt stmt: sqlite.Stmt
rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil) rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil)
testing.expect(t, rc == sqlite.OK, "prepare failed") testing.expect_value(t, rc, sqlite.OK)
if rc != sqlite.OK { if rc != sqlite.OK {
return return
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
testing.expect(t, rc == sqlite.ROW, "expected at least one row") testing.expect_value(t, rc, sqlite.ROW)
if rc == sqlite.ROW { if rc == sqlite.ROW {
path := string(sqlite.column_text(stmt, 0)) path := string(sqlite.column_text(stmt, 0))
testing.expect(t, len(path) > 0, "path should not be empty") testing.expect(t, len(path) > 0, "path should not be empty")
@@ -275,15 +275,7 @@ test_full_db_cycle :: proc(t: ^testing.T) {
} }
defer delete(plaintext2) defer delete(plaintext2)
testing.expect( testing.expect_value(t, len(plaintext2), len(original_data))
t,
len(plaintext2) == len(original_data),
fmt.tprintf(
"double round-trip size mismatch: expected %d, got %d",
len(original_data),
len(plaintext2),
),
)
os.remove(data_path) os.remove(data_path)
os.remove(envr_dir_path) os.remove(envr_dir_path)
@@ -317,7 +309,7 @@ test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
return return
} }
testing.expect(t, len(x25519_pairs) == 1, "should have 1 x25519 keypair") testing.expect_value(t, len(x25519_pairs), 1)
} }
@(test) @(test)
@@ -327,7 +319,7 @@ test_config_load_with_fixture_key :: proc(t: ^testing.T) {
delete(cfg.keys) delete(cfg.keys)
} }
testing.expect(t, len(cfg.keys) == 1, "should have 1 key") testing.expect_value(t, len(cfg.keys), 1)
key := cfg.keys[0] key := cfg.keys[0]

View File

@@ -81,7 +81,7 @@ test_db_insert_or_replace :: proc(t: ^testing.T) {
results, list_ok := db_list(&db) results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed") testing.expect(t, list_ok, "list should succeed")
testing.expect(t, len(results) == 1, "should have 1 row, not 2") testing.expect_value(t, len(results), 1)
fetched, fetch_ok := db_fetch(&db, "/project/.env") fetched, fetch_ok := db_fetch(&db, "/project/.env")
testing.expect(t, fetch_ok, "fetch should succeed") testing.expect(t, fetch_ok, "fetch should succeed")
@@ -149,7 +149,7 @@ test_db_list_empty :: proc(t: ^testing.T) {
results, list_ok := db_list(&db) results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed on empty db") testing.expect(t, list_ok, "list should succeed on empty db")
testing.expect(t, len(results) == 0, "should have 0 rows") testing.expect_value(t, len(results), 0)
} }
@(test) @(test)
@@ -276,11 +276,11 @@ test_get_git_remotes_single :: proc(t: ^testing.T) {
config_content := "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n" config_content := "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
config_path := fmt.tprintf("%s/config", git_dir) config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base, context.temp_allocator)
testing.expect(t, len(remotes) == 1, "should find 1 remote") testing.expect_value(t, len(remotes), 1)
if len(remotes) != 1 do return if len(remotes) != 1 do return
testing.expect_value(t, remotes[0], "git@github.com:user/repo.git") testing.expect_value(t, remotes[0], "git@github.com:user/repo.git")
} }
@@ -296,11 +296,11 @@ test_get_git_remotes_multiple :: proc(t: ^testing.T) {
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n[remote \"upstream\"]\n\turl = https://gitlab.com/upstream/repo.git\n" config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n[remote \"upstream\"]\n\turl = https://gitlab.com/upstream/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir) config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base, context.temp_allocator)
testing.expect(t, len(remotes) == 2, "should find 2 remotes") testing.expect_value(t, len(remotes), 2)
} }
@(test) @(test)
@@ -310,7 +310,7 @@ test_get_git_remotes_no_config :: proc(t: ^testing.T) {
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base, context.temp_allocator)
testing.expect(t, len(remotes) == 0, "should return empty when no .git/config") testing.expect_value(t, len(remotes), 0)
} }
@(test) @(test)
@@ -324,11 +324,11 @@ test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
config_content := "[core]\n\trepositoryformatversion = 0\n\tbare = false\n" config_content := "[core]\n\trepositoryformatversion = 0\n\tbare = false\n"
config_path := fmt.tprintf("%s/config", git_dir) config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base, context.temp_allocator)
testing.expect(t, len(remotes) == 0, "should return empty when no remote sections") testing.expect_value(t, len(remotes), 0)
} }
@(test) @(test)
@@ -338,7 +338,7 @@ test_new_env_file :: proc(t: ^testing.T) {
env_path := fmt.tprintf("%s/.env", base) env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "SECRET=value\n") err := os.write_entire_file(env_path, "SECRET=value\n")
testing.expect(t, err == nil, ".env file should exists") testing.expect_value(t, err, nil)
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")
@@ -350,8 +350,8 @@ test_new_env_file :: proc(t: ^testing.T) {
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_value(t, file.contents, "SECRET=value\n")
testing.expect(t, len(file.sha256) == 64, "sha256 should be 64 hex chars") testing.expect_value(t, len(file.sha256), 64)
} }
@(test) @(test)
@@ -366,7 +366,7 @@ test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
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)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect_value(t, err, nil)
{ {
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path) cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
@@ -385,7 +385,7 @@ test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
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)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect_value(t, err, nil)
{ {
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path) cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
@@ -422,7 +422,7 @@ test_db_sync_noop :: proc(t: ^testing.T) {
env_path := fmt.tprintf("%s/.env", base) env_path := fmt.tprintf("%s/.env", base)
content := "KEY=value\n" content := "KEY=value\n"
write_err := os.write_entire_file(env_path, transmute([]u8)content) write_err := os.write_entire_file(env_path, transmute([]u8)content)
testing.expect(t, write_err == nil, "should write .env file") testing.expect_value(t, write_err, nil)
digest := hash.hash_bytes( digest := hash.hash_bytes(
hash.Algorithm.SHA256, hash.Algorithm.SHA256,
@@ -441,8 +441,8 @@ test_db_sync_noop :: proc(t: ^testing.T) {
db_insert(&db, f) db_insert(&db, f)
result, sync_err := db_sync(&db, &f) result, sync_err := db_sync(&db, &f)
testing.expect(t, sync_err == .None, "sync should not error") testing.expect_value(t, sync_err, SyncError.None)
testing.expect(t, result == {}, "should be noop") testing.expect_value(t, result, nil)
} }
@(test) @(test)
@@ -453,7 +453,7 @@ test_db_sync_backed_up :: proc(t: ^testing.T) {
env_path := fmt.tprintf("%s/.env", base) env_path := fmt.tprintf("%s/.env", base)
changed_content := "KEY=changed\n" changed_content := "KEY=changed\n"
write_err := os.write_entire_file(env_path, transmute([]u8)changed_content) write_err := os.write_entire_file(env_path, transmute([]u8)changed_content)
testing.expect(t, write_err == nil, "should write .env file") testing.expect_value(t, write_err, nil)
db, ok := db_init() db, ok := db_init()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
@@ -464,7 +464,7 @@ test_db_sync_backed_up :: proc(t: ^testing.T) {
db_insert(&db, f) db_insert(&db, f)
result, sync_err := db_sync(&db, &f) result, sync_err := db_sync(&db, &f)
testing.expect(t, sync_err == .None, "sync should not error") testing.expect_value(t, sync_err, SyncError.None)
testing.expect(t, .BackedUp in result, "should be backed up") testing.expect(t, .BackedUp in result, "should be backed up")
} }
@@ -485,11 +485,11 @@ test_db_sync_restored :: proc(t: ^testing.T) {
db_insert(&db, f) db_insert(&db, f)
result, err := db_sync(&db, &f) result, err := db_sync(&db, &f)
testing.expect(t, err == .None, "sync should not error") testing.expect_value(t, err, SyncError.None)
testing.expect(t, .Restored in result, "should be restored") testing.expect(t, .Restored in result, "should be restored")
data, read_err := os.read_entire_file_from_path(env_path, context.temp_allocator) data, read_err := os.read_entire_file_from_path(env_path, context.temp_allocator)
testing.expect(t, read_err == nil, "file should exist after restore") testing.expect_value(t, read_err, nil)
if read_err == nil { if read_err == nil {
testing.expect_value(t, string(data), "SECRET=value") testing.expect_value(t, string(data), "SECRET=value")
} }
@@ -522,7 +522,7 @@ test_db_sync_moved :: proc(t: ^testing.T) {
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n" config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir) config_path := fmt.tprintf("%s/config", git_dir)
write_err := os.write_entire_file(config_path, transmute([]u8)config_content) write_err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, write_err == nil, "should write .git/config") testing.expect_value(t, write_err, nil)
db, ok := db_init() db, ok := db_init()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
@@ -540,7 +540,7 @@ test_db_sync_moved :: proc(t: ^testing.T) {
testing.expect(t, db_insert(&db, f), "insert should succeed") testing.expect(t, db_insert(&db, f), "insert should succeed")
result, err := db_sync(&db, &f) result, err := db_sync(&db, &f)
testing.expect(t, err == .None, "sync should not error") testing.expect_value(t, err, SyncError.None)
if err != .None do return if err != .None do return
testing.expect(t, .DirUpdated in result, "should have DirUpdated flag") testing.expect(t, .DirUpdated in result, "should have DirUpdated flag")
testing.expect(t, .Restored in result, "should have Restored flag") testing.expect(t, .Restored in result, "should have Restored flag")

View File

@@ -26,7 +26,7 @@ find_repos :: proc(roots: []string, results: ^[dynamic]string, thread_count: int
pool.threads = make([]^thread.Thread, thread_count) pool.threads = make([]^thread.Thread, thread_count)
for root in roots { for root in roots {
root_clone, _ := strings.clone(root) root_clone := strings.clone(root)
append(&pool.queue, root_clone) append(&pool.queue, root_clone)
sync.atomic_sema_post(&pool.queue_sema) sync.atomic_sema_post(&pool.queue_sema)
} }
@@ -97,7 +97,7 @@ process_repo_dir :: proc(pool: ^RepoPool, dir_path: string) {
defer linux.close(fd) defer linux.close(fd)
if has_git_dir(fd) { if has_git_dir(fd) {
cloned, _ := strings.clone(dir_path) cloned := strings.clone(dir_path)
sync.mutex_lock(&pool.results_lock) sync.mutex_lock(&pool.results_lock)
append(pool.results, cloned) append(pool.results, cloned)
sync.mutex_unlock(&pool.results_lock) sync.mutex_unlock(&pool.results_lock)

View File

@@ -137,7 +137,7 @@ collect_results :: proc(env: TestEnv, args: []string, opts: WalkOptions) -> [dyn
if len(stripped) > 0 && stripped[0] == os.Path_Separator { if len(stripped) > 0 && stripped[0] == os.Path_Separator {
stripped = stripped[1:] stripped = stripped[1:]
} }
new_r, _ := strings.clone(stripped) new_r := strings.clone(stripped)
delete(r) delete(r)
results[i] = new_r results[i] = new_r
} }

View File

@@ -83,6 +83,6 @@ test_scan_path_empty_dir :: proc(t: ^testing.T) {
results, ok := scan_path(base, cfg) results, ok := scan_path(base, cfg)
defer delete(results) defer delete(results)
testing.expect(t, ok, "scan_path should succeed") testing.expect(t, ok, "scan_path should succeed")
testing.expect(t, len(results) == 0, fmt.tprintf("expected 0 results, got %d", len(results))) testing.expect_value(t, len(results), 0)
} }

View File

@@ -17,6 +17,12 @@ DESERIALIZE_FLAGS :: bit_set[DESERIALIZE_FLAG]
DESERIALIZE_FLAG :: enum u32 { DESERIALIZE_FLAG :: enum u32 {
FREEONCLOSE = 1, FREEONCLOSE = 1,
RESIZEABLE = 2, RESIZEABLE = 2,
READONLY = 4,
}
SERIALIZE_FLAGS :: bit_set[SERIALIZE_FLAG]
SERIALIZE_FLAG :: enum u32 {
NOCOPY = 1,
} }
foreign lib { foreign lib {
@@ -43,7 +49,7 @@ foreign lib {
@(link_name = "sqlite3_changes") @(link_name = "sqlite3_changes")
changes :: proc(db: Db) -> c.int --- changes :: proc(db: Db) -> c.int ---
@(link_name = "sqlite3_serialize") @(link_name = "sqlite3_serialize")
serialize :: proc(db: Db, zSchema: cstring, piSize: ^i64, mFlags: u32) -> [^]u8 --- serialize :: proc(db: Db, zSchema: cstring, piSize: ^i64, mFlags: SERIALIZE_FLAGS) -> [^]u8 ---
@(link_name = "sqlite3_deserialize") @(link_name = "sqlite3_deserialize")
deserialize :: proc(db: Db, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: DESERIALIZE_FLAGS) -> c.int --- deserialize :: proc(db: Db, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: DESERIALIZE_FLAGS) -> c.int ---
@(link_name = "sqlite3_malloc64") @(link_name = "sqlite3_malloc64")

View File

@@ -46,15 +46,7 @@ test_private_key_pub_matches_public_key :: proc(t: ^testing.T) {
kp, priv_ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519") kp, priv_ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519")
testing.expect(t, priv_ok, "expected private key to parse") testing.expect(t, priv_ok, "expected private key to parse")
testing.expect( testing.expect_value(t, pub_from_pub, kp.Public)
t,
pub_from_pub == kp.Public,
fmt.tprintf(
"public key mismatch:\n from .pub: %v\n from priv: %v",
pub_from_pub,
kp.Public,
),
)
} }
@(test) @(test)
@@ -64,12 +56,11 @@ test_read_wire_string :: proc(t: ^testing.T) {
s, ok := read_wire_string(data, &offset) s, ok := read_wire_string(data, &offset)
testing.expect(t, ok, "expected read_wire_string to succeed") testing.expect(t, ok, "expected read_wire_string to succeed")
testing.expect(t, s == "hello", fmt.tprintf("expected 'hello', got %q", s)) testing.expect_value(t, s, "hello")
testing.expect(t, offset == 9, fmt.tprintf("expected offset 9, got %d", offset)) testing.expect_value(t, offset, 9)
s2, ok2 := read_wire_string(data, &offset) s2, ok2 := read_wire_string(data, &offset)
testing.expect(t, ok2, "expected second read to succeed") testing.expect(t, ok2, "expected second read to succeed")
testing.expect(t, s2 == "", "expected empty string") testing.expect_value(t, s2, "")
} }

View File

@@ -3,7 +3,6 @@ package main
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:text/table" import "core:text/table"
import "core:unicode/utf8"
decorations := table.Decorations { decorations := table.Decorations {
"┌", "┌",
@@ -19,20 +18,17 @@ decorations := table.Decorations {
"─", "─",
} }
// TODO: Optimize ansi_aware_width ansi_aware_width :: proc(str: string) -> int #no_bounds_check {
ansi_aware_width :: proc(str: string) -> int { width := 0
buf: [4096]byte for i := 0; i < len(str); {
pos := 0
i := 0
for i < len(str) {
if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' { if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' {
i += 2 i += 2
for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}} for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}}
} else { } else {
buf[pos] = str[i]; pos += 1; i += 1 width += 1
i += 1
} }
} }
_, _, width := utf8.grapheme_count(string(buf[:pos]))
return width return width
} }

View File

@@ -21,9 +21,9 @@ test_ansi_aware_width_with_color_codes :: proc(t: ^testing.T) {
} }
@(test) @(test)
test_ansi_aware_width_unicode :: proc(t: ^testing.T) { test_ansi_aware_width_multibyte :: proc(t: ^testing.T) {
testing.expect_value(t, ansi_aware_width("\u2713 Available"), 11) testing.expect_value(t, ansi_aware_width("\u2713 Available"), 13)
testing.expect_value(t, ansi_aware_width("\u2717 Missing"), 9) testing.expect_value(t, ansi_aware_width("\u2717 Missing"), 11)
} }
@(test) @(test)