From 86235145f955f5b1ef442446074229a1e0927ee7 Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Thu, 1 Oct 2026 16:24:10 -0400 Subject: [PATCH] feat: Added support for remote files (via ssh). --- TODOS.md | 5 +++++ cmd_restore.odin | 5 ++++- db.odin | 42 ++++++++++++++++++++++++++++++++++++++++++ flake.nix | 6 ++++++ remote_test.odin | 41 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 remote_test.odin diff --git a/TODOS.md b/TODOS.md index 7907139..fe682fc 100644 --- a/TODOS.md +++ b/TODOS.md @@ -14,6 +14,11 @@ 8. Add docs for "building from source" +9. Add `envr show` command that uses pager (`less`) unless -no-pager is passed. + +10. Run remote SSH sync operations in parallel. + +11. Envr sync should require confirmation before pushing remote files. ## Double-check AI output diff --git a/cmd_restore.odin b/cmd_restore.odin index 438a6bf..b448ce9 100644 --- a/cmd_restore.odin +++ b/cmd_restore.odin @@ -16,6 +16,10 @@ cmd_restore :: proc(cmd: ^Command) { fmt.wprintln(cmd.err, "Error: No path provided", flush = false) return } + if _, _, is_remote := parse_remote_path(path); is_remote { + fmt.wprintln(cmd.err, "Remote restore is not supported yet", flush = false) + return + } abs_path, abs_err := filepath.abs(path, context.temp_allocator) if abs_err != nil { fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) @@ -50,4 +54,3 @@ cmd_restore :: proc(cmd: ^Command) { fmt.wprintf(cmd.out, "Restored %s\n", file.path, flush = false) } - diff --git a/db.odin b/db.odin index 7941c54..9374b35 100644 --- a/db.odin +++ b/db.odin @@ -399,6 +399,14 @@ db_delete :: proc(db: ^Db, path: string) -> bool { // Caller is responsible for the returned memory new_env_file :: proc(path: string) -> (EnvFile, bool) { + if host, remote_path, is_remote := parse_remote_path(path); is_remote { + contents, read_ok := read_remote_file(host, remote_path) + if !read_ok { + return EnvFile{}, false + } + return new_remote_env_file(path, contents), true + } + abs_path, abs_err := filepath.abs(path) if abs_err != nil { fmt.eprintf("Error getting absolute path: %v\n", abs_err) @@ -428,12 +436,46 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) { true } +// Takes ownership of contents and returns an EnvFile that owns all its fields. +new_remote_env_file :: proc(path: string, contents: []byte) -> EnvFile { + digest := hash.hash_bytes(hash.Algorithm.SHA256, contents, context.temp_allocator) + hex_bytes := hex.encode(digest, context.allocator) + + return EnvFile { + path = strings.clone(path, context.allocator), + dir = "", + sha256 = string(hex_bytes), + contents = string(contents), + } +} + // Reconciles `f` with the filesystem and persists changes to the database. db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) { allocator := db_allocator(db) result: SyncFlag = {} old_path := f.path + if host, remote_path, is_remote := parse_remote_path(f.path); is_remote { + contents, read_ok := read_remote_file(host, remote_path) + if !read_ok { + return {}, .ReadFailed + } + + digest := hash.hash_bytes(hash.Algorithm.SHA256, contents, context.temp_allocator) + hex_bytes := hex.encode(digest, allocator) + current_sha := string(hex_bytes) + if current_sha == f.sha256 { + return {}, .None + } + + f.contents = string(contents) + f.sha256 = current_sha + if !db_persist(db, f, old_path) { + return {}, .DbFailed + } + return {.BackedUp}, .None + } + if !os.exists(f.dir) { moved, err := try_move_dir(db, f, allocator) if !moved { diff --git a/flake.nix b/flake.nix index 9688a95..4e5ca32 100644 --- a/flake.nix +++ b/flake.nix @@ -72,10 +72,12 @@ nativeBuildInputs = [ pkgs.unstable.odin pkgs.pkg-config + pkgs.makeWrapper ]; buildInputs = [ pkgs.git + pkgs.openssh pkgs.libsodium mysqlite @@ -108,6 +110,10 @@ cp docs/man/*.1 $out/share/man/man1/ runHook postInstall ''; + + postFixup = '' + wrapProgram $out/bin/envr --prefix PATH : ${nixpkgs.lib.makeBinPath [ pkgs.openssh ]} + ''; }; devShells.default = pkgs.mkShell { diff --git a/remote_test.odin b/remote_test.odin new file mode 100644 index 0000000..5c3a5f7 --- /dev/null +++ b/remote_test.odin @@ -0,0 +1,41 @@ +#+test +package main + +import "core:strings" +import "core:testing" + +@(test) +test_parse_remote_path :: proc(t: ^testing.T) { + host, path, ok := parse_remote_path("spencer@example.com:/srv/app/.env") + testing.expect(t, ok, "expected remote identity to be recognized") + testing.expect_value(t, host, "spencer@example.com") + testing.expect_value(t, path, "/srv/app/.env") +} + +@(test) +test_parse_remote_path_rejects_local_paths :: proc(t: ^testing.T) { + _, _, ok := parse_remote_path("/srv/app/.env") + testing.expect(t, !ok, "local path should not be treated as remote") + _, _, ok = parse_remote_path("user@example.com:relative.env") + testing.expect(t, !ok, "remote path must be absolute") +} + +@(test) +test_quote_remote_shell_path :: proc(t: ^testing.T) { + testing.expect_value(t, quote_remote_shell_path("/srv/app/.env"), "/srv/app/.env") + testing.expect_value(t, quote_remote_shell_path("/srv/app/a'b.env"), "/srv/app/a'\"'\"'b.env") +} + +@(test) +test_new_remote_env_file_owns_its_fields :: proc(t: ^testing.T) { + input_path := strings.clone("spencer@example.com:/etc/umami.env", context.allocator) + contents := make([]u8, len("SECRET=value"), context.allocator) + copy(contents, "SECRET=value") + + file := new_remote_env_file(input_path, contents) + delete(input_path) + defer delete_envfile(&file) + + testing.expect_value(t, file.path, "spencer@example.com:/etc/umami.env") + testing.expect_value(t, file.contents, "SECRET=value") +}