mirror of
https://github.com/sbrow/envr.git
synced 2026-10-25 16:51:55 -04:00
Compare commits
1
Commits
prerelease
...
bf6cc9a60a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6cc9a60a |
@@ -14,6 +14,11 @@
|
|||||||
|
|
||||||
8. Add docs for "building from source"
|
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
|
## Double-check AI output
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ key somewhere, otherwise your data could be lost forever.`,
|
|||||||
name = "restore",
|
name = "restore",
|
||||||
usage = "envr restore <path>",
|
usage = "envr restore <path>",
|
||||||
short = "Restore a .env file from the database",
|
short = "Restore a .env file from the database",
|
||||||
flags = GLOBAL_FLAGS,
|
flags = GLOBAL_FLAGS + {.Force},
|
||||||
args = {
|
args = {
|
||||||
{name = "path", completion = "tracked-paths", desc = "Path to .env file to restore"},
|
{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)
|
bufio.writer_destroy(cmd.out_buf)
|
||||||
free(cmd.out_buf)
|
free(cmd.out_buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-8
@@ -7,22 +7,29 @@ import "core:path/filepath"
|
|||||||
// TODO: What happens if you pass a non existent path to cmd_check?
|
// 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.
|
// TODO: UX could be improved, so "run envr add ." if file not exists.
|
||||||
cmd_check :: proc(cmd: ^Command) {
|
cmd_check :: proc(cmd: ^Command) {
|
||||||
_check_path: string
|
input_path: string
|
||||||
if len(cmd.args) > 0 {
|
if len(cmd.args) > 0 {
|
||||||
_check_path = cmd.args[0]
|
input_path = cmd.args[0]
|
||||||
} else {
|
} else {
|
||||||
cwd, cwd_err := os.get_working_directory(context.temp_allocator)
|
cwd, cwd_err := os.get_working_directory(context.temp_allocator)
|
||||||
if cwd_err != nil {
|
if cwd_err != nil {
|
||||||
fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false)
|
fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false)
|
||||||
return
|
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 {
|
if abs_err != nil {
|
||||||
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
|
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
resolved_path = abs_path
|
||||||
|
}
|
||||||
|
|
||||||
db, db_ok := db_open(cmd.flags.config_file)
|
db, db_ok := db_open(cmd.flags.config_file)
|
||||||
if !db_ok {
|
if !db_ok {
|
||||||
@@ -30,20 +37,23 @@ cmd_check :: proc(cmd: ^Command) {
|
|||||||
}
|
}
|
||||||
defer db_close(&db)
|
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
|
// TODO: set a reasonable default
|
||||||
files_in_path := make([dynamic]string, context.temp_allocator)
|
files_in_path := make([dynamic]string, context.temp_allocator)
|
||||||
|
|
||||||
if is_dir {
|
if is_dir {
|
||||||
scanned, scan_ok := scan_path(check_path, db.cfg)
|
scanned, scan_ok := scan_path(resolved_path, db.cfg)
|
||||||
if !scan_ok {
|
if !scan_ok {
|
||||||
fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false)
|
fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
files_in_path = scanned
|
files_in_path = scanned
|
||||||
} else {
|
} else {
|
||||||
append(&files_in_path, check_path)
|
append(&files_in_path, resolved_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
db_files, list_ok := db_list(&db)
|
db_files, list_ok := db_list(&db)
|
||||||
@@ -56,6 +66,8 @@ 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.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false)
|
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 {
|
} else {
|
||||||
fmt.wprintln(
|
fmt.wprintln(
|
||||||
cmd.out,
|
cmd.out,
|
||||||
@@ -73,7 +85,10 @@ cmd_check :: proc(cmd: ^Command) {
|
|||||||
for file in not_backed {
|
for file in not_backed {
|
||||||
fmt.wprintf(cmd.out, " %s\n", file, flush = false)
|
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)
|
fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "core:testing"
|
import "core:testing"
|
||||||
|
import "core:bufio"
|
||||||
|
import "core:os"
|
||||||
|
import "core:path/filepath"
|
||||||
|
import "core:strings"
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
|
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)
|
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
@@ -16,11 +16,18 @@ cmd_remove :: proc(cmd: ^Command) {
|
|||||||
return
|
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)
|
abs_path, abs_err := filepath.abs(path, context.temp_allocator)
|
||||||
if abs_err != nil {
|
if abs_err != nil {
|
||||||
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
|
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
resolved_path = abs_path
|
||||||
|
}
|
||||||
|
|
||||||
db, db_ok := db_open(cmd.flags.config_file)
|
db, db_ok := db_open(cmd.flags.config_file)
|
||||||
if !db_ok {
|
if !db_ok {
|
||||||
@@ -28,10 +35,9 @@ cmd_remove :: proc(cmd: ^Command) {
|
|||||||
}
|
}
|
||||||
defer db_close(&db)
|
defer db_close(&db)
|
||||||
|
|
||||||
if !db_delete(&db, abs_path) {
|
if !db_delete(&db, resolved_path) {
|
||||||
return
|
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
@@ -16,12 +16,18 @@ cmd_restore :: proc(cmd: ^Command) {
|
|||||||
fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
|
fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
|
||||||
return
|
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)
|
abs_path, abs_err := filepath.abs(path, context.temp_allocator)
|
||||||
if abs_err != nil {
|
if abs_err != nil {
|
||||||
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
|
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
resolved_path = abs_path
|
||||||
|
}
|
||||||
|
|
||||||
db, db_ok := db_open(cmd.flags.config_file)
|
db, db_ok := db_open(cmd.flags.config_file)
|
||||||
if !db_ok {
|
if !db_ok {
|
||||||
@@ -29,10 +35,17 @@ cmd_restore :: proc(cmd: ^Command) {
|
|||||||
}
|
}
|
||||||
defer db_close(&db)
|
defer db_close(&db)
|
||||||
|
|
||||||
file, fetch_ok := db_fetch(&db, abs_path)
|
file, fetch_ok := db_fetch(&db, resolved_path)
|
||||||
if !fetch_ok {
|
if !fetch_ok {
|
||||||
return
|
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)
|
dir := filepath.dir(file.path)
|
||||||
if err := os.mkdir_all(dir); err != nil {
|
if err := os.mkdir_all(dir); err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import "core:fmt"
|
|||||||
import "core:mem"
|
import "core:mem"
|
||||||
import "core:os"
|
import "core:os"
|
||||||
import "core:path/filepath"
|
import "core:path/filepath"
|
||||||
|
import "core:path/slashpath"
|
||||||
import "core:strings"
|
import "core:strings"
|
||||||
|
|
||||||
import "sqlite"
|
import "sqlite"
|
||||||
@@ -316,7 +317,11 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
|
|||||||
//
|
//
|
||||||
// Expects an absolute path
|
// Expects an absolute path
|
||||||
db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
|
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))
|
assert(os.is_absolute_path(path))
|
||||||
|
}
|
||||||
|
|
||||||
sql: cstring = "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: sqlite.Stmt
|
stmt: sqlite.Stmt
|
||||||
@@ -399,6 +404,17 @@ db_delete :: proc(db: ^Db, path: string) -> bool {
|
|||||||
|
|
||||||
// Caller is responsible for the returned memory
|
// Caller is responsible for the returned memory
|
||||||
new_env_file :: proc(path: string) -> (EnvFile, bool) {
|
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)
|
abs_path, abs_err := filepath.abs(path)
|
||||||
if abs_err != nil {
|
if abs_err != nil {
|
||||||
fmt.eprintf("Error getting absolute path: %v\n", abs_err)
|
fmt.eprintf("Error getting absolute path: %v\n", abs_err)
|
||||||
@@ -428,12 +444,60 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
|
|||||||
true
|
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.
|
// Reconciles `f` with the filesystem and persists changes to the database.
|
||||||
db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
|
db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
|
||||||
allocator := db_allocator(db)
|
allocator := db_allocator(db)
|
||||||
result: SyncFlag = {}
|
result: SyncFlag = {}
|
||||||
old_path := f.path
|
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) {
|
if !os.exists(f.dir) {
|
||||||
moved, err := try_move_dir(db, f, allocator)
|
moved, err := try_move_dir(db, f, allocator)
|
||||||
if !moved {
|
if !moved {
|
||||||
@@ -480,6 +544,211 @@ db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
|
|||||||
return result + {.BackedUp}, .None
|
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 {
|
db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool {
|
||||||
if f.path != old_path {
|
if f.path != old_path {
|
||||||
if !db_delete(db, old_path) {
|
if !db_delete(db, old_path) {
|
||||||
@@ -591,4 +860,3 @@ clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
|
|||||||
|
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ envr restore <path> [flags]
|
|||||||
|
|
||||||
* `path` — Path to .env file to restore
|
* `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
|
### Options
|
||||||
|
|
||||||
```
|
```
|
||||||
-h, --help show this documentation
|
-h, --help show this documentation
|
||||||
-c, --config-file config file (default "~/.envr/config.json")
|
-c, --config-file config file (default "~/.envr/config.json")
|
||||||
--color Whether or not to colorize output (default 'auto')
|
--color Whether or not to colorize output (default 'auto')
|
||||||
|
-f, --force Overwrite existing config
|
||||||
```
|
```
|
||||||
|
|
||||||
### SEE ALSO
|
### SEE ALSO
|
||||||
|
|||||||
@@ -72,10 +72,12 @@
|
|||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
pkgs.unstable.odin
|
pkgs.unstable.odin
|
||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
|
pkgs.makeWrapper
|
||||||
];
|
];
|
||||||
|
|
||||||
buildInputs = [
|
buildInputs = [
|
||||||
pkgs.git
|
pkgs.git
|
||||||
|
pkgs.openssh
|
||||||
|
|
||||||
pkgs.libsodium
|
pkgs.libsodium
|
||||||
mysqlite
|
mysqlite
|
||||||
@@ -108,6 +110,10 @@
|
|||||||
cp docs/man/*.1 $out/share/man/man1/
|
cp docs/man/*.1 $out/share/man/man1/
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
postFixup = ''
|
||||||
|
wrapProgram $out/bin/envr --prefix PATH : ${nixpkgs.lib.makeBinPath [ pkgs.openssh ]}
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user