feat: Added support for remote files (via ssh).

This commit is contained in:
2026-10-06 12:04:20 -04:00
parent d62123e831
commit bf6cc9a60a
10 changed files with 507 additions and 26 deletions
+5
View File
@@ -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
+1 -2
View File
@@ -105,7 +105,7 @@ key somewhere, otherwise your data could be lost forever.`,
name = "restore",
usage = "envr restore <path>",
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)
}
+23 -8
View File
@@ -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)
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)
}
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)
}
}
}
+43
View File
@@ -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")
}
+9 -3
View File
@@ -16,11 +16,18 @@ cmd_remove :: proc(cmd: ^Command) {
return
}
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)
}
+15 -2
View File
@@ -16,12 +16,18 @@ cmd_restore :: proc(cmd: ^Command) {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return
}
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 {
+269 -1
View File
@@ -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"
@@ -316,7 +317,11 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
//
// Expects an absolute path
db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
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 +404,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_ok := read_remote_file(host, remote_path)
if !read_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 +444,60 @@ 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_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 {
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 +544,211 @@ 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, ok: bool) {
quoted_path := quote_remote_shell_path(remote_path)
remote_command := fmt.tprintf("cat -- '%s'", 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, false
}
delete(stderr)
if !state.success {
fmt.eprintf("Error reading remote file via ssh (exit code %d)\n", state.exit_code)
delete(stdout)
return nil, false
}
return stdout, true
}
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 +860,3 @@ clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
return str
}
+5
View File
@@ -10,12 +10,17 @@ envr restore <path> [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
+6
View File
@@ -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 {
+121
View File
@@ -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")
}