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/cli.odin b/cli.odin index c97494b..7c10d14 100644 --- a/cli.odin +++ b/cli.odin @@ -105,7 +105,7 @@ key somewhere, otherwise your data could be lost forever.`, name = "restore", usage = "envr restore ", short = "Restore a .env file from the database", - flags = GLOBAL_FLAGS, + flags = GLOBAL_FLAGS + {.Force}, args = { {name = "path", completion = "tracked-paths", desc = "Path to .env file to restore"}, }, @@ -485,4 +485,3 @@ delete_command :: proc(cmd: ^Command) { bufio.writer_destroy(cmd.out_buf) free(cmd.out_buf) } - diff --git a/cmd_check.odin b/cmd_check.odin index 9118ae5..89d6c64 100644 --- a/cmd_check.odin +++ b/cmd_check.odin @@ -7,22 +7,29 @@ import "core:path/filepath" // TODO: What happens if you pass a non existent path to cmd_check? // TODO: UX could be improved, so "run envr add ." if file not exists. cmd_check :: proc(cmd: ^Command) { - _check_path: string + input_path: string if len(cmd.args) > 0 { - _check_path = cmd.args[0] + input_path = cmd.args[0] } else { cwd, cwd_err := os.get_working_directory(context.temp_allocator) if cwd_err != nil { fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false) return } - _check_path = cwd + input_path = cwd } - check_path, abs_err := filepath.abs(_check_path, context.temp_allocator) - if abs_err != nil { - fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) + resolved_path, _, _, is_remote, path_resolved := normalize_remote_identity(input_path) + if is_remote && !path_resolved { return } + if !is_remote { + abs_path, abs_err := filepath.abs(input_path, context.temp_allocator) + if abs_err != nil { + fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) + return + } + resolved_path = abs_path + } db, db_ok := db_open(cmd.flags.config_file) if !db_ok { @@ -30,20 +37,23 @@ cmd_check :: proc(cmd: ^Command) { } defer db_close(&db) - is_dir := os.is_directory(check_path) + is_dir := false + if !is_remote { + is_dir = os.is_directory(resolved_path) + } // TODO: set a reasonable default files_in_path := make([dynamic]string, context.temp_allocator) if is_dir { - scanned, scan_ok := scan_path(check_path, db.cfg) + scanned, scan_ok := scan_path(resolved_path, db.cfg) if !scan_ok { fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false) return } files_in_path = scanned } else { - append(&files_in_path, check_path) + append(&files_in_path, resolved_path) } db_files, list_ok := db_list(&db) @@ -56,6 +66,8 @@ cmd_check :: proc(cmd: ^Command) { if len(not_backed) == 0 { if len(files_in_path) == 0 { fmt.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false) + } else if is_remote { + fmt.wprintln(cmd.out, "✓ Remote file is backed up.", flush = false) } else { fmt.wprintln( cmd.out, @@ -73,7 +85,10 @@ cmd_check :: proc(cmd: ^Command) { for file in not_backed { fmt.wprintf(cmd.out, " %s\n", file, flush = false) } - fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false) + if is_remote { + fmt.wprintf(cmd.out, "\nRun 'envr backup %s' to back up this remote file.\n", resolved_path, flush = false) + } else { + fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false) + } } } - diff --git a/cmd_check_test.odin b/cmd_check_test.odin index f25e7c7..24f50d2 100644 --- a/cmd_check_test.odin +++ b/cmd_check_test.odin @@ -2,6 +2,10 @@ package main import "core:testing" +import "core:bufio" +import "core:os" +import "core:path/filepath" +import "core:strings" @(test) test_find_unbacked_finds_missing :: proc(t: ^testing.T) { @@ -42,3 +46,42 @@ test_find_unbacked_none_backed :: proc(t: ^testing.T) { testing.expect_value(t, len(result), 2) } +@(test) +test_cmd_check_remote_path :: proc(t: ^testing.T) { + base := test_temp_dir(t, "envr-test-check-remote-*") + defer os.remove_all(base) + cfg_path, _ := filepath.join({base, "config.json"}, context.temp_allocator) + cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path) + testing.expect(t, save_config(cfg, force = true), "config should save") + delete_config(&cfg) + + db, db_ok := db_open(cfg_path) + testing.expect(t, db_ok, "db should open") + if !db_ok do return + file := make_test_env_file("spencer@example.com:/etc/umami.env", "abc123", "SECRET=value") + testing.expect(t, db_insert(&db, file), "remote record should insert") + db_close(&db) + + out_b: strings.Builder + strings.builder_init(&out_b) + defer strings.builder_destroy(&out_b) + err_b: strings.Builder + strings.builder_init(&err_b) + defer strings.builder_destroy(&err_b) + + cmd, ok := parse_args( + []string{"envr", "check", "spencer@example.com:/etc/umami.env", "--config-file", cfg_path}, + strings.to_stream(&out_b), + strings.to_stream(&err_b), + ) + testing.expect(t, ok, "command should parse") + if !ok do return + defer delete_command(&cmd) + + cmd_check(&cmd) + bufio.writer_flush(cmd.out_buf) + output := strings.to_string(out_b) + error_output := strings.to_string(err_b) + testing.expect(t, strings.contains(output, "Remote file is backed up"), "tracked remote should report backed up") + testing.expect(t, len(error_output) == 0, "remote path should not be treated as a local path") +} diff --git a/cmd_remove.odin b/cmd_remove.odin index 7b3c3a0..5c85109 100644 --- a/cmd_remove.odin +++ b/cmd_remove.odin @@ -16,11 +16,18 @@ cmd_remove :: proc(cmd: ^Command) { 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) + resolved_path, _, _, is_remote, path_resolved := normalize_remote_identity(path) + if is_remote && !path_resolved { return } + if !is_remote { + 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) + return + } + resolved_path = abs_path + } db, db_ok := db_open(cmd.flags.config_file) if !db_ok { @@ -28,10 +35,9 @@ cmd_remove :: proc(cmd: ^Command) { } defer db_close(&db) - if !db_delete(&db, abs_path) { + if !db_delete(&db, resolved_path) { return } - fmt.wprintf(cmd.out, "Removed %s from the database\n", abs_path, flush = false) + fmt.wprintf(cmd.out, "Removed %s from the database\n", resolved_path, flush = false) } - diff --git a/cmd_restore.odin b/cmd_restore.odin index 438a6bf..6686485 100644 --- a/cmd_restore.odin +++ b/cmd_restore.odin @@ -16,12 +16,18 @@ cmd_restore :: proc(cmd: ^Command) { fmt.wprintln(cmd.err, "Error: No path provided", 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) - + resolved_path, host, remote_path, is_remote, path_ok := normalize_remote_identity(path) + if is_remote && !path_ok { return } + if !is_remote { + 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) + return + } + resolved_path = abs_path + } db, db_ok := db_open(cmd.flags.config_file) if !db_ok { @@ -29,10 +35,17 @@ cmd_restore :: proc(cmd: ^Command) { } defer db_close(&db) - file, fetch_ok := db_fetch(&db, abs_path) + file, fetch_ok := db_fetch(&db, resolved_path) if !fetch_ok { return } + if is_remote { + if !write_remote_file(host, remote_path, file.contents, file.sha256, cmd.flags.force) { + return + } + fmt.wprintf(cmd.out, "Restored %s\n", file.path, flush = false) + return + } dir := filepath.dir(file.path) if err := os.mkdir_all(dir); err != nil { diff --git a/db.odin b/db.odin index 7941c54..781db25 100644 --- a/db.odin +++ b/db.odin @@ -7,6 +7,7 @@ import "core:fmt" import "core:mem" import "core:os" import "core:path/filepath" +import "core:path/slashpath" import "core:strings" import "sqlite" @@ -19,6 +20,12 @@ SyncFlagEnum :: enum { SyncFlag :: bit_set[SyncFlagEnum] +Remote_Read_Status :: enum { + Ok, + Missing, + Failed, +} + SyncError :: enum { None, DirMissing, @@ -316,7 +323,11 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool { // // Expects an absolute path db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) { - assert(os.is_absolute_path(path)) + if _, remote_path, is_remote := parse_remote_path(path); is_remote { + assert(slashpath.is_abs(remote_path)) + } else { + assert(os.is_absolute_path(path)) + } sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?" stmt: sqlite.Stmt @@ -399,6 +410,17 @@ db_delete :: proc(db: ^Db, path: string) -> bool { // Caller is responsible for the returned memory new_env_file :: proc(path: string) -> (EnvFile, bool) { + if identity, host, remote_path, is_remote, resolved := normalize_remote_identity(path); is_remote { + if !resolved { + return EnvFile{}, false + } + contents, read_status := read_remote_file(host, remote_path) + if read_status != .Ok { + return EnvFile{}, false + } + return new_remote_env_file(identity, 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 +450,66 @@ 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 _, _, is_remote := parse_remote_path(f.path); is_remote { + identity, host, remote_path, _, resolved := normalize_remote_identity(f.path) + if !resolved { + return {}, .ReadFailed + } + contents, read_status := read_remote_file(host, remote_path) + if read_status == .Failed { + return {}, .ReadFailed + } + if read_status == .Missing { + if !write_remote_file(host, remote_path, f.contents, f.sha256, false) { + return {}, .WriteFailed + } + return {.Restored}, .None + } + + 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 { + delete(contents) + if identity != old_path { + f.path = strings.clone(identity, allocator) + if !db_persist(db, f, old_path) { + return {}, .DbFailed + } + } + return {}, .None + } + + if identity != old_path { + f.path = strings.clone(identity, allocator) + } + 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 { @@ -480,6 +556,215 @@ db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) { return result + {.BackedUp}, .None } +// A remote identity uses user@host:path; CLI input is expanded to an absolute path. +parse_remote_path :: proc(path: string) -> (host, remote_path: string, is_remote: bool) { + separator := strings.index(path, ":") + if separator <= 0 || separator + 1 >= len(path) { + return + } + host = path[:separator] + if strings.index(host, "@") <= 0 || + strings.index(host, " ") >= 0 || + strings.index(host, "'") >= 0 { + return "", "", false + } + remote_path = path[separator + 1:] + is_remote = true + return +} + +// Resolves relative and tilde-prefixed remote input against the remote HOME. +// Absolute paths stay absolute, independent of the local host OS. +normalize_remote_identity :: proc(path: string) -> ( + identity, host, remote_path: string, + is_remote, ok: bool, +) { + parsed_host, parsed_path, parsed_is_remote := parse_remote_path(path) + if !parsed_is_remote { + return path, "", "", false, true + } + host = parsed_host + is_remote = true + + if slashpath.is_abs(parsed_path) { + remote_path = slashpath.clean(parsed_path, context.temp_allocator) + } else { + home, home_ok := remote_home_directory(host) + if !home_ok { + return "", host, "", true, false + } + remote_path = expand_remote_path(home, parsed_path) + } + + identity = fmt.tprintf("%s:%s", host, remote_path) + ok = true + return +} + +// Expands relative and tilde-prefixed remote input against an absolute POSIX home. +expand_remote_path :: proc(home, path: string) -> string { + if slashpath.is_abs(path) { + return slashpath.clean(path, context.temp_allocator) + } + relative := path + if strings.has_prefix(relative, "~") { + relative = relative[1:] + if strings.has_prefix(relative, "/") { + relative = relative[1:] + } + } + return slashpath.join({home, relative}, context.temp_allocator) +} + +remote_home_directory :: proc(host: string) -> (home: string, ok: bool) { + // The command is constant; HOME is expanded by the remote shell and no + // user-supplied path is interpolated into it. + desc := os.Process_Desc { + command = []string{"ssh", "-T", "--", host, "printf '%s' \"$HOME\""}, + } + state, stdout, stderr, err := os.process_exec(desc, context.temp_allocator) + delete(stderr) + if err != nil { + fmt.eprintf("Error querying remote home directory over ssh: %v\n", err) + delete(stdout) + return "", false + } + if !state.success { + fmt.eprintf("Error querying remote home directory over ssh (exit code %d)\n", state.exit_code) + delete(stdout) + return "", false + } + + home = string(stdout) + if !slashpath.is_abs(home) { + fmt.eprintf("Error: remote HOME is not an absolute POSIX path\n") + return "", false + } + home = slashpath.clean(home, context.temp_allocator) + ok = true + return +} + +// Read a remote file through OpenSSH. The path is quoted for the remote shell; +// the host and command are passed as distinct local argv elements. +read_remote_file :: proc(host, remote_path: string) -> (contents: []byte, status: Remote_Read_Status) { + quoted_path := quote_remote_shell_path(remote_path) + remote_command := fmt.tprintf("if [ -e '%s' ]; then cat -- '%s'; else exit 44; fi", quoted_path, quoted_path) + desc := os.Process_Desc { + command = []string{"ssh", "-T", "--", host, remote_command}, + stdin = nil, + } + state, stdout, stderr, err := os.process_exec(desc, context.allocator) + if err != nil { + fmt.eprintf("Error starting ssh: %v\n", err) + delete(stdout) + delete(stderr) + return nil, .Failed + } + delete(stderr) + if !state.success { + if state.exit_code == 44 { + delete(stdout) + return nil, .Missing + } + fmt.eprintf("Error reading remote file via ssh (exit code %d)\n", state.exit_code) + delete(stdout) + return nil, .Failed + } + return stdout, .Ok +} + +quote_remote_shell_path :: proc(path: string) -> string { + quoted_path, _ := strings.replace_all(path, "'", "'\"'\"'", context.temp_allocator) + return quoted_path +} + +// Atomically writes a remote file via SSH stdin, never staging plaintext locally. +// Existing remote contents must match the stored digest unless force is set. +write_remote_file :: proc(host, remote_path, contents, expected_sha: string, force: bool) -> bool { + if len(expected_sha) != 64 { + fmt.eprintf("Error: invalid stored SHA-256 digest for remote restore\n") + return false + } + for c in expected_sha { + if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F') { + fmt.eprintf("Error: invalid stored SHA-256 digest for remote restore\n") + return false + } + } + quoted_path := quote_remote_shell_path(remote_path) + force_check := force ? ":" : fmt.tprintf(` +if [ -e "$target" ]; then + if command -v sha256sum >/dev/null 2>&1; then + current=$(sha256sum -- "$target" | cut -d ' ' -f 1) + elif command -v shasum >/dev/null 2>&1; then + current=$(shasum -a 256 -- "$target" | cut -d ' ' -f 1) + else + echo 'Remote restore requires sha256sum or shasum; use --force to bypass conflict checking' >&2 + exit 74 + fi + if [ "$current" != '%s' ]; then + echo 'Remote file changed since backup; use --force to overwrite' >&2 + exit 73 + fi +fi +`, expected_sha) + remote_command := fmt.tprintf(` +set -eu +target='%s' +dir=${target%/*} +[ -n "$dir" ] || dir=/ +mkdir -p -- "$dir" +umask 077 +tmp=$(mktemp "$dir/.envr.XXXXXXXX") +trap 'rm -f -- "$tmp"' EXIT HUP INT TERM +cat > "$tmp" +%s +mv -f -- "$tmp" "$target" +trap - EXIT HUP INT TERM +`, quoted_path, force_check) + read_pipe, write_pipe, pipe_err := os.pipe() + if pipe_err != nil { + fmt.eprintf("Error creating SSH input pipe: %v\n", pipe_err) + return false + } + desc := os.Process_Desc { + command = []string{"ssh", "-T", "--", host, remote_command}, + stdin = read_pipe, + } + process, start_err := os.process_start(desc) + _ = os.close(read_pipe) + if start_err != nil { + _ = os.close(write_pipe) + fmt.eprintf("Error starting ssh: %v\n", start_err) + return false + } + remaining := transmute([]byte)contents + write_err: os.Error + for len(remaining) > 0 { + written, err := os.write(write_pipe, remaining) + if err != nil { + write_err = err + break + } + if written == 0 { + break + } + remaining = remaining[written:] + } + _ = os.close(write_pipe) + state, wait_err := os.process_wait(process) + if write_err != nil { + fmt.eprintf("Error sending file contents over ssh: %v\n", write_err) + return false + } + if wait_err != nil || !state.success { + fmt.eprintf("Error writing remote file via ssh (exit code %d)\n", state.exit_code) + return false + } + return true +} + db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool { if f.path != old_path { if !db_delete(db, old_path) { @@ -591,4 +876,3 @@ clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string { return str } - diff --git a/docs/cli/envr_restore.md b/docs/cli/envr_restore.md index d625c72..77c2b83 100644 --- a/docs/cli/envr_restore.md +++ b/docs/cli/envr_restore.md @@ -10,12 +10,17 @@ envr restore [flags] * `path` — Path to .env file to restore +Remote files are restored over SSH using an atomic replacement. If the remote +file has changed since it was backed up, restoration is refused unless +`--force` is specified. + ### Options ``` -h, --help show this documentation -c, --config-file config file (default "~/.envr/config.json") --color Whether or not to colorize output (default 'auto') + -f, --force Overwrite existing config ``` ### SEE ALSO diff --git a/docs/cli/envr_sync.md b/docs/cli/envr_sync.md index e9ec126..f5938a3 100644 --- a/docs/cli/envr_sync.md +++ b/docs/cli/envr_sync.md @@ -2,6 +2,10 @@ Update or restore your env backups +For both local and remote files, `sync` backs up changed file contents and +restores missing files from the backup. Remote SSH and permission errors are +reported instead of being treated as missing files. + ``` envr sync [flags] ``` 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..fc45bab --- /dev/null +++ b/remote_test.odin @@ -0,0 +1,121 @@ +#+test +package main + +import "core:strings" +import "core:fmt" +import "core:os" +import "core:path/filepath" +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:") + testing.expect(t, !ok, "remote path must not be empty") +} + +@(test) +test_expand_remote_path_to_absolute_posix_path :: proc(t: ^testing.T) { + cases := [?][2]string{ + {"outline/docker.env", "/root/outline/docker.env"}, + {"~/outline/docker.env", "/root/outline/docker.env"}, + {"~outline/docker.env", "/root/outline/docker.env"}, + {"/root/outline/docker.env", "/root/outline/docker.env"}, + {"../outside.env", "/outside.env"}, + } + for c in cases { + got := expand_remote_path("/root", c[0]) + testing.expect_value(t, got, c[1]) + } +} + +@(test) +test_normalize_remote_absolute_identity :: proc(t: ^testing.T) { + identity, host, path, is_remote, ok := normalize_remote_identity( + "root@vertaxdev.com:/root/outline/docker.env", + ) + testing.expect(t, is_remote && ok, "absolute remote identity should normalize without SSH") + testing.expect_value(t, identity, "root@vertaxdev.com:/root/outline/docker.env") + testing.expect_value(t, host, "root@vertaxdev.com") + testing.expect_value(t, path, "/root/outline/docker.env") +} + +@(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_quote_remote_shell_path_round_trips_without_injection :: proc(t: ^testing.T) { + when ODIN_OS == .Windows { + return + } + + base := test_temp_dir(t, "envr-shell-quote-*") + defer os.remove_all(base) + marker, _ := filepath.join({base, "injected"}, context.temp_allocator) + quoted_marker := quote_remote_shell_path(marker) + + paths := make([dynamic]string, 0, 10, context.temp_allocator) + append(&paths, + "", + "plain-name_123./:+@%-=,", + "spaces and\ttabs", + "single'quote and \"double quote\"", + "two''consecutive'''quotes", + fmt.tprintf("; touch '%s'; #", marker), + fmt.tprintf("$(touch '%s')", marker), + fmt.tprintf("`touch '%s'`", marker), + "&& || | < > ( ) $ ! * ? [ ] # ~ % { } \\", + "line one\nline two", + ) + + for path in paths { + if os.exists(marker) { + _ = os.remove(marker) + } + quoted_path := quote_remote_shell_path(path) + script := fmt.tprintf( + "printf '%%s' '%s'; if [ -e '%s' ]; then printf INJECTED; fi", + quoted_path, + quoted_marker, + ) + desc := os.Process_Desc { + command = []string{"/bin/sh", "-c", script}, + } + state, stdout, stderr, err := os.process_exec(desc, context.allocator) + delete(stdout) + delete(stderr) + testing.expectf(t, err == nil, "shell execution failed for %q: %v", path, err) + if err != nil { + return + } + testing.expectf(t, state.success, "shell failed for %q", path) + testing.expect_value(t, string(stdout), path) + testing.expectf(t, !os.exists(marker), "path %q executed the marker command", path) + } +} + +@(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") +}