45 Commits

Author SHA1 Message Date
Spencer Brower
84550d4708 chore(main): release 0.3.0 2026-06-16 11:43:36 -04:00
fe2b256bd6 feat: All encryption/decryption now happens in-memory.
Release-as: v0.3.0
2026-06-16 11:38:20 -04:00
41decd9cdb ci: Updated release-please. 2026-06-16 11:36:05 -04:00
397f45d4d0 chore: Completed todos. 2026-06-16 11:36:05 -04:00
73a41830d1 docs: Removed completed TODOs. 2026-06-16 11:36:05 -04:00
e17d04c93d test: Table tests now view full output. 2026-06-16 11:36:05 -04:00
4600c81401 test: commands now accept stdout/stderr fields. 2026-06-16 11:36:05 -04:00
ec96dff055 chore: Cleaned up code. 2026-06-16 11:36:05 -04:00
4a26ee8145 feat: Config can be loaded from any path with --config-file (-c) flag. 2026-06-16 11:36:05 -04:00
e23ea960d7 test: Added missing tests. 2026-06-16 11:36:04 -04:00
3db86f0d2e refactor: Fixed cli command. 2026-06-16 11:36:04 -04:00
567cc8b1e2 tests: Added plan for improving testing. 2026-06-16 11:36:04 -04:00
fe3253f274 refactor: Fixed duplicate terminal checks. 2026-06-16 11:36:04 -04:00
f6ffeeee65 docs: Created table improvement plan. 2026-06-16 11:36:04 -04:00
23d5ff5e01 style: Removed unused code. 2026-06-16 11:36:04 -04:00
4599b25b1b refactor: Removed duplicate insert calls. 2026-06-16 11:36:04 -04:00
e32f0ea6d2 refactor: Fixed logic bug in db. 2026-06-16 11:36:03 -04:00
650c91d51b ci: Updated release-please. 2026-06-16 11:36:03 -04:00
ad3ce748bb docs: updated README.md 2026-06-16 11:36:03 -04:00
930c3d4c5d ci: Updated github action. 2026-06-16 11:36:03 -04:00
b1b0449b7b refactor: Removed go code. 2026-06-16 11:36:03 -04:00
0a74b0dbcc build: Converted Makefile and flake package. 2026-06-16 11:36:03 -04:00
d56f11250c refactor: removed is_tty. 2026-06-16 11:36:03 -04:00
23b8c2dc67 feat: Switched from age to libsodium.
This means, fewer dependencies, a smaller binary, and more secure data.

BREAKING CHANGE: The encryption format of databases has changed. Age
encryption is no longer supported, and no automatic migration path was
implemented.
2026-06-16 11:34:36 -04:00
2f4a7887ea docs: Updated TODOs. 2026-06-16 10:48:11 -04:00
5eee6cd6ea refactor(odin): Migrated nushell-completion command to go. 2026-06-16 10:48:10 -04:00
67f735a654 test: Added tests. 2026-06-16 10:48:10 -04:00
7d16dae4f4 refactor: Fixed the rest of the (tested) leaks. 2026-06-16 10:48:10 -04:00
365e9149b1 perf: Improved writer performance. 2026-06-16 10:48:10 -04:00
1068458f32 refactor: Fixed a number of memory leaks. 2026-06-16 10:48:10 -04:00
22a517340a refactor(odin): Added proper help text to all commands. 2026-06-16 10:48:10 -04:00
fcee4ca7b1 refactor: Got rid of go fallback code. 2026-06-16 10:48:10 -04:00
dff5235d65 refactor: Fixed memory leaks in find_binary. 2026-06-16 10:48:09 -04:00
5865315161 refactor(odin): Ported init command. 2026-06-16 10:48:09 -04:00
191ba305ef refactor(odin): Ported scan command. 2026-06-16 10:48:09 -04:00
d890c88b6d refactor(odin): port check command. 2026-06-16 10:48:09 -04:00
f8add2ad22 refactor(odin): Fixed AI mistakes. 2026-06-16 10:47:57 -04:00
2de7e20f5c refactor(odin): ported edit-config command. 2026-06-16 10:22:25 -04:00
8dd6b17cb9 refactor(odin): Ported restore command. 2026-06-16 10:22:08 -04:00
83b940337c refactor(odin): Ported remove command. 2026-06-16 10:21:46 -04:00
83a8caf691 refactor(odin): Added long text and --help flags. 2026-06-16 10:21:24 -04:00
1964698e35 refactor(odin): ported backup command. 2026-06-16 10:18:56 -04:00
de2186a2e5 refactor(odin): ported list command. 2026-06-16 10:18:21 -04:00
cb51a398ad refactor(odin): ported deps command, added utilities (features, tty, table). 2026-06-16 10:18:05 -04:00
e989b88303 refactor: scaffolded odin project with CLI parser, version command, Go fallback 2026-06-16 10:17:12 -04:00
46 changed files with 1688 additions and 626 deletions

View File

@@ -25,3 +25,4 @@ jobs:
# this is a built-in strategy in release-please, see "Action Inputs" # this is a built-in strategy in release-please, see "Action Inputs"
# for more options # for more options
release-type: simple release-type: simple
target-branch: ${{ github.ref_name }}

View File

@@ -1,5 +1,23 @@
# Changelog # Changelog
## [0.3.0](https://github.com/sbrow/envr/compare/v0.2.1...v0.3.0) (2026-06-16)
### ⚠ BREAKING CHANGES
* The encryption format of databases has changed. Age encryption is no longer supported, and no automatic migration path was implemented.
### Features
* All encryption/decryption now happens in-memory. ([fe2b256](https://github.com/sbrow/envr/commit/fe2b256bd61eaf551d53faf3893b473a64a94667))
* Config can be loaded from any path with `--config-file (-c)` flag. ([4a26ee8](https://github.com/sbrow/envr/commit/4a26ee814591e6aab0eb99d2359d51b31011edfe))
* Switched from age to libsodium. ([23b8c2d](https://github.com/sbrow/envr/commit/23b8c2dc671a23cf76cf6746b33806ded9381486))
### Performance Improvements
* Improved writer performance. ([365e914](https://github.com/sbrow/envr/commit/365e9149b1a738ac9119bb5f74dc7e047ecfed5b))
## [0.2.1](https://github.com/sbrow/envr/compare/v0.2.0...v0.2.1) (2026-01-12) ## [0.2.1](https://github.com/sbrow/envr/compare/v0.2.0...v0.2.1) (2026-01-12)

70
TEST_PLAN.md Normal file
View File

@@ -0,0 +1,70 @@
# Test Coverage Plan
## Current State
- 104 tests, all passing
- Strong coverage: crypto, ssh, db CRUD + env_file + update_dir, config save/load + paths, scan, features, cant_scan, parse_args, `-c`/`--config-file` flag
- Misleading test files: `cmd_check_test`, `cmd_list_test`, `cmd_nushell_completion_test` don't test their namesake procs
- Biggest remaining gap: all `cmd_*` handlers untested
## Command handler tests
Stdout will be captured by redirecting `os.stdout` to a pipe.
### `cmd_version` (cmd_version.odin)
- Test default output (prints VERSION)
### `cmd_list` (cmd_list.odin)
- Test TTY path: fixture DB with rows, capture table output
- Test non-TTY path: capture JSON output, unmarshal and verify keys/values
- Test empty DB: verify clean output (empty table or `[]`)
### `cmd_backup` (cmd_backup.odin)
- Test successful backup: valid path, verify `db_insert` called
- Test missing file: verify error message
- Test duplicate backup: verify rejection or update behavior
### `cmd_remove` (cmd_remove.odin)
- Test successful removal: existing entry, verify `db_delete` called
- Test removal of non-existent entry: verify error or no-op
### `cmd_restore` (cmd_restore.odin)
- Test successful restore: entry exists in DB, verify file written to correct path
- Test restore of missing entry: verify error
- Test directory creation: restore to path with missing parent dirs
## Hard to test (interactive / external deps)
### `cmd_deps` (cmd_deps.odin)
- Needs `git` and/or `fd` in PATH
- Test TTY and non-TTY paths
- Skip if dependencies not available (with `#assert` like TODO 28 suggests)
### `cmd_scan` (cmd_scan.odin)
- Needs `fd` installed
- Test with fixture git repo containing `.env` files
- Test `find_unbacked` integration (already partially tested in `cmd_check_test.odin`)
- Non-TTY JSON output path
### `cmd_edit_config` (cmd_edit_config.odin)
- Needs refactoring: extract `$EDITOR` parsing into testable helper (TODO 12)
- Test multi-word editor values (`"code -w"`)
- Test missing `$EDITOR`
### `cmd_init` (cmd_init.odin)
- Interactive prompt makes this hard
- Needs refactoring: extract SSH key discovery and config generation into testable procs
- Test `--force` flag behavior
### `prompt.odin`
- Needs refactoring to be testable
- `render_options` could be tested if it accepted an `io.Writer`
- `read_key` could be tested with a pipe/redirect instead of raw stdin
- `multi_select` is end-to-end interactive, likely integration test only
## Notes
- DB integration tests should use in-memory SQLite (`:memory:`) where possible.
- Temp dir fixtures should follow the pattern in `scan_test.odin`.
- External dependency tests (`fd`, `git`) should use `#assert` to ensure the dependency is present rather than silently skipping (TODO 28).
- Tests that manipulate the `HOME` env var must use a mutex to prevent races with parallel test execution.

115
TODOS.md
View File

@@ -1,104 +1,83 @@
# TODO # TODOs
Note: These todos can wait until all the subcommands have been ported.
## HIGH 1. Consider giving db its own allocator
2. **db.odin:380-383, 405, 446**`sqlite.bind_text` return values overwritten but never checked. A failed bind means `sqlite.step` operates on unbound params. 2. **db.odin:324-327** — Map iteration (`remote_set`) is non-deterministic. Same file can produce different JSON on each backup, causing spurious DB diffs. Sort remotes before storing.
3. **config.odin:52-54**`os.user_home_dir` error silently ignored. If it fails, `home` is `""` and all paths become relative (`".envr"` instead of `"~/.envr"`). 3. **db.odin:135, 250** — String interpolation into SQL (`VACUUM INTO '%s'`, `ATTACH DATABASE '%s'`). Currently safe because input is controlled, but fragile.
## MEDIUM 4. **features.odin:30-41**`find_binary` uses `strings.join` instead of `filepath.join`, uses `os.stat` instead of checking executability, hardcodes `:` as PATH separator (wrong on Windows).
4. **db.odin:29-35**`make_temp_path` never calls `strings.builder_destroy`. Leaks builder buffer every call. 5. **cmd_restore.odin:20-30 & cmd_remove.odin:19-29** — Identical path-resolution block copy-pasted. `is_abs` guard is redundant since `filepath.abs` is a no-op on absolute paths. Extract a helper.
5. **db.odin:324-327** — Map iteration (`remote_set`) is non-deterministic. Same file can produce different JSON on each backup, causing spurious DB diffs. Sort remotes before storing. 6. **cmd_restore.odin:44**`os.mkdir_all` error silently discarded. Subsequent write failure will be confusing.
6. **db.odin:470-473**`string_to_cstring` allocates via `strings.clone_to_cstring` and never frees. Called dozens of times across db operations. 8. **config.odin:178**`search_paths` silently ignores `os.user_home_dir` error. If home is empty, `~` isn't expanded. Same class of bug as issue 3.
7. **db.odin:470, 462** — Both `string_to_cstring` and `cstring_to_string` ignore allocation errors. A nil cstring gets passed to SQLite (UB). 10. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data.
8. **db.odin:135, 250**String interpolation into SQL (`VACUUM INTO '%s'`, `ATTACH DATABASE '%s'`). Currently safe because input is controlled, but fragile. 11. **db.odin:352-353**`hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice.
9. **features.odin:30-41**`find_binary` uses `strings.join` instead of `filepath.join`, uses `os.stat` instead of checking executability, hardcodes `:` as PATH separator (wrong on Windows). 12. **cmd_sync.odin:80, cmd_list.odin:33, cmd_deps.odin:9**`make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass.
10. **cmd_restore.odin:20-30 & cmd_remove.odin:19-29** — Identical path-resolution block copy-pasted. `is_abs` guard is redundant since `filepath.abs` is a no-op on absolute paths. Extract a helper. 13. **cmd_list.odin** — Non-TTY branch builds `ListEntry` structs and marshals JSON separately. Now that `render_json_rows` (issue 1) accepts an `io.Writer` and uses `json.marshal`, unify both branches to use it. Note: will change JSON keys from `"directory"/"path"` to `"Directory"/"Path"`.
11. **cmd_restore.odin:44**`os.mkdir_all` error silently discarded. Subsequent write failure will be confusing. 14. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
12. **cmd_edit_config.odin:27**`$EDITOR` used as single binary name. Breaks for multi-word values like `"code -w"`. Needs `strings.fields()`. 15. Add a text filter to the multi_select.
33. **config.odin:178**`search_paths` silently ignores `os.user_home_dir` error. If home is empty, `~` isn't expanded. Same class of bug as issue 3. 16. Create backup / fallback fd.
35. **prompt.odin:124**`make([dynamic]bool, len(options))` creates N zero-initialized elements. Works because `false` is the default, but same footgun as original issue 1. Should be `make([dynamic]bool, 0, len(options))`. 17. Add tests for untested commands.
39. Lots of memory leaks to fix. 18. 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.
## LOW 19. Try to do all encryption / decryption in memory - only read / write encrypted data to disk.
15. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data. 20. add --format -f flag to commands that draw tables.
16. **db.odin:352-353**`hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice. 21. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate.
18. **config.odin:51-60**`envr_dir` recomputes home dir on every call. Could cache. 22. Change struct field names from PascalCase to snake_case.
37. **cmd_sync.odin:80, cmd_list.odin:33, cmd_deps.odin:9**`make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass. 23. procedures should be ordered by use, main at the top, then in the order they are called from main.
## REFACTOR
20. **cmd_list.odin** — Non-TTY branch builds `ListEntry` structs and marshals JSON separately. Now that `render_json_rows` (issue 1) accepts an `io.Writer` and uses `json.marshal`, unify both branches to use it. Note: will change JSON keys from `"directory"/"path"` to `"Directory"/"Path"`.
21. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
23. Add a text filter to the multi_select.
24. Create backup / fallback fd.
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.
38. Try to do all encryption / decryption in memory - only read / write encrypted data to disk.
## Double-check AI output ## Double-check AI output
- [ ] cli.odin - [ ] cli.odin
- [ ] config.odin
- [ ] crypto.odin
- [ ] db.odin
- [ ] features.odin
- [ ] main.odin
- [ ] prompt.odin
- [ ] scan.odin
- [ ] sodium.odin
- [ ] ssh.odin
- [ ] table.odin
- [ ] cmd_backup.odin
- [ ] cmd_check.odin
- [ ] cmd_deps.odin
- [ ] cmd_edit_config.odin
- [ ] cmd_init.odin
- [ ] cmd_list.odin
- [ ] cmd_nushell_completion.odin
- [ ] cmd_remove.odin
- [ ] cmd_restore.odin
- [ ] cmd_scan.odin
- [ ] cmd_sync.odin
- [ ] cmd_version.odin
- [ ] sqlite/sqlite.odin
- [ ] cli_test.odin - [ ] cli_test.odin
- [x] cmd_backup.odin
- [x] cmd_check.odin
- [ ] cmd_check_test.odin - [ ] cmd_check_test.odin
- [x] cmd_deps.odin
- [ ] cmd_edit_config.odin
- [x] cmd_init.odin
- [x] cmd_list.odin
- [ ] cmd_list_test.odin - [ ] cmd_list_test.odin
- [ ] cmd_nushell_completion_test.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 - [ ] config_test.odin
- [ ] crypto.odin
- [ ] crypto_test.odin - [ ] crypto_test.odin
- [ ] db.odin
- [ ] db_integration_test.odin - [ ] db_integration_test.odin
- [ ] db_test.odin - [ ] db_test.odin
- [ ] features_test.odin - [x] features.odin
- [x] features_test.odin
- [x] main.odin
- [x] prompt.odin
- [ ] scan.odin
- [ ] scan_test.odin - [ ] scan_test.odin
- [ ] sodium.odin
- [ ] sqlite/sqlite.odin
- [ ] ssh.odin
- [ ] ssh_test.odin - [ ] ssh_test.odin
- [ ] table.odin
- [ ] table_test.odin - [ ] table_test.odin

View File

@@ -3,7 +3,6 @@ package main
import "core:bufio" import "core:bufio"
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:mem"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
@@ -12,6 +11,10 @@ Command :: struct {
args: [dynamic]string, args: [dynamic]string,
flags: map[string]string, flags: map[string]string,
bool_set: map[string]bool, bool_set: map[string]bool,
config_path: string,
out_buf: ^bufio.Writer,
out: io.Writer,
err: io.Writer,
} }
CommandInfo :: struct { CommandInfo :: struct {
@@ -27,7 +30,10 @@ COMMANDS := []CommandInfo {
"init", "init",
"envr init", "envr init",
"Set up envr", "Set up envr",
"The init command generates your initial config and saves it to\n~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to\nencrypt your databse. **Make 100% sure** that you have **a remote copy** of this\nkey somewhere, otherwise your data could be lost forever.", `The init command generates your initial config and saves it to
~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to
encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
key somewhere, otherwise your data could be lost forever.`,
{}, {},
}, },
{"scan", "envr scan", "Find and select .env files for backup", "", {}}, {"scan", "envr scan", "Find and select .env files for backup", "", {}},
@@ -46,23 +52,40 @@ COMMANDS := []CommandInfo {
}, },
{"version", "envr version", "Show envr's version", "", {}}, {"version", "envr version", "Show envr's version", "", {}},
{"edit-config", "envr edit-config", "Edit your config with your default editor", "", {}}, {"edit-config", "envr edit-config", "Edit your config with your default editor", "", {}},
{"nushell-completion", "envr nushell-completion", "Generate custom completions for nushell", "", {}}, {
"nushell-completion",
"envr nushell-completion",
"Generate custom completions for nushell",
"",
{},
},
} }
parse_args :: proc() -> (cmd: Command, ok: bool) { delete_command :: proc(cmd: ^Command) {
args := os.args delete(cmd.args)
if len(args) < 2 { delete(cmd.flags)
print_usage() delete(cmd.bool_set)
return Command{}, false bufio.writer_destroy(cmd.out_buf)
free(cmd.out_buf)
}
// Caller is responsible for calling delete_command(cmd).
// FIXME: Works in kinda a wonky and awkward way.
parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Command, ok: bool) {
{
cmd.out_buf = new(bufio.Writer)
bufio.writer_init(cmd.out_buf, out)
cmd.out = bufio.writer_to_writer(cmd.out_buf)
cmd.err = err
}
if len(args) < 2 || args[1] == "--help" || args[1] == "-h" {
write_usage(cmd.out)
return cmd, false
} }
cmd.name = args[1] cmd.name = args[1]
if cmd.name == "--help" || cmd.name == "-h" {
print_usage()
return Command{}, false
}
cmd.args = make([dynamic]string) cmd.args = make([dynamic]string)
cmd.flags = make(map[string]string) cmd.flags = make(map[string]string)
cmd.bool_set = make(map[string]bool) cmd.bool_set = make(map[string]bool)
@@ -94,9 +117,21 @@ parse_args :: proc() -> (cmd: Command, ok: bool) {
} }
} }
if val, ok := cmd.flags["config-file"]; ok {
cmd.config_path = val
} else if val, ok := cmd.flags["c"]; ok {
cmd.config_path = val
} else {
// FIXME: Handle err
// 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") { if has_flag(&cmd, "help") {
print_command_help(cmd.name) print_command_help(&cmd)
return Command{}, false return cmd, false
} }
return cmd, true return cmd, true
@@ -146,24 +181,24 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
fmt.wprintf(w, "\n%s\n", info.long, flush = false) fmt.wprintf(w, "\n%s\n", info.long, flush = false)
} }
fmt.wprintf(w, "\nFlags:\n -h, --help help for %s\n", info.name, flush = false) fmt.wprintf(
w,
"\nFlags:\n -h, --help help for %s\n -c, --config-file <path> config file (default \"~/.envr/config.json\")\n",
info.name,
flush = false,
)
return true return true
} }
print_command_help :: proc(name: string) { print_command_help :: proc(cmd: ^Command) {
bw: bufio.Writer ok := write_command_help(cmd.name, cmd.out)
bufio.writer_init(&bw, io.to_writer(os.to_writer(os.stdout)), mem.DEFAULT_PAGE_SIZE)
defer bufio.writer_destroy(&bw)
w := bufio.writer_to_writer(&bw)
ok := write_command_help(name, w)
if !ok { if !ok {
fmt.printf("Unknown command: %s\n", name) fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
print_usage() write_usage(cmd.out)
} }
bufio.writer_flush(&bw)
} }
// TODO: command args should be shown in usage.
write_usage :: proc(w: io.Writer) { write_usage :: proc(w: io.Writer) {
fmt.wprintf( fmt.wprintf(
w, w,
@@ -227,6 +262,7 @@ Available Commands:
` `
Flags: Flags:
-h, --help help for envr -h, --help help for envr
-c, --config-file <path> config file (default "~/.envr/config.json")
Use "envr [command] --help" for more information about a command. Use "envr [command] --help" for more information about a command.
`, `,
@@ -234,13 +270,3 @@ Use "envr [command] --help" for more information about a command.
) )
} }
// TODO: Look at usages,might want to pass a writer
print_usage :: proc() {
bw: bufio.Writer
bufio.writer_init(&bw, io.to_writer(os.to_writer(os.stdout)), mem.DEFAULT_PAGE_SIZE)
defer bufio.writer_destroy(&bw)
defer bufio.writer_flush(&bw)
write_usage(bufio.writer_to_writer(&bw))
}

View File

@@ -2,6 +2,7 @@
package main package main
import "core:bufio" import "core:bufio"
import "core:fmt"
import "core:strings" import "core:strings"
import "core:testing" import "core:testing"
@@ -189,3 +190,181 @@ test_has_flag_empty_command :: proc(t: ^testing.T) {
} }
test_parse_args :: proc( test_parse_args :: proc(
args: []string,
) -> (
cmd: Command,
ok: bool,
out_text: string,
err_text: string,
) {
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(args, strings.to_stream(&out_b), strings.to_stream(&err_b))
if ok {
bufio.writer_flush(cmd.out_buf)
out_text = strings.to_string(out_b)
err_text = strings.to_string(err_b)
}
return
}
@(test)
test_parse_args_bare_command :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
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)
test_parse_args_positional :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "/project/.env"})
defer delete_command(&cmd)
testing.expect(t, ok, "should succeed")
testing.expect(t, cmd.name == "backup")
testing.expect(t, len(cmd.args) == 1)
testing.expect(t, cmd.args[0] == "/project/.env")
}
@(test)
test_parse_args_long_flag_with_value :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "--config", "x.json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd.flags["config"] == "x.json")
}
@(test)
test_parse_args_short_flag_with_value :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "-c", "x.json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd.flags["c"] == "x.json")
}
@(test)
test_parse_args_long_bool_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "--force"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd.bool_set["force"] == true)
}
@(test)
test_parse_args_short_bool_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "version", "-l"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd.bool_set["l"] == true)
}
@(test)
test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a", "b"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, len(cmd.args) == 2)
testing.expect(t, cmd.args[0] == "a")
testing.expect(t, cmd.args[1] == "b")
}
@(test)
test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "/project/.env", "--force"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd.bool_set["force"] == true)
testing.expect(t, len(cmd.args) == 1)
testing.expect(t, cmd.args[0] == "/project/.env")
}
@(test)
test_parse_args_no_args :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr"})
defer delete_command(&cmd)
testing.expect(t, !ok, "no args should return false")
}
@(test)
test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a.env", "--force", "--verbose"})
defer delete_command(&cmd)
testing.expect(t, ok, "should succeed")
testing.expect(t, cmd.bool_set["force"] == true)
testing.expect(t, cmd.bool_set["verbose"] == true)
testing.expect(t, len(cmd.args) == 1)
testing.expect(t, cmd.args[0] == "a.env")
}
@(test)
test_parse_args_config_file_long_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args(
[]string{"envr", "list", "--config-file", "/custom/config.json"},
)
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(
t,
cmd.config_path == "/custom/config.json",
"config_path should be set from --config-file",
)
}
@(test)
test_parse_args_config_file_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-c", "/custom/config.json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(
t,
cmd.config_path == "/custom/config.json",
"config_path should be set from -c",
)
}
@(test)
test_parse_args_config_file_defaults :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, len(cmd.config_path) > 0, "config_path should default to non-empty path")
testing.expect(
t,
strings.contains(cmd.config_path, ".envr"),
"default config_path should contain .envr dir, got %s",
)
}

View File

@@ -5,13 +5,13 @@ import "core:strings"
cmd_backup :: proc(cmd: ^Command) { cmd_backup :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help("backup") print_command_help(cmd)
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.println("Error: No path provided") fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return return
} }
@@ -20,7 +20,7 @@ cmd_backup :: proc(cmd: ^Command) {
return return
} }
db, db_ok := db_open() db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -30,5 +30,6 @@ cmd_backup :: proc(cmd: ^Command) {
return return
} }
fmt.printf("Saved %s into the database\n", path) fmt.wprintf(cmd.out, "Saved %s into the database\n", path, flush = false)
} }

View File

@@ -11,9 +11,9 @@ cmd_check :: proc(cmd: ^Command) {
if len(cmd.args) > 0 { if len(cmd.args) > 0 {
check_path = cmd.args[0] check_path = cmd.args[0]
} else { } else {
cwd, cwd_err := os.get_working_directory(context.allocator) cwd, cwd_err := os.get_working_directory(context.temp_allocator)
if cwd_err != nil { if cwd_err != nil {
fmt.printf("Error getting current directory: %v\n", cwd_err) fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false)
return return
} }
check_path = cwd check_path = cwd
@@ -25,13 +25,13 @@ cmd_check :: proc(cmd: ^Command) {
} else { } else {
resolved, abs_err := filepath.abs(check_path) resolved, abs_err := filepath.abs(check_path)
if abs_err != nil { if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved abs_path = resolved
} }
db, db_ok := db_open() db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -43,15 +43,17 @@ cmd_check :: proc(cmd: ^Command) {
if is_dir { if is_dir {
if cant_scan(feats) { if cant_scan(feats) {
fmt.println( fmt.wprintln(
cmd.err,
"Error: please install fd to use the check command (https://github.com/sharkdp/fd)", "Error: please install fd to use the check command (https://github.com/sharkdp/fd)",
flush = false,
) )
return return
} }
scanned, scan_ok := scan_path(abs_path, db.cfg) scanned, scan_ok := scan_path(abs_path, db.cfg)
if !scan_ok { if !scan_ok {
fmt.println("Error scanning directory for .env files") fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false)
return return
} }
files_in_path = scanned files_in_path = scanned
@@ -68,16 +70,15 @@ cmd_check :: proc(cmd: ^Command) {
if len(not_backed) == 0 { if len(not_backed) == 0 {
if len(files_in_path) == 0 { if len(files_in_path) == 0 {
fmt.println("No .env files found in the specified directory.") fmt.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false)
} else { } else {
fmt.println("✓ All .env files in the directory are backed up.") fmt.wprintln(cmd.out, "✓ All .env files in the directory are backed up.", flush = false)
} }
} else { } else {
fmt.printf("Found %d .env file(s) that are not backed up:\n", len(not_backed)) fmt.wprintf(cmd.out, "Found %d .env file(s) that are not backed up:\n", len(not_backed), flush = false)
for file in not_backed { for file in not_backed {
fmt.printf(" %s\n", file) fmt.wprintf(cmd.out, " %s\n", file, flush = false)
} }
fmt.println("\nRun 'envr sync' to back up these files.") fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false)
} }
} }

View File

@@ -1,10 +1,10 @@
package main package main
import "core:fmt" import "core:fmt"
import "core:io"
import "core:os" import "core:os"
import "core:terminal" import "core:terminal"
// TODO: Improve table rendering
cmd_deps :: proc(cmd: ^Command) { cmd_deps :: proc(cmd: ^Command) {
feats := check_features() feats := check_features()
@@ -24,11 +24,10 @@ cmd_deps :: proc(cmd: ^Command) {
} }
if terminal.is_terminal(os.stdout) { if terminal.is_terminal(os.stdout) {
render_table(headers, rows[:]) render_table(cmd.out, headers, rows[:])
} else { } else {
w := io.to_writer(os.to_writer(os.stdout)) render_json_rows(cmd.out, headers, rows[:])
render_json_rows(w, headers, rows[:]) fmt.wprint(cmd.out, "\n", flush = false)
io.write_string(w, "\n")
} }
} }

View File

@@ -2,24 +2,24 @@ package main
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath"
cmd_edit_config :: proc(cmd: ^Command) { cmd_edit_config :: proc(cmd: ^Command) {
editor := os.get_env("EDITOR", context.allocator) editor := os.get_env("EDITOR", context.allocator)
if len(editor) == 0 { if len(editor) == 0 {
fmt.println("Error: $EDITOR environment variable is not set") fmt.wprintln(cmd.err, "Error: $EDITOR environment variable is not set", flush = false)
return return
} }
config_path, join_err := filepath.join([]string{envr_dir(), "config.json"}) config_path := cmd.config_path
if join_err != nil {
fmt.printf("Error building config path: %v\n", join_err)
return
}
_, stat_err := os.stat(config_path, context.allocator) _, stat_err := os.stat(config_path, context.allocator)
if stat_err != nil { if stat_err != nil {
fmt.printf("Config file does not exist at %s. Run 'envr init' first.\n", config_path) fmt.wprintf(
cmd.err,
"Config file does not exist at %s. Run 'envr init' first.\n",
config_path,
flush = false,
)
return return
} }
@@ -33,13 +33,13 @@ cmd_edit_config :: proc(cmd: ^Command) {
p, start_err := os.process_start(desc) p, start_err := os.process_start(desc)
if start_err != nil { if start_err != nil {
fmt.printf("Error running editor: %v\n", start_err) fmt.wprintf(cmd.err, "Error running editor: %v\n", start_err, flush = false)
return return
} }
state, wait_err := os.process_wait(p) state, wait_err := os.process_wait(p)
if wait_err != nil { if wait_err != nil {
fmt.printf("Error waiting for editor: %v\n", wait_err) fmt.wprintf(cmd.err, "Error waiting for editor: %v\n", wait_err, flush = false)
return return
} }
if state.exit_code != 0 { if state.exit_code != 0 {

View File

@@ -5,10 +5,16 @@ import "core:fmt"
cmd_init :: proc(cmd: ^Command) { cmd_init :: proc(cmd: ^Command) {
force := has_flag(cmd, "force") || has_flag(cmd, "f") force := has_flag(cmd, "force") || has_flag(cmd, "f")
_, cfg_exists := load_config() fmt.wprintln(cmd.out, cmd.config_path, flush = false)
_, cfg_exists := load_config(cmd.config_path)
if cfg_exists && !force { if cfg_exists && !force {
fmt.println("You have already initialized envr.") fmt.wprintln(
fmt.println("Run again with the --force flag if you want to reinitialize.") cmd.out,
`You have already initialized envr.
Run again with the --force flag if you want to reinitialize.`,
flush = false,
)
return return
} }
@@ -18,14 +24,15 @@ cmd_init :: proc(cmd: ^Command) {
} }
if len(keys) == 0 { if len(keys) == 0 {
fmt.println("No ssh-ed25519 keys found in ~/.ssh") fmt.wprintln(cmd.err, `No ssh-ed25519 keys found in ~/.ssh
fmt.println("Generate one with: ssh-keygen -t ed25519") Generate one with: ssh-keygen -t ed25519`, flush = false)
return return
} }
selected, result := multi_select("Select SSH private keys:", keys[:]) selected, result := multi_select("Select SSH private keys:", keys[:])
defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.println("\x1b[2mCancelled.\x1b[0m") fmt.wprintln(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false)
return return
} }
@@ -37,18 +44,19 @@ cmd_init :: proc(cmd: ^Command) {
} }
if len(selected_paths) == 0 { if len(selected_paths) == 0 {
fmt.println("No SSH keys selected - Config not created") fmt.wprintln(cmd.err, "No SSH keys selected - Config not created", flush = false)
return return
} }
cfg := new_config(selected_paths[:]) cfg := new_config(selected_paths[:], cmd.config_path)
if !save_config(cfg, force = force) { if !save_config(cfg, force = force) {
return return
} }
fmt.printf( fmt.wprintf(
cmd.out,
"Config initialized with %d SSH key(s). You are ready to use envr.\n", "Config initialized with %d SSH key(s). You are ready to use envr.\n",
len(selected_paths), len(selected_paths),
flush = false,
) )
} }

View File

@@ -12,8 +12,10 @@ ListEntry :: struct {
Path: string `json:"path"`, Path: string `json:"path"`,
} }
// TODO: Support --format flag
// TODO: Improve table rendering
cmd_list :: proc(cmd: ^Command) { cmd_list :: proc(cmd: ^Command) {
db, db_ok := db_open() db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -38,8 +40,9 @@ cmd_list :: proc(cmd: ^Command) {
append(&table_rows, row_slice) append(&table_rows, row_slice)
} }
render_table(headers, table_rows[:]) render_table(cmd.out, headers, table_rows[:])
} else { } else {
// TODO: Should we instead print full entries here?
entries: [dynamic]ListEntry entries: [dynamic]ListEntry
for row in rows { for row in rows {
filename := filepath.base(row.Path) filename := filepath.base(row.Path)
@@ -52,12 +55,12 @@ 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 { if marshal_err != nil {
fmt.printf("Error marshaling JSON: %v\n", marshal_err) fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return return
} }
fmt.println(string(data)) fmt.wprintln(cmd.out, string(data), flush = false)
} }
} }

View File

@@ -5,5 +5,6 @@ import "core:fmt"
COMPLETION_SCRIPT: string : string(#load("mod.nu")) COMPLETION_SCRIPT: string : string(#load("mod.nu"))
cmd_nushell_completion :: proc(cmd: ^Command) { cmd_nushell_completion :: proc(cmd: ^Command) {
fmt.print(COMPLETION_SCRIPT) fmt.wprint(cmd.out, COMPLETION_SCRIPT, flush = false)
} }

View File

@@ -6,29 +6,30 @@ import "core:strings"
cmd_remove :: proc(cmd: ^Command) { cmd_remove :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help("remove") print_command_help(cmd)
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.println("Error: No path provided") fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return return
} }
// TODO: Is this the best way to do it?
abs_path: string abs_path: string
if filepath.is_abs(path) { if filepath.is_abs(path) {
abs_path = path abs_path = path
} else { } else {
resolved, abs_err := filepath.abs(path) resolved, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved abs_path = resolved
} }
db, db_ok := db_open() db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -38,5 +39,6 @@ cmd_remove :: proc(cmd: ^Command) {
return return
} }
fmt.printf("Removed %s from the database\n", abs_path) fmt.wprintf(cmd.out, "Removed %s from the database\n", abs_path, flush = false)
} }

View File

@@ -7,29 +7,30 @@ import "core:strings"
cmd_restore :: proc(cmd: ^Command) { cmd_restore :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help("restore") print_command_help(cmd)
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.println("Error: No path provided") fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return return
} }
// TODO: Is this the right way to handle this?
abs_path: string abs_path: string
if filepath.is_abs(path) { if filepath.is_abs(path) {
abs_path = path abs_path = path
} else { } else {
resolved, abs_err := filepath.abs(path) resolved, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved abs_path = resolved
} }
db, db_ok := db_open() db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -45,9 +46,10 @@ cmd_restore :: proc(cmd: ^Command) {
write_err := os.write_entire_file(file.Path, file.contents) write_err := os.write_entire_file(file.Path, file.contents)
if write_err != nil { if write_err != nil {
fmt.printf("Error writing file: %v\n", write_err) fmt.wprintf(cmd.err, "Error writing file: %v\n", write_err, flush = false)
return return
} }
fmt.printf("Restored %s\n", file.Path) fmt.wprintf(cmd.out, "Restored %s\n", file.Path, flush = false)
} }

View File

@@ -8,13 +8,15 @@ import "core:terminal"
cmd_scan :: proc(cmd: ^Command) { cmd_scan :: proc(cmd: ^Command) {
feats := check_features() feats := check_features()
if cant_scan(feats) { if cant_scan(feats) {
fmt.println( fmt.wprintln(
cmd.err,
"Error: please install fd to use the scan command (https://github.com/sharkdp/fd)", "Error: please install fd to use the scan command (https://github.com/sharkdp/fd)",
flush = false,
) )
return return
} }
db, db_ok := db_open() db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -22,7 +24,11 @@ cmd_scan :: proc(cmd: ^Command) {
search_dirs := search_paths(db.cfg) search_dirs := search_paths(db.cfg)
if len(search_dirs) == 0 { if len(search_dirs) == 0 {
fmt.println("No search paths configured. Please run `envr init` or edit your config.") fmt.wprintln(
cmd.err,
"No search paths configured. Please run `envr init -f` or edit your config.",
flush = false,
)
return return
} }
@@ -31,7 +37,7 @@ cmd_scan :: proc(cmd: ^Command) {
for dir in search_dirs { for dir in search_dirs {
found, scan_ok := scan_path(dir, db.cfg) found, scan_ok := scan_path(dir, db.cfg)
if !scan_ok { if !scan_ok {
fmt.printf("Error scanning %s\n", dir) fmt.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false)
continue continue
} }
for f in found { for f in found {
@@ -47,23 +53,29 @@ cmd_scan :: proc(cmd: ^Command) {
files := find_unbacked(all_files[:], db_files[:]) files := find_unbacked(all_files[:], db_files[:])
if len(files) == 0 { if len(files) == 0 {
fmt.println("No .env files found to add.") fmt.wprintln(cmd.out, "No .env files found to add.", flush = false)
return return
} }
if !terminal.is_terminal(os.stdout) { if !terminal.is_terminal(os.stdout) {
output, marshal_err := json.marshal(files[:]) output, marshal_err := json.marshal(files[:])
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling files to JSON: %v\n", marshal_err) fmt.wprintf(
cmd.err,
"Error marshaling files to JSON: %v\n",
marshal_err,
flush = false,
)
return return
} }
fmt.println(string(output)) fmt.wprintln(cmd.out, string(output), flush = false)
return return
} }
selected, result := multi_select("Select .env files to backup:", files[:]) selected, result := multi_select("Select .env files to backup:", files[:])
defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.println("\x1b[2mCancelled.\x1b[0m") fmt.wprintln(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false)
return return
} }
@@ -74,20 +86,25 @@ cmd_scan :: proc(cmd: ^Command) {
} }
env_file, ok := new_env_file(files[i]) env_file, ok := new_env_file(files[i])
if !ok { if !ok {
fmt.printf("Error reading %s\n", files[i]) fmt.wprintf(cmd.err, "Error reading %s\n", files[i], flush = false)
continue continue
} }
if !db_insert(&db, env_file) { if !db_insert(&db, env_file) {
fmt.printf("Error adding %s\n", files[i]) fmt.wprintf(cmd.err, "Error adding %s\n", files[i], flush = false)
continue continue
} }
added_count += 1 added_count += 1
} }
if added_count > 0 { if added_count > 0 {
fmt.printf("\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n", added_count) fmt.wprintf(
cmd.out,
"\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n",
added_count,
flush = false,
)
} else { } else {
fmt.println("\x1b[2mNo files were added.\x1b[0m") fmt.wprintln(cmd.out, "\x1b[2mNo files were added.\x1b[0m", flush = false)
} }
} }

View File

@@ -12,8 +12,9 @@ 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() db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -25,11 +26,13 @@ cmd_sync :: proc(cmd: ^Command) {
} }
defer delete(files) defer delete(files)
// TODO: Set sane default size
results: [dynamic]SyncEntry results: [dynamic]SyncEntry
defer delete(results)
for &file in files { for &file in files {
old_path: string 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) result, err_msg := db_sync(&db, &file)
@@ -80,14 +83,14 @@ cmd_sync :: proc(cmd: ^Command) {
append(&table_rows, row_slice) append(&table_rows, row_slice)
} }
render_table(headers, table_rows[:]) render_table(cmd.out, headers, table_rows[:])
} else { } else {
data, marshal_err := json.marshal(results[:]) data, marshal_err := json.marshal(results[:])
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling JSON: %v\n", marshal_err) fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return return
} }
fmt.println(string(data)) fmt.wprintln(cmd.out, string(data), flush = false)
} }
} }

View File

@@ -5,10 +5,6 @@ import "core:fmt"
VERSION :: #load("version.txt", string) VERSION :: #load("version.txt", string)
cmd_version :: proc(cmd: ^Command) { cmd_version :: proc(cmd: ^Command) {
if has_flag(cmd, "long") || has_flag(cmd, "l") { fmt.wprintln(cmd.out, VERSION, flush = false)
fmt.printf("envr version %s\n", VERSION)
} else {
fmt.println(VERSION)
}
} }

View File

@@ -20,50 +20,63 @@ ScanConfig :: struct {
Config :: struct { Config :: struct {
Keys: [dynamic]SshKeyPair `json:"keys"`, Keys: [dynamic]SshKeyPair `json:"keys"`,
ScanConfig: ScanConfig `json:"scan"`, ScanConfig: ScanConfig `json:"scan"`,
config_path: string `json:"-"`,
} }
load_config :: proc() -> (Config, bool) { default_config_path :: proc(home: string, allocator := context.allocator) -> string {
home, home_err := os.user_home_dir(context.temp_allocator) path, err := filepath.join([]string{home, ".envr", "config.json"}, allocator)
if home_err != nil { if err != nil {
fmt.printf("Error getting home dir: %v\n", home_err) panic("Ran out of memory when building config path")
return Config{}, false
} }
config_path, join_err := filepath.join([]string{home, ".envr", "config.json"}) return path
if join_err != nil {
return Config{}, false
} }
load_config :: proc(config_path: string) -> (Config, bool) {
data, read_err := os.read_entire_file_from_path(config_path, context.allocator) data, read_err := os.read_entire_file_from_path(config_path, context.allocator)
if read_err != nil { if read_err != nil {
fmt.println("No config file found. Please run `envr init` to generate one.") fmt.println("No config file found. Please run `envr init` to generate one.")
return Config{}, false return Config{}, false
} }
defer delete(data)
cfg: Config cfg: Config
// TODO: use json 5
err := json.unmarshal(data, &cfg) err := json.unmarshal(data, &cfg)
if err != nil { if err != nil {
fmt.printf("Error parsing config: %v\n", err) fmt.printf("Error parsing config: %v\n", err)
return Config{}, false return Config{}, false
} }
cfg.config_path = config_path
return cfg, true 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.Keys)
delete(cfg.ScanConfig.Matcher)
for exclude in cfg.ScanConfig.Exclude {
delete(exclude)
}
delete(cfg.ScanConfig.Exclude) delete(cfg.ScanConfig.Exclude)
for include in cfg.ScanConfig.Include {
delete(include)
}
delete(cfg.ScanConfig.Include) delete(cfg.ScanConfig.Include)
} }
envr_dir :: proc() -> string { envr_dir :: proc(config_path: string) -> string {
home, _ := os.user_home_dir(context.allocator) return filepath.dir(config_path)
dir, _ := filepath.join([]string{home, ".envr"})
return dir
} }
data_encrypted_path :: proc() -> string { data_path :: proc(config_path: string) -> string {
dir := envr_dir() path, _ := filepath.join([]string{envr_dir(config_path), "data.envr"})
path, _ := filepath.join([]string{dir, "data.envr"})
return path return path
} }
@@ -113,53 +126,50 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
return return
} }
new_config :: proc(private_key_paths: []string) -> Config { // Caller is responsible for calling delete_config()
new_config :: proc(
private_key_paths: []string,
cfg_path: string = "~/.envr/config.json",
) -> Config {
keys := make([dynamic]SshKeyPair, 0, len(private_key_paths)) keys := make([dynamic]SshKeyPair, 0, len(private_key_paths))
for priv in private_key_paths { for priv in private_key_paths {
// TODO: Is this bad? // TODO: Is this bad?
pub, _ := strings.concatenate([]string{priv, ".pub"}, context.temp_allocator) priv_key := strings.clone(priv)
append(&keys, SshKeyPair{Private = priv, Public = pub}) pub, _ := strings.concatenate([]string{priv_key, ".pub"})
append(&keys, SshKeyPair{Private = priv_key, Public = pub})
} }
exclude := make([dynamic]string, 0, 4) exclude := make([dynamic]string, 0, 4)
append(&exclude, "*\\.envrc") append(&exclude, strings.clone("*\\.envrc"))
append(&exclude, "\\.local/") append(&exclude, strings.clone("\\.local/"))
append(&exclude, "node_modules") append(&exclude, strings.clone("node_modules"))
append(&exclude, "vendor") append(&exclude, strings.clone("vendor"))
include := make([dynamic]string, 0, 1) include := make([dynamic]string, 0, 1)
append(&include, "~") append(&include, strings.clone("~"))
scan_cfg := ScanConfig { scan_cfg := ScanConfig {
Matcher = "\\.env", Matcher = strings.clone("\\.env"),
Exclude = exclude, Exclude = exclude,
Include = include, Include = include,
} }
return Config{Keys = keys, ScanConfig = scan_cfg} return Config{Keys = keys, ScanConfig = scan_cfg, config_path = cfg_path}
} }
save_config :: proc(cfg: Config, force: bool = false) -> bool { save_config :: proc(cfg: Config, force: bool = false) -> bool {
home, home_err := os.user_home_dir(context.allocator) config_dir := envr_dir(cfg.config_path)
if home_err != nil {
fmt.printf("Error getting home dir: %v\n", home_err)
return false
}
config_dir, _ := filepath.join([]string{home, ".envr"})
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 ~/.envr directory: %v\n", mkdir_err) fmt.printf("Error creating %s directory: %v\n", config_dir, mkdir_err)
return false return false
} }
} }
config_path, _ := filepath.join([]string{config_dir, "config.json"}) if os.exists(cfg.config_path) && !force {
info, stat_err := os.stat(cfg.config_path, context.allocator)
if os.exists(config_path) && !force {
info, stat_err := os.stat(config_path, context.allocator)
if stat_err == nil { if stat_err == nil {
defer os.file_info_delete(info, context.allocator) defer os.file_info_delete(info, context.allocator)
if info.size > 0 { if info.size > 0 {
@@ -174,8 +184,9 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
fmt.printf("Error marshaling config: %v\n", marshal_err) fmt.printf("Error marshaling config: %v\n", marshal_err)
return false return false
} }
defer delete(data)
write_err := os.write_entire_file(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.printf("Error writing config: %v\n", write_err)
return false return false
@@ -185,15 +196,18 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
} }
search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) { 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 { for include in cfg.ScanConfig.Include {
// TODO: Do we need to manually expand ~/ in odin?
expanded, _ := strings.replace(include, "~", home, 1) expanded, _ := strings.replace(include, "~", home, 1)
cloned, _ := strings.clone(expanded) if filepath.is_abs(expanded) {
if filepath.is_abs(cloned) { append(&paths, expanded)
append(&paths, cloned)
} else { } else {
resolved, err := filepath.abs(cloned) defer delete(expanded)
resolved, err := filepath.abs(expanded)
if err == nil { if err == nil {
append(&paths, resolved) append(&paths, resolved)
} }

View File

@@ -1,12 +1,19 @@
package main package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:sync"
import "core:testing" import "core:testing"
home_mutex: sync.Mutex
@(test) @(test)
test_new_config_single_key :: proc(t: ^testing.T) { test_new_config_single_key :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"} paths := []string{"/home/user/.ssh/id_ed25519"}
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(t, len(cfg.Keys) == 1, "should have 1 key")
testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519", "Private path mismatch") testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519", "Private path mismatch")
@@ -21,7 +28,7 @@ test_new_config_single_key :: proc(t: ^testing.T) {
test_new_config_multiple_keys :: 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"} paths := []string{"/home/user/.ssh/id_ed25519", "/home/user/.ssh/id_rsa"}
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(t, len(cfg.Keys) == 2, "should have 2 keys")
testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519") testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519")
@@ -32,7 +39,7 @@ test_new_config_multiple_keys :: proc(t: ^testing.T) {
test_new_config_empty_keys :: proc(t: ^testing.T) { test_new_config_empty_keys :: proc(t: ^testing.T) {
paths: []string paths: []string
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(t, len(cfg.Keys) == 0, "should have 0 keys")
} }
@@ -41,7 +48,7 @@ test_new_config_empty_keys :: proc(t: ^testing.T) {
test_new_config_scan_defaults :: proc(t: ^testing.T) { test_new_config_scan_defaults :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"} paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths) 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, cfg.ScanConfig.Matcher == "\\.env", "matcher should be \\.env")
testing.expect(t, len(cfg.ScanConfig.Exclude) == 4, "should have 4 exclude patterns") testing.expect(t, len(cfg.ScanConfig.Exclude) == 4, "should have 4 exclude patterns")
@@ -53,7 +60,7 @@ test_new_config_scan_defaults :: proc(t: ^testing.T) {
test_new_config_exclude_patterns :: proc(t: ^testing.T) { test_new_config_exclude_patterns :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"} paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(cfg) defer delete_config(&cfg)
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) {
@@ -61,3 +68,143 @@ test_new_config_exclude_patterns :: proc(t: ^testing.T) {
} }
} }
@(test)
test_save_load_config_roundtrip :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-cfg-rt-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath)
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)
testing.expect(t, len(loaded.Keys) == 1, "should have 1 key")
testing.expect(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(t, loaded.ScanConfig.Matcher == "\\.env")
testing.expect(t, len(loaded.ScanConfig.Exclude) == 4)
testing.expect(t, len(loaded.ScanConfig.Include) == 1)
testing.expect(t, loaded.ScanConfig.Include[0] == "~")
}
@(test)
test_load_config_missing :: proc(t: ^testing.T) {
_, ok := load_config("/tmp/envr-test-cfg-nonexistent/config.json")
testing.expect(t, !ok, "missing config should return false")
}
@(test)
test_save_config_no_clobber :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-cfg-noclobber-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
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)
testing.expect(t, !save_config(cfg2), "second save without force should fail")
}
@(test)
test_save_config_force_overwrites :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-cfg-force-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
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)
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)
testing.expect(t, len(loaded.Keys) == 1, "should have 1 key")
testing.expect(
t,
loaded.Keys[0].Private == "/home/user/.ssh/key2",
"should be the overwritten key",
)
}
@(test)
test_envr_dir :: proc(t: ^testing.T) {
dir := envr_dir("/tmp/envr-fake-home-envrdir/.envr/config.json")
testing.expectf(t, strings.has_suffix(dir, ".envr"), "dir should end with .envr, got %s", dir)
testing.expectf(
t,
strings.contains(dir, "envr-fake-home-envrdir"),
"dir should contain home dir, got %s",
dir,
)
}
@(test)
test_data_path :: proc(t: ^testing.T) {
p := data_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)
}
@(test)
test_search_paths_expands_tilde :: proc(t: ^testing.T) {
sync.mutex_lock(&home_mutex)
defer sync.mutex_unlock(&home_mutex)
old_home := os.get_env("HOME", context.temp_allocator)
defer {
if old_home != "" {
os.set_env("HOME", old_home)
}
}
os.set_env("HOME", "/tmp/envr-fake-home-search")
cfg := Config {
ScanConfig = ScanConfig{Include = make([dynamic]string, 0, 1)},
}
defer delete(cfg.ScanConfig.Include)
append(&cfg.ScanConfig.Include, "~")
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 {
testing.expectf(
t,
strings.contains(paths[0], "envr-fake-home-search"),
"should expand ~ to home, got %s",
paths[0],
)
testing.expect(t, !strings.contains(paths[0], "~"), "should not contain literal ~")
}
}

View File

@@ -3,7 +3,7 @@ package main
import "core:fmt" import "core:fmt"
import "core:testing" import "core:testing"
CRYPTO_TEST_KEY_DIR :: "/tmp/envr-test-keys" CRYPTO_TEST_KEY_DIR :: "fixtures/keys"
make_test_key_pair :: proc(name: string) -> SshKeyPair { make_test_key_pair :: proc(name: string) -> SshKeyPair {
priv := fmt.tprintf("%s/%s", CRYPTO_TEST_KEY_DIR, name) priv := fmt.tprintf("%s/%s", CRYPTO_TEST_KEY_DIR, name)
@@ -25,7 +25,11 @@ 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(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) { for i in 0 ..< len(original) {
testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at index %d", i)) 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) defer delete(decrypted2)
for i in 0 ..< len(original) { for i in 0 ..< len(original) {
testing.expect(t, decrypted1[i] == original[i], fmt.tprintf("key1: byte mismatch at %d", i)) testing.expect(
testing.expect(t, decrypted2[i] == original[i], fmt.tprintf("key2: byte mismatch at %d", i)) 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") 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)
test_ciphertext_has_magic :: proc(t: ^testing.T) { test_ciphertext_has_magic :: proc(t: ^testing.T) {
key := make_test_key_pair("test_ed25519") 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[2] == u8('V'), "magic byte 2")
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3") testing.expect(t, encrypted[3] == u8('R'), "magic byte 3")
} }

331
db.odin
View File

@@ -27,6 +27,7 @@ SyncDirection :: enum {
} }
Db :: struct { Db :: struct {
// Pointer to the sqlite db
db: ^rawptr, db: ^rawptr,
cfg: Config, cfg: Config,
changed: bool, changed: bool,
@@ -40,21 +41,32 @@ EnvFile :: struct {
contents: string, 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)
}
make_temp_path :: proc() -> string { make_temp_path :: proc() -> string {
ts := time.time_to_unix(time.now()) ts := time.time_to_unix(time.now())
b: strings.Builder b: strings.Builder
strings.builder_init(&b) strings.builder_init(&b)
defer strings.builder_destroy(&b)
fmt.sbprintf(&b, "/tmp/envr-%d-%d.db", os.get_pid(), ts) fmt.sbprintf(&b, "/tmp/envr-%d-%d.db", os.get_pid(), ts)
return strings.to_string(b) return strings.to_string(b)
} }
db_open :: proc() -> (Db, bool) { db_open :: proc(cfg_path: string) -> (Db, bool) {
cfg, ok := load_config() cfg, ok := load_config(cfg_path)
if !ok { if !ok {
return Db{}, false return Db{}, false
} }
data_path := data_encrypted_path() data_path := data_path(cfg.config_path)
_, stat_err := os.stat(data_path, context.allocator) _, stat_err := os.stat(data_path, context.allocator)
db: ^rawptr db: ^rawptr
@@ -64,8 +76,8 @@ db_open :: proc() -> (Db, bool) {
return Db{}, false 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)" 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, string_to_cstring(create_sql), nil, nil, nil) rc = sqlite.db_exec(db, create_sql, nil, nil, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db)) fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db))
sqlite.db_close(db) sqlite.db_close(db)
@@ -83,56 +95,66 @@ db_open :: proc() -> (Db, bool) {
} }
db_close :: proc(d: ^Db) { db_close :: proc(d: ^Db) {
defer sqlite.db_close(d.db)
if d.changed { if d.changed {
tmp_path := make_temp_path() rc := sqlite.db_exec(d.db, "VACUUM", nil, nil, nil)
if rc != sqlite.OK {
if !db_vacuum_to_file(d.db, tmp_path) { fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(d.db))
os.remove(tmp_path)
sqlite.db_close(d.db)
return return
} }
sqlite_data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator) sz: i64
os.remove(tmp_path) data := sqlite.serialize(d.db, "main", &sz, 0)
if read_err != nil { if data == nil {
fmt.printf("Error reading vacuumed database: %v\n", read_err) fmt.println("Error: failed to serialize database")
sqlite.db_close(d.db)
return return
} }
defer sqlite.free(data)
sqlite_data := data[:sz]
encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:]) encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:])
delete(sqlite_data)
if !enc_ok { if !enc_ok {
fmt.println("Error: encryption failed") fmt.println("Error: encryption failed")
sqlite.db_close(d.db)
return return
} }
data_path := data_encrypted_path() data_path := data_path(d.cfg.config_path)
envr_d := envr_dir() envr_d := envr_dir(d.cfg.config_path)
os.mkdir_all(envr_d) os.mkdir_all(envr_d)
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.printf("Error writing encrypted database: %v\n", write_err)
sqlite.db_close(d.db)
return return
} }
d.changed = false d.changed = false
} }
sqlite.db_close(d.db)
} }
db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) { // Caller is responsible for calling:
sql := "SELECT path, remotes, sha256, contents FROM envr_env_files" // ```odin
// delete(results)
// for &result in results {
// delete(&result)
// }
// ```
db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]EnvFile, ok: bool) {
stmt: ^rawptr 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 { if rc != sqlite.OK {
fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db))
return return
} }
defer sqlite.finalize(stmt)
for { for {
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
@@ -141,19 +163,15 @@ db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) {
} }
if rc != sqlite.ROW { if rc != sqlite.ROW {
fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db))
sqlite.finalize(stmt)
return return
} }
path := cstring_to_string(sqlite.column_text(stmt, 0)) remotes_json := string(sqlite.column_text(stmt, 1))
remotes_json := cstring_to_string(sqlite.column_text(stmt, 1)) remotes: [dynamic]string = ---
sha := cstring_to_string(sqlite.column_text(stmt, 2))
contents := cstring_to_string(sqlite.column_text(stmt, 3))
remotes: [dynamic]string
if len(remotes_json) > 0 { 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( append(
&results, &results,
@@ -161,33 +179,22 @@ db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) {
Path = path, Path = path,
Dir = filepath.dir(path), Dir = filepath.dir(path),
Remotes = remotes, Remotes = remotes,
Sha256 = sha, Sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator),
contents = contents, contents = clone_cstring(sqlite.column_text(stmt, 3), allocator),
}, },
) )
} }
sqlite.finalize(stmt)
ok = true ok = true
return return
} }
db_vacuum_to_file :: proc(db: ^rawptr, path: string) -> bool {
b: strings.Builder
strings.builder_init(&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)
if rc != sqlite.OK {
fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(db))
return false
}
return true
}
db_restore_from_encrypted :: proc(db: ^rawptr, cfg: Config) -> bool { db_restore_from_encrypted :: proc(db: ^rawptr, cfg: Config) -> bool {
data_path := data_encrypted_path() encrypted_data, read_err := os.read_entire_file_from_path(
encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator) data_path(cfg.config_path),
context.allocator,
)
defer delete(encrypted_data)
if read_err != nil { if read_err != nil {
fmt.printf("Error reading encrypted database: %v\n", read_err) fmt.printf("Error reading encrypted database: %v\n", read_err)
return false return false
@@ -200,56 +207,39 @@ db_restore_from_encrypted :: proc(db: ^rawptr, cfg: Config) -> bool {
} }
defer delete(plaintext) defer delete(plaintext)
tmp_path := make_temp_path() n := i64(len(plaintext))
write_err := os.write_entire_file(tmp_path, plaintext) buf := sqlite.malloc64(n)
if write_err != nil { if buf == nil {
fmt.printf("Error writing temp database: %v\n", write_err) fmt.println("Error: failed to allocate buffer for deserialization")
return false return false
} }
defer os.remove(tmp_path) copy(buf[:len(plaintext)], plaintext)
if !db_attach_and_copy(db, tmp_path) { rc := sqlite.deserialize(
return false db,
} "main",
buf,
return true n,
} n,
sqlite.DESERIALIZE_FREEONCLOSE | sqlite.DESERIALIZE_RESIZEABLE,
db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool {
b: strings.Builder
strings.builder_init(&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)
if rc != sqlite.OK {
fmt.printf("Error attaching database: %s\n", sqlite.db_errmsg(mem_db))
return false
}
rc = sqlite.db_exec(
mem_db,
"INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files",
nil,
nil,
nil,
) )
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error copying data: %s\n", sqlite.db_errmsg(mem_db)) sqlite.free(buf)
sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil) fmt.printf("Error deserializing database: %s\n", sqlite.db_errmsg(db))
return false return false
} }
sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil)
return true return true
} }
get_git_remotes :: proc(dir: string) -> [dynamic]string { get_git_remotes :: proc(dir: string) -> [dynamic]string {
remotes: [dynamic]string remotes: [dynamic]string
remote_set: map[string]bool remote_set: map[string]bool
b: strings.Builder b: strings.Builder
strings.builder_init(&b) strings.builder_init(&b)
defer strings.builder_destroy(&b)
fmt.sbprintf(&b, "%s-git-remotes", make_temp_path()) fmt.sbprintf(&b, "%s-git-remotes", make_temp_path())
tmp_path := strings.to_string(b) tmp_path := strings.to_string(b)
tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC) tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC)
@@ -279,13 +269,13 @@ get_git_remotes :: proc(dir: string) -> [dynamic]string {
} }
data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator) data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator)
defer delete(data)
os.remove(tmp_path) os.remove(tmp_path)
if read_err != nil { if read_err != nil {
return remotes return remotes
} }
output_str := string(data) lines := strings.split(string(data), "\n")
lines := strings.split(output_str, "\n")
for &line in lines { for &line in lines {
line = strings.trim_space(line) line = strings.trim_space(line)
@@ -312,27 +302,27 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
fmt.printf("Error getting absolute path: %v\n", abs_err) fmt.printf("Error getting absolute path: %v\n", abs_err)
return EnvFile{}, false return EnvFile{}, false
} }
cloned_path, _ := strings.clone(abs_path)
dir := filepath.dir(cloned_path) dir := filepath.dir(abs_path)
remotes := get_git_remotes(dir) 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 { 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 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) hex_bytes, _ := hex.encode(digest)
sha_str := string(hex_bytes)
return EnvFile { return EnvFile {
Path = cloned_path, Path = abs_path,
Dir = dir, Dir = dir,
Remotes = remotes, Remotes = remotes,
Sha256 = sha_str, Sha256 = string(hex_bytes),
contents = string(data), contents = string(data),
}, },
true true
@@ -344,20 +334,51 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
fmt.printf("Error marshaling remotes: %v\n", marshal_err) fmt.printf("Error marshaling remotes: %v\n", marshal_err)
return false 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 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 { if rc != sqlite.OK {
fmt.printf("Error preparing insert: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error preparing insert: %s\n", sqlite.db_errmsg(d.db))
return false return false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
rc = sqlite.bind_text(stmt, 1, string_to_cstring(file.Path), -1, nil) // TODO: deal with elsewhere?
rc = sqlite.bind_text(stmt, 2, string_to_cstring(string(remotes_json)), -1, nil) cpath := to_cstring(file.Path)
rc = sqlite.bind_text(stmt, 3, string_to_cstring(file.Sha256), -1, nil) defer delete(cpath)
rc = sqlite.bind_text(stmt, 4, string_to_cstring(file.contents), -1, nil) rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db))
return false
}
cremotes := to_cstring(string(remotes_json))
defer delete(cremotes)
rc = sqlite.bind_text(stmt, 2, cremotes, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding remotes: %s\n", sqlite.db_errmsg(d.db))
return false
}
csha := to_cstring(file.Sha256)
defer delete(csha)
rc = sqlite.bind_text(stmt, 3, csha, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding sha256: %s\n", sqlite.db_errmsg(d.db))
return false
}
ccontents := to_cstring(file.contents)
defer delete(ccontents)
rc = sqlite.bind_text(stmt, 4, ccontents, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding contents: %s\n", sqlite.db_errmsg(d.db))
return false
}
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc != sqlite.DONE { if rc != sqlite.DONE {
@@ -369,17 +390,23 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
return true return true
} }
db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) { db_fetch :: proc(d: ^Db, path: string, allocator := context.allocator) -> (EnvFile, bool) {
sql := "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?" sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?"
stmt: ^rawptr 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 { if rc != sqlite.OK {
fmt.printf("Error preparing fetch: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error preparing fetch: %s\n", sqlite.db_errmsg(d.db))
return EnvFile{}, false return EnvFile{}, false
} }
defer sqlite.finalize(stmt) 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)
if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db))
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.printf("No file found with path: %s\n", path)
@@ -390,38 +417,41 @@ db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) {
return EnvFile{}, false return EnvFile{}, false
} }
file_path := cstring_to_string(sqlite.column_text(stmt, 0)) remotes_json := string(sqlite.column_text(stmt, 1))
remotes_json := cstring_to_string(sqlite.column_text(stmt, 1)) remotes: [dynamic]string = ---
sha := cstring_to_string(sqlite.column_text(stmt, 2))
contents := cstring_to_string(sqlite.column_text(stmt, 3))
remotes: [dynamic]string
if len(remotes_json) > 0 { 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 { return EnvFile {
Path = cloned_path, Path = file_path,
Dir = filepath.dir(cloned_path), Dir = filepath.dir(file_path),
Remotes = remotes, Remotes = remotes,
Sha256 = sha, Sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator),
contents = contents, contents = clone_cstring(sqlite.column_text(stmt, 3), allocator),
}, },
true true
} }
db_delete :: proc(d: ^Db, path: string) -> bool { 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 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 { if rc != sqlite.OK {
fmt.printf("Error preparing delete: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error preparing delete: %s\n", sqlite.db_errmsg(d.db))
return false return false
} }
defer sqlite.finalize(stmt) 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)
if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db))
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.db_errmsg(d.db)) fmt.printf("Error deleting: %s\n", sqlite.db_errmsg(d.db))
@@ -437,19 +467,31 @@ db_delete :: proc(d: ^Db, path: string) -> bool {
return true return true
} }
cstring_to_string :: proc(cs: cstring) -> string { to_cstring :: proc {
if cs == nil { string_to_cstring,
return "" strings.to_cstring,
}
s, _ := strings.clone_from_cstring(cs)
return s
} }
string_to_cstring :: proc(s: string) -> cstring { string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring {
cs, _ := strings.clone_to_cstring(s) 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 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 { db_update_required :: proc(status: SyncFlag) -> bool {
return .BackedUp in status || .DirUpdated in status return .BackedUp in status || .DirUpdated in status
} }
@@ -496,20 +538,11 @@ find_moved_dirs :: proc(d: ^Db, f: ^EnvFile) -> ([dynamic]string, bool) {
return moved, true return moved, true
} }
env_file_backup :: proc(f: ^EnvFile) -> bool { db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, string) {
data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) return env_file_sync(f, .TrustFilesystem, d)
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
} }
// 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) { env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, string) {
result: SyncFlag = {} result: SyncFlag = {}
@@ -555,6 +588,7 @@ env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, str
} }
digest := hash.hash_bytes(hash.Algorithm.SHA256, data) digest := hash.hash_bytes(hash.Algorithm.SHA256, data)
// TODO: Handle error
hex_bytes, _ := hex.encode(digest) hex_bytes, _ := hex.encode(digest)
current_sha := string(hex_bytes) current_sha := string(hex_bytes)
@@ -580,7 +614,24 @@ env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, str
return result, "" return result, ""
} }
db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, string) { // Loads the contents of the the file at f.Path into f.contents
return env_file_sync(f, .TrustFilesystem, d) //
// 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
} }

View File

@@ -8,16 +8,22 @@ import "core:testing"
import "sqlite" import "sqlite"
FIXTURES :: "/home/spencer/github.com/envr-zig/fixtures" FIXTURES :: "fixtures"
fixture_key :: proc() -> SshKeyPair { fixture_key :: proc() -> SshKeyPair {
priv, _ := strings.concatenate([]string{FIXTURES, "/insecure-test-key"}, context.allocator) priv, _ := strings.concatenate(
pub, _ := strings.concatenate([]string{FIXTURES, "/insecure-test-key.pub"}, context.allocator) []string{FIXTURES, "/keys/insecure-test-key"},
context.temp_allocator,
)
pub, _ := strings.concatenate(
[]string{FIXTURES, "/keys/insecure-test-key.pub"},
context.temp_allocator,
)
return SshKeyPair{Private = priv, Public = pub} return SshKeyPair{Private = priv, Public = pub}
} }
fixture_db_path :: proc() -> string { fixture_db_path :: proc() -> string {
p, _ := strings.concatenate([]string{FIXTURES, "/single-file.db"}, context.allocator) p, _ := strings.concatenate([]string{FIXTURES, "/single-file.db"}, context.temp_allocator)
return p return p
} }
@@ -130,7 +136,7 @@ test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
} }
@(test) @(test)
test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) { test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
cfg := fixture_config() cfg := fixture_config()
defer { defer {
delete(cfg.Keys) delete(cfg.Keys)
@@ -158,14 +164,6 @@ test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) {
} }
defer delete(plaintext) defer delete(plaintext)
tmp_db_path := fmt.tprintf("/tmp/envr-test-attach-%d.db", os.get_pid())
write_err := os.write_entire_file(tmp_db_path, plaintext)
testing.expectf(t, write_err == nil, "failed to write temp db: %v", write_err)
if write_err != nil {
return
}
defer os.remove(tmp_db_path)
mem_db: ^rawptr mem_db: ^rawptr
rc := sqlite.db_open(":memory:", &mem_db) rc := sqlite.db_open(":memory:", &mem_db)
testing.expectf(t, rc == sqlite.OK, "failed to open in-memory db") testing.expectf(t, rc == sqlite.OK, "failed to open in-memory db")
@@ -174,16 +172,29 @@ test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) {
} }
defer sqlite.db_close(mem_db) 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)" n := i64(len(plaintext))
rc = sqlite.db_exec(mem_db, string_to_cstring(create_sql), nil, nil, nil) buf := sqlite.malloc64(n)
testing.expect(t, rc == sqlite.OK, "failed to create table") testing.expect(t, buf != nil, "malloc64 should succeed")
if buf == nil do return
copy(buf[:len(plaintext)], plaintext)
attach_ok := db_attach_and_copy(mem_db, tmp_db_path) rc = sqlite.deserialize(
testing.expect(t, attach_ok, "failed to attach and copy") mem_db,
"main",
buf,
n,
n,
sqlite.DESERIALIZE_FREEONCLOSE | sqlite.DESERIALIZE_RESIZEABLE,
)
testing.expect(t, rc == sqlite.OK, "deserialize should succeed")
if rc != sqlite.OK {
sqlite.free(buf)
return
}
sql := "SELECT path FROM envr_env_files" sql: cstring = "SELECT path FROM envr_env_files"
stmt: ^rawptr 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") testing.expect(t, rc == sqlite.OK, "prepare failed")
if rc != sqlite.OK { if rc != sqlite.OK {
return return
@@ -193,7 +204,7 @@ test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) {
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
testing.expect(t, rc == sqlite.ROW, "expected at least one row") testing.expect(t, rc == sqlite.ROW, "expected at least one row")
if rc == sqlite.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") testing.expect(t, len(path) > 0, "path should not be empty")
} }
} }
@@ -201,9 +212,7 @@ test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) {
@(test) @(test)
test_full_db_cycle :: proc(t: ^testing.T) { test_full_db_cycle :: proc(t: ^testing.T) {
cfg := fixture_config() cfg := fixture_config()
defer { defer delete(cfg.Keys)
delete(cfg.Keys)
}
db_path := fixture_db_path() db_path := fixture_db_path()
original_data, read_err := os.read_entire_file_from_path(db_path, context.allocator) original_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
@@ -224,6 +233,7 @@ test_full_db_cycle :: proc(t: ^testing.T) {
os.mkdir_all(envr_dir_path) os.mkdir_all(envr_dir_path)
data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"}) data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"})
defer delete(data_path)
write_err := os.write_entire_file(data_path, encrypted) write_err := os.write_entire_file(data_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err) testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err)
if write_err != nil { if write_err != nil {

View File

@@ -1,7 +1,239 @@
package main package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing" import "core:testing"
import "sqlite"
make_test_db :: proc() -> (Db, bool) {
db: ^rawptr
rc := sqlite.db_open(":memory:", &db)
if rc != sqlite.OK {
return Db{}, false
}
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
}
return Db{db = db}, true
}
make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {}) -> EnvFile {
f := EnvFile {
Path = path,
Dir = "",
Sha256 = sha,
contents = contents,
Remotes = make([dynamic]string, 0, len(remotes)),
}
for r in remotes {
append(&f.Remotes, r)
}
return f
}
@(test)
test_db_insert_and_fetch :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
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
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)
test_db_fetch_missing :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
_, fetch_ok := db_fetch(&d, "/nonexistent/.env")
testing.expect(t, !fetch_ok, "fetch missing should return false")
}
@(test)
test_db_insert_or_replace :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
f1 := make_test_env_file("/project/.env", "sha1", "KEY=old")
defer delete(f1.Remotes)
testing.expect(t, db_insert(&d, f1), "first insert should succeed")
f2 := make_test_env_file("/project/.env", "sha2", "KEY=new")
defer delete(f2.Remotes)
testing.expect(t, db_insert(&d, f2), "second insert should succeed")
results, list_ok := db_list(&d)
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_envfile(&fetched)
testing.expect_value(t, fetched.contents, "KEY=new")
testing.expect_value(t, fetched.Sha256, "sha2")
}
@(test)
test_db_delete_existing :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
db_insert(&d, f)
testing.expect(t, db_delete(&d, "/project/.env"), "delete should return true")
_, fetch_ok := db_fetch(&d, "/project/.env")
testing.expect(t, !fetch_ok, "row should be gone after delete")
}
@(test)
test_db_delete_missing :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
testing.expect(t, !db_delete(&d, "/nonexistent/.env"), "delete missing should return false")
}
@(test)
test_db_list_multiple :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"})
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)
f3 := make_test_env_file("/proj3/.env", "sha3", "C=3")
db_insert(&d, f1)
db_insert(&d, f2)
db_insert(&d, f3)
results, list_ok := db_list(&d)
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_value(t, len(results), 3)
}
@(test)
test_db_list_empty :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed on empty db")
testing.expect(t, len(results) == 0, "should have 0 rows")
if list_ok do delete(results)
}
@(test)
test_db_insert_sets_changed :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
testing.expect(t, !d.changed, "changed should start false")
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
db_insert(&d, f)
testing.expect(t, d.changed, "changed should be true after insert")
}
@(test)
test_db_delete_sets_changed :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
db_insert(&d, f)
d.changed = false
db_delete(&d, "/project/.env")
testing.expect(t, d.changed, "changed should be true after delete")
}
@(test)
test_db_serialize :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
db_insert(&d, f)
sz: i64
data := sqlite.serialize(d.db, "main", &sz, 0)
testing.expect(t, data != nil, "serialize should return non-nil")
if data == nil do return
defer sqlite.free(data)
testing.expect(t, sz > 0, "serialized size should be > 0")
}
@(test) @(test)
test_db_update_required_noop :: proc(t: ^testing.T) { test_db_update_required_noop :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({}), "Noop should not require update") testing.expect(t, !db_update_required({}), "Noop should not require update")
@@ -87,3 +319,82 @@ test_shares_remote_both_empty :: proc(t: ^testing.T) {
testing.expect(t, !shares_remote(&f, remotes), "both empty should not share") testing.expect(t, !shares_remote(&f, remotes), "both empty should not share")
} }
@(test)
test_make_temp_path_format :: proc(t: ^testing.T) {
p := make_temp_path()
testing.expect(t, strings.has_suffix(p, ".db"), "should end with .db")
testing.expect(t, strings.contains(p, fmt.tprintf("%d", os.get_pid())), "should contain PID")
}
@(test)
test_new_env_file :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-envfile-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "SECRET=value\n")
testing.expect(t, err == nil, ".env file should exists")
file, ok := new_env_file(env_path)
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")
testing.expect(t, file.contents == "SECRET=value\n", "contents mismatch")
testing.expect(t, len(file.Sha256) == 64, "sha256 should be 64 hex chars")
}
@(test)
test_new_env_file_missing :: proc(t: ^testing.T) {
_, ok := new_env_file("/tmp/envr-nonexistent-envfile/path/.env")
testing.expect(t, !ok, "missing file should return false")
}
@(test)
test_env_file_backup :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-backup-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "KEY=12345\n")
testing.expect(t, err == nil, ".env file should exist")
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_value(t, f.contents, "KEY=12345\n")
testing.expect_value(t, len(f.Sha256), 64)
}
@(test)
test_env_file_backup_missing :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/tmp/envr-nonexistent-backup/.env",
}
testing.expect(t, !env_file_backup(&f), "missing file should return false")
}
@(test)
test_update_dir :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/old/project/.env",
Dir = "/old/project",
Remotes = make([dynamic]string, 0),
}
defer delete_envfile(&f)
update_dir(&f, "/new/location")
testing.expect_value(t, f.Dir, "/new/location")
testing.expect_value(t, f.Path, "/new/location/.env")
}

View File

@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACCbll0MJper9prPwGn2wwikH3hTByL8tlzmhViuvfrryAAAAJCkxfzapMX8
2gAAAAtzc2gtZWQyNTUxOQAAACCbll0MJper9prPwGn2wwikH3hTByL8tlzmhViuvfrryA
AAAEDXQExhs89b3fjqJHkhuo9QX4JEjXiEC+vSnCAYc8OxcpuWXQwml6v2ms/AafbDCKQf
eFMHIvy2XOaFWK69+uvIAAAACnNwZW5jZXJAZncBAgM=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJuWXQwml6v2ms/AafbDCKQfeFMHIvy2XOaFWK69+uvI spencer@fw

View File

@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACC4CdhiPHmU44cyy9UZV1ISnDq9RbYl1m1qTYOXaSNougAAAIg+8A82PvAP
NgAAAAtzc2gtZWQyNTUxOQAAACC4CdhiPHmU44cyy9UZV1ISnDq9RbYl1m1qTYOXaSNoug
AAAEAalxEoCavixCImtND1I0YHZZjhOrBLxk//t9v0sjYNVLgJ2GI8eZTjhzLL1RlXUhKc
Or1FtiXWbWpNg5dpI2i6AAAABHRlc3QB
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILgJ2GI8eZTjhzLL1RlXUhKcOr1FtiXWbWpNg5dpI2i6 test

View File

@@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABD342Kol/
iE3kW3alqJTPVpAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIF29NuS3O0JUKCj4
j/NmmJJyJk6n/MwI37WtVeWAC5c/AAAAoPFp0zRQufp8S+f68atSqFT1FYMUvGqL2cmmtJ
r+kXEeEvSGdi3xAxCSLuoe0tMeUYP8aUP1M5L9VzTpFoi8jBIfcPl/ZRX8F/+J4dhp5jno
3nQuo1AN0D60r+UmmX+Z0IzIrD2jIpZ/Y7P2kXT8OErIhtC4ZJs3nIIOKFY7ZzlM1IqbYH
dSSlpUnsAoMPjMb0eD0Q6s6JaldfiNshckauU=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF29NuS3O0JUKCj4j/NmmJJyJk6n/MwI37WtVeWAC5c/ encrypted test key

View File

@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACCZhSOlxHj1zxd+P7adxHOjo3tqqe68AVQ1itJ96nJ95wAAAIh6gz6PeoM+
jwAAAAtzc2gtZWQyNTUxOQAAACCZhSOlxHj1zxd+P7adxHOjo3tqqe68AVQ1itJ96nJ95w
AAAEAEsVzs6egkWMZolD/pZCX5ZcZVXfd5wZ6Ja12f+PxAQJmFI6XEePXPF34/tp3Ec6Oj
e2qp7rwBVDWK0n3qcn3nAAAABXRlc3Qy
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmFI6XEePXPF34/tp3Ec6Oje2qp7rwBVDWK0n3qcn3n test2

27
fixtures/keys/test_rsa Normal file
View File

@@ -0,0 +1,27 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAQEAjwq/ISeK/TmKiV1NABIq+tFwevArpTRTyZ9eC5JyGvDzDB03buVl
6bXd6+cwv+h0AZa7BZN60ayv8zAUmyGpSxFN2gMFiJ/0iFYpTHiLZD4VUH8mCPllIehOdr
epchmlh14BeShJjlGzwBAlgiEON5V62gCWWLmkIzcAgUd3R2NUQfajl74wA0JBkaNeFwUp
nUARyPUeMVX8ZVUvbpE/WOFTZYfFZDkul6aSkAzEeyZq9s4qJ2mWt5acuXcMcUl6YtuAGM
Xii+uV1nJyQpNgHRdEZ2Ch1zmtiTrqjutdBUOfyQZJ3Ln9h/nPJDerUHZboyhu654dLbac
0P3pYciW8wAAA8BvZFJ5b2RSeQAAAAdzc2gtcnNhAAABAQCPCr8hJ4r9OYqJXU0AEir60X
B68CulNFPJn14LknIa8PMMHTdu5WXptd3r5zC/6HQBlrsFk3rRrK/zMBSbIalLEU3aAwWI
n/SIVilMeItkPhVQfyYI+WUh6E52t6lyGaWHXgF5KEmOUbPAECWCIQ43lXraAJZYuaQjNw
CBR3dHY1RB9qOXvjADQkGRo14XBSmdQBHI9R4xVfxlVS9ukT9Y4VNlh8VkOS6XppKQDMR7
Jmr2zionaZa3lpy5dwxxSXpi24AYxeKL65XWcnJCk2AdF0RnYKHXOa2JOuqO610FQ5/JBk
ncuf2H+c8kN6tQdlujKG7rnh0ttpzQ/elhyJbzAAAAAwEAAQAAAQAVAR96x1s1/vaUYDJ3
4bMU/J83NkA6dJofH7tIGLuPsDUIYNvseVwDOxT42IyEiaZLO26ADZ1535FAtR05gHJjFw
nnCw2Ld+2I/Zn35DWXxTQNC3ay16hdl8a50RNdMV3oqEmwGFXgw6eQ+u3/E0qKp/UPwQlS
wwPStfdphGyD+15BxNcc/ZTAByKe9JMi7KkygE02jUn9OMPjJJT9RR+oRXZHLq+yU8Fayl
QUDgmU5Vq8Mhp0P4JrmCMVeZuRhMPrk3XaDJFPgfSMY1fKEapW6itwsG9VTh6xUMxks26t
hk/GuGNjhmt5NOKpQDLLOTKd22u+PZ6kJJQcJjsj47ktAAAAgGcWjHLNm6T0Dp1p5hgfPy
QK019Xp24V1zlejyC0iykzBaC+ZFFS9JOBkqfdrrEE1nAzLvJblhUeWpmLBaqOF+PpPxkF
oAGXzYck2axVcXhpvgB71uOARGZntVDoxVoOC7vT6I2h8eL75pZNGYJZt1K9Zufr4UwNR4
F+FY194pSLAAAAgQDEx1MSFuVZ5sfAH7RteSHWjvyD/CWwbhVzL3IWeUXCMsf9HwUZZd8e
zgyqE6Dh65GTXviuy8Tpb4gT4Gne/QblMHGvdbFMlXNOfzz9U5VkF0q1Y/D4rN0Sa7+nzR
lZx/LKM20egfypNeJWBQT5KzZ8gEOamL7Qyyk5YG2q5evWnwAAAIEAuhdRyPjXaCM2NyvO
dPxvbnpEJZDWRw6iVWtzPAXgwIiI6ngEUVXK2O8T8j0Ufssk3AVbVj1OH8/KJonyWUbedM
mDaFhs4Uvd9iuSZdpS7PbLqHYonurg3m6dz4TrtoWUQuBATdGuIGrtkN+Y83e6UqOGT7lY
Vqw7lPqhNUowAy0AAAAIdGVzdC1yc2EBAgM=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCPCr8hJ4r9OYqJXU0AEir60XB68CulNFPJn14LknIa8PMMHTdu5WXptd3r5zC/6HQBlrsFk3rRrK/zMBSbIalLEU3aAwWIn/SIVilMeItkPhVQfyYI+WUh6E52t6lyGaWHXgF5KEmOUbPAECWCIQ43lXraAJZYuaQjNwCBR3dHY1RB9qOXvjADQkGRo14XBSmdQBHI9R4xVfxlVS9ukT9Y4VNlh8VkOS6XppKQDMR7Jmr2zionaZa3lpy5dwxxSXpi24AYxeKL65XWcnJCk2AdF0RnYKHXOa2JOuqO610FQ5/JBkncuf2H+c8kN6tQdlujKG7rnh0ttpzQ/elhyJbz test-rsa

BIN
fixtures/single-file.db Normal file

Binary file not shown.

View File

@@ -11,11 +11,12 @@
}; };
outputs = outputs =
inputs@{ flake-parts inputs@{
, nixpkgs flake-parts,
, nixpkgs-unstable nixpkgs,
, self nixpkgs-unstable,
, treefmt-nix self,
treefmt-nix,
}: }:
flake-parts.lib.mkFlake { inherit inputs; } { flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ imports = [
@@ -29,7 +30,18 @@
]; ];
perSystem = perSystem =
{ pkgs, system, inputs', ... }: { {
pkgs,
system,
inputs',
...
}:
let
mysqlite = pkgs.sqlite.overrideAttrs (old: {
configureFlags = (old.configureFlags or [ ]) ++ [ "--enable-deserialize" ];
});
in
{
_module.args.pkgs = import nixpkgs { _module.args.pkgs = import nixpkgs {
inherit system; inherit system;
config.allowUnfree = true; config.allowUnfree = true;
@@ -64,7 +76,7 @@
buildInputs = [ buildInputs = [
pkgs.libsodium pkgs.libsodium
pkgs.sqlite mysqlite
]; ];
buildPhase = '' buildPhase = ''
@@ -87,7 +99,7 @@
nushell nushell
libsodium libsodium
sqlite mysqlite
unstable.odin unstable.odin
unstable.ols unstable.ols

View File

@@ -1,10 +1,14 @@
package main package main
import "core:bufio"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
main :: proc() { main :: proc() {
cmd, ok := parse_args() defer free_all(context.temp_allocator)
cmd, ok := parse_args(os.args, os.to_writer(os.stdout), os.to_writer(os.stderr))
defer bufio.writer_flush(cmd.out_buf)
if !ok { if !ok {
return return
} }
@@ -35,10 +39,9 @@ main :: proc() {
case "nushell-completion": case "nushell-completion":
cmd_nushell_completion(&cmd) cmd_nushell_completion(&cmd)
case: case:
fmt.printf("Unknown command: %s\n", cmd.name) fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
print_usage() write_usage(cmd.out)
os.exit(1) os.exit(1)
} }
} }

View File

@@ -1,44 +0,0 @@
package main
import "core:fmt"
import "core:os"
main :: proc() {
cmd, ok := parse_args()
if !ok {
return
}
switch cmd.name {
case "init":
cmd_init(&cmd)
case "version":
cmd_version(&cmd)
case "deps":
cmd_deps(&cmd)
case "list":
cmd_list(&cmd)
case "backup", "add":
cmd_backup(&cmd)
case "remove":
cmd_remove(&cmd)
case "restore":
cmd_restore(&cmd)
case "edit-config":
cmd_edit_config(&cmd)
case "check":
cmd_check(&cmd)
case "scan":
cmd_scan(&cmd)
case "sync":
cmd_sync(&cmd)
case "nushell-completion":
cmd_nushell_completion(&cmd)
case:
fmt.printf("Unknown command: %s\n", cmd.name)
print_usage()
os.exit(1)
}
}

View File

@@ -3,11 +3,113 @@ package main
import "core:fmt" import "core:fmt"
import "core:sys/posix" import "core:sys/posix"
MultiSelect_Result :: enum {
Confirm,
Cancel,
}
Key :: enum {
Up,
Down,
Space,
Enter,
Escape,
Unknown,
}
Raw_State :: struct { Raw_State :: struct {
original: posix.termios, original: posix.termios,
fd: posix.FD, fd: posix.FD,
} }
MAX_VISIBLE :: 7
// Caller is responsible for deleting the responses.
multi_select :: proc(
prompt: string,
options: []string,
) -> (
selected: [dynamic]bool,
result: MultiSelect_Result,
) {
if len(options) == 0 {
return
}
selected = make([dynamic]bool, 0, len(options))
cursor: int = 0
scroll_offset: int = 0
fmt.printf("\x1b[?25l")
visible := render_options(prompt, options, selected[:], cursor, scroll_offset)
raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok {
fmt.printf("\x1b[?25h")
return
}
defer disable_raw_mode(&raw)
for {
key := read_key()
switch key {
case .Up:
if cursor > 0 {
cursor -= 1
}
case .Down:
if cursor < len(options) - 1 {
cursor += 1
}
case .Space:
selected[cursor] = !selected[cursor]
case .Enter:
fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
result = .Confirm
return
case .Escape:
fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
result = .Cancel
return
case .Unknown:
}
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - MAX_VISIBLE))
fmt.printf("\x1b[%dA\x1b[0J", visible + 1)
visible = render_options(prompt, options, selected[:], cursor, scroll_offset)
}
}
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 ..< end {
checkbox := " "
if selected[i] {
checkbox = "x"
}
if i == cursor {
fmt.printf("\x1b[1;32m> \x1b[0m[\x1b[32m%s\x1b[0m] %s\r\n", checkbox, options[i])
} else {
fmt.printf(" [\x1b[2m%s\x1b[0m] %s\r\n", checkbox, options[i])
}
}
return end - scroll_offset
}
enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) { enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) {
state: Raw_State state: Raw_State
state.fd = fd state.fd = fd
@@ -35,15 +137,6 @@ disable_raw_mode :: proc(state: ^Raw_State) {
posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original) posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original)
} }
Key :: enum {
Up,
Down,
Space,
Enter,
Escape,
Unknown,
}
read_key :: proc() -> Key { read_key :: proc() -> Key {
buf: [3]u8 buf: [3]u8
@@ -106,88 +199,3 @@ read_key :: proc() -> Key {
} }
} }
MultiSelect_Result :: enum {
Confirm,
Cancel,
}
MAX_VISIBLE :: 7
multi_select :: proc(
prompt: string,
options: []string,
) -> (selected: [dynamic]bool, result: MultiSelect_Result) {
if len(options) == 0 {
return
}
selected = make([dynamic]bool, len(options))
cursor: int = 0
scroll_offset: int = 0
fmt.printf("\x1b[?25l")
visible := render_options(prompt, options, selected[:], cursor, scroll_offset)
raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok {
fmt.printf("\x1b[?25h")
return
}
defer disable_raw_mode(&raw)
for {
key := read_key()
switch key {
case .Up:
if cursor > 0 {
cursor -= 1
}
case .Down:
if cursor < len(options) - 1 {
cursor += 1
}
case .Space:
selected[cursor] = !selected[cursor]
case .Enter:
fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
result = .Confirm
return
case .Escape:
fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
result = .Cancel
return
case .Unknown:
}
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - MAX_VISIBLE))
fmt.printf("\x1b[%dA\x1b[0J", visible + 1)
visible = render_options(prompt, options, selected[:], cursor, scroll_offset)
}
}
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..<end {
checkbox := " "
if selected[i] {
checkbox = "x"
}
if i == cursor {
fmt.printf("\x1b[1;32m> \x1b[0m[\x1b[32m%s\x1b[0m] %s\r\n", checkbox, options[i])
} else {
fmt.printf(" [\x1b[2m%s\x1b[0m] %s\r\n", checkbox, options[i])
}
}
return end - scroll_offset
}

View File

@@ -69,6 +69,7 @@ run_fd :: proc(args: []string) -> (lines: []string, ok: bool) {
tmp_path := next_fd_tmp_path() tmp_path := next_fd_tmp_path()
tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC) tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC)
if tmp_err != nil { if tmp_err != nil {
// TODO: Log a message here
return return
} }

View File

@@ -3,6 +3,7 @@ package main
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings"
import "core:testing" import "core:testing"
@(test) @(test)
@@ -85,3 +86,11 @@ test_scan_path_empty_dir :: proc(t: ^testing.T) {
testing.expect(t, len(results) == 0, fmt.tprintf("expected 0 results, got %d", len(results))) testing.expect(t, len(results) == 0, fmt.tprintf("expected 0 results, got %d", len(results)))
} }
@(test)
test_scan_meets_expectations :: proc(t: ^testing.T) {
testing.expect(t, cant_scan({}), "no features should mean can't scan")
testing.expect(t, cant_scan({.Git}), "Git alone should mean can't scan")
testing.expect(t, !cant_scan({.Fd}), "having Fd should mean can scan")
testing.expect(t, !cant_scan({.Fd, .Git}), "both Fd and Git should mean can scan")
}

View File

@@ -8,6 +8,9 @@ OK :: 0
ROW :: 100 ROW :: 100
DONE :: 101 DONE :: 101
DESERIALIZE_FREEONCLOSE :: 1
DESERIALIZE_RESIZEABLE :: 2
foreign lib { foreign lib {
@(link_name="sqlite3_open") @(link_name="sqlite3_open")
db_open :: proc(filename: cstring, ppDb: ^^rawptr) -> c.int --- db_open :: proc(filename: cstring, ppDb: ^^rawptr) -> c.int ---
@@ -31,4 +34,12 @@ foreign lib {
bind_text :: proc(stmt: ^rawptr, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int --- bind_text :: proc(stmt: ^rawptr, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int ---
@(link_name="sqlite3_changes") @(link_name="sqlite3_changes")
changes :: proc(db: ^rawptr) -> c.int --- changes :: proc(db: ^rawptr) -> c.int ---
@(link_name="sqlite3_serialize")
serialize :: proc(db: ^rawptr, zSchema: cstring, piSize: ^i64, mFlags: u32) -> [^]u8 ---
@(link_name="sqlite3_deserialize")
deserialize :: proc(db: ^rawptr, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: u32) -> c.int ---
@(link_name="sqlite3_malloc64")
malloc64 :: proc(n: i64) -> [^]u8 ---
@(link_name="sqlite3_free")
free :: proc(p: rawptr) ---
} }

View File

@@ -3,7 +3,7 @@ package main
import "core:fmt" import "core:fmt"
import "core:testing" import "core:testing"
TEST_KEY_DIR :: "/tmp/envr-test-keys" TEST_KEY_DIR :: "fixtures/keys"
@(test) @(test)
test_parse_ed25519_public_key :: proc(t: ^testing.T) { test_parse_ed25519_public_key :: proc(t: ^testing.T) {
@@ -70,3 +70,39 @@ test_read_wire_string :: proc(t: ^testing.T) {
testing.expect(t, s2 == "", "expected empty string") testing.expect(t, s2 == "", "expected empty string")
} }
@(test)
test_is_encrypted_key_encrypted :: proc(t: ^testing.T) {
testing.expect(
t,
is_encrypted_key(TEST_KEY_DIR + "/test_ed25519_encrypted"),
"encrypted key should be detected as encrypted",
)
}
@(test)
test_is_encrypted_key_unencrypted :: proc(t: ^testing.T) {
testing.expect(
t,
!is_encrypted_key(TEST_KEY_DIR + "/test_ed25519"),
"unencrypted key should not be detected as encrypted",
)
}
@(test)
test_is_encrypted_key_rsa_unencrypted :: proc(t: ^testing.T) {
testing.expect(
t,
!is_encrypted_key(TEST_KEY_DIR + "/test_rsa"),
"unencrypted RSA key should not be detected as encrypted",
)
}
@(test)
test_is_encrypted_key_missing_file :: proc(t: ^testing.T) {
testing.expect(
t,
is_encrypted_key(TEST_KEY_DIR + "/nonexistent"),
"missing file should be treated as encrypted (fail-safe)",
)
}

View File

@@ -5,16 +5,16 @@ import "core:fmt"
import "core:io" import "core:io"
import "core:strings" import "core:strings"
render_table :: proc(headers: []string, rows: [][]string) { render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
col_widths := make([dynamic]int, 0, len(headers)) col_widths := make([dynamic]int, 0, len(headers))
for i in 0 ..< len(headers) { for i in 0 ..< len(headers) {
append(&col_widths, strings.rune_count(headers[i])) append(&col_widths, strings.rune_count(headers[i]))
} }
for r in rows { for r in rows {
for i in 0 ..< len(r) { for i in 0 ..< len(r) {
w := strings.rune_count(r[i]) rw := strings.rune_count(r[i])
if i < len(col_widths) && w > col_widths[i] { if i < len(col_widths) && rw > col_widths[i] {
col_widths[i] = w col_widths[i] = rw
} }
} }
} }
@@ -24,7 +24,7 @@ render_table :: proc(headers: []string, rows: [][]string) {
defer strings.builder_destroy(&b) defer strings.builder_destroy(&b)
defer delete(col_widths) defer delete(col_widths)
hline :: proc(b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) { hline :: proc(w: io.Writer, b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) {
strings.write_string(b, left) strings.write_string(b, left)
for i in 0 ..< len(widths) { for i in 0 ..< len(widths) {
for _ in 0 ..< widths[i] + 2 { for _ in 0 ..< widths[i] + 2 {
@@ -36,11 +36,11 @@ render_table :: proc(headers: []string, rows: [][]string) {
strings.write_string(b, right) strings.write_string(b, right)
} }
} }
fmt.println(strings.to_string(b^)) fmt.wprintf(w, "%s\n", strings.to_string(b^), flush = false)
strings.builder_reset(b) strings.builder_reset(b)
} }
hline(&b, "\u250c", "\u252c", "\u2510", col_widths) hline(w, &b, "\u250c", "\u252c", "\u2510", col_widths)
cell :: proc(b: ^strings.Builder, s: string, width: int) { cell :: proc(b: ^strings.Builder, s: string, width: int) {
extra := len(s) - strings.rune_count(s) extra := len(s) - strings.rune_count(s)
@@ -51,21 +51,21 @@ render_table :: proc(headers: []string, rows: [][]string) {
for i in 0 ..< len(headers) { for i in 0 ..< len(headers) {
cell(&b, headers[i], col_widths[i]) cell(&b, headers[i], col_widths[i])
} }
fmt.println(strings.to_string(b)) fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b) strings.builder_reset(&b)
hline(&b, "\u251c", "\u253c", "\u2524", col_widths) hline(w, &b, "\u251c", "\u253c", "\u2524", col_widths)
for r in rows { for r in rows {
strings.write_string(&b, "\u2502") strings.write_string(&b, "\u2502")
for i in 0 ..< len(r) { for i in 0 ..< len(r) {
cell(&b, r[i], col_widths[i]) cell(&b, r[i], col_widths[i])
} }
fmt.println(strings.to_string(b)) fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b) strings.builder_reset(&b)
} }
hline(&b, "\u2514", "\u2534", "\u2518", col_widths) hline(w, &b, "\u2514", "\u2534", "\u2518", col_widths)
} }
render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) { render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) {

View File

@@ -102,3 +102,97 @@ test_render_json_rows_empty :: proc(t: ^testing.T) {
testing.expect(t, len(result) == 0) testing.expect(t, len(result) == 0)
} }
@(test)
test_render_table_normal :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Name", "Path"}
rows := [][]string{{"foo", "/home/user/.env"}, {"bar", "/home/user/project/.env"}}
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
expected := `┌──────┬─────────────────────────┐
│ Name │ Path │
├──────┼─────────────────────────┤
│ foo │ /home/user/.env │
│ bar │ /home/user/project/.env │
└──────┴─────────────────────────┘
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
}
@(test)
test_render_table_empty :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Name"}
rows: [][]string
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
expected := `┌──────┐
│ Name │
├──────┤
└──────┘
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
}
@(test)
test_render_table_unicode :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Status", "Detail"}
rows := [][]string{{"\u2713 Available", "ok"}, {"\u2717 Missing", "fail"}}
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
expected := `┌─────────────┬────────┐
│ Status │ Detail │
├─────────────┼────────┤
│ ✓ Available │ ok │
│ ✗ Missing │ fail │
└─────────────┴────────┘
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
}

View File

@@ -1 +1 @@
0.2.0 0.3.0