diff --git a/internal/restic/exec.go b/internal/restic/exec.go index 01cd421d5ce7c1743316adddbbc76b6409d433ed..fe55d6c3086a1145d6f4ae20e2a9641f634c9651 100644 --- a/internal/restic/exec.go +++ b/internal/restic/exec.go @@ -1,15 +1,29 @@ package restic import ( + "bytes" "fmt" "os" "os/exec" + "sort" "strings" "syscall" "git.secluded.site/keld/internal/config" ) +// commandSuffix is the suffix keld recognises in .environ keys to indicate +// that the value is a shell command whose stdout should be captured and used +// as the actual environment variable value (with the suffix stripped). +const commandSuffix = "_COMMAND" + +// resticNativeCommands lists .environ keys that restic handles natively. +// Keld passes these through as-is rather than executing them. +var resticNativeCommands = map[string]bool{ + "RESTIC_PASSWORD_COMMAND": true, + "RESTIC_FROM_PASSWORD_COMMAND": true, +} + // DefaultExecutable is the restic binary name used when KELD_EXECUTABLE is // unset. const DefaultExecutable = "restic" @@ -29,6 +43,10 @@ func Run(cfg *config.ResolvedConfig) error { } } + if err := resolveEnvironCommands(cfg.Environ); err != nil { + return err + } + argv := buildArgv(exe, cfg) env := buildEnv(cfg.Environ) @@ -49,8 +67,16 @@ func DryRun(cfg *config.ResolvedConfig) string { if len(cfg.Environ) > 0 { fmt.Fprintln(&b, "environ:") - for k, v := range cfg.Environ { - fmt.Fprintf(&b, " %s=%s\n", k, v) + + // Sort keys for deterministic output. + keys := make([]string, 0, len(cfg.Environ)) + for k := range cfg.Environ { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + fmt.Fprintf(&b, " %s=%s\n", k, cfg.Environ[k]) } } @@ -87,6 +113,45 @@ func buildArgv(exe string, cfg *config.ResolvedConfig) []string { return argv } +// resolveEnvironCommands finds keys ending in _COMMAND in the environ map, +// executes their values as shell commands, and replaces them with the base key +// set to the command's stdout (trailing newline stripped). Keys that restic +// handles natively (like RESTIC_PASSWORD_COMMAND) are left untouched. +// +// If both FOO_COMMAND and FOO are present, the command result takes precedence +// and FOO_COMMAND is removed from the map. +func resolveEnvironCommands(environ map[string]string) error { + for key, cmd := range environ { + if !strings.HasSuffix(key, commandSuffix) { + continue + } + if resticNativeCommands[key] { + continue + } + + baseKey := strings.TrimSuffix(key, commandSuffix) + if baseKey == "" { + continue + } + + var stdout, stderr bytes.Buffer + proc := exec.Command("sh", "-c", cmd) + proc.Stdout = &stdout + proc.Stderr = &stderr + + if err := proc.Run(); err != nil { + return fmt.Errorf( + "environ %s: running %q: %w\nstderr: %s", + key, cmd, err, strings.TrimSpace(stderr.String()), + ) + } + + environ[baseKey] = strings.TrimRight(stdout.String(), "\n") + delete(environ, key) + } + return nil +} + // buildEnv merges extra environment variables into the current process env. func buildEnv(extra map[string]string) []string { env := os.Environ() diff --git a/internal/restic/exec_test.go b/internal/restic/exec_test.go index ed460cf6473dbc05efc78edce444f455734e9f0b..7952de755b30959e8270442eb31245f02543f777 100644 --- a/internal/restic/exec_test.go +++ b/internal/restic/exec_test.go @@ -122,6 +122,170 @@ func TestDryRunExecutableOverride(t *testing.T) { } } +func TestResolveEnvironCommands(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + environ map[string]string + want map[string]string + wantError string + }{ + { + name: "command stdout becomes env var value", + environ: map[string]string{ + "AWS_ACCESS_KEY_ID_COMMAND": "echo my-key-id", + }, + want: map[string]string{ + "AWS_ACCESS_KEY_ID": "my-key-id", + }, + }, + { + name: "trailing newline stripped", + environ: map[string]string{ + "MY_SECRET_COMMAND": "printf 'hello\\n\\n'", + }, + want: map[string]string{ + "MY_SECRET": "hello", + }, + }, + { + name: "multi-line output preserves internal newlines", + environ: map[string]string{ + "MY_CERT_COMMAND": "printf 'line1\\nline2\\nline3\\n'", + }, + want: map[string]string{ + "MY_CERT": "line1\nline2\nline3", + }, + }, + { + name: "command takes precedence over literal value", + environ: map[string]string{ + "MY_KEY": "old-literal", + "MY_KEY_COMMAND": "echo from-command", + }, + want: map[string]string{ + "MY_KEY": "from-command", + }, + }, + { + name: "restic native RESTIC_PASSWORD_COMMAND passes through", + environ: map[string]string{ + "RESTIC_PASSWORD_COMMAND": "op read 'op://Vault/Backup/password'", + }, + want: map[string]string{ + "RESTIC_PASSWORD_COMMAND": "op read 'op://Vault/Backup/password'", + }, + }, + { + name: "restic native RESTIC_FROM_PASSWORD_COMMAND passes through", + environ: map[string]string{ + "RESTIC_FROM_PASSWORD_COMMAND": "pass show backup/source", + }, + want: map[string]string{ + "RESTIC_FROM_PASSWORD_COMMAND": "pass show backup/source", + }, + }, + { + name: "non-command keys left untouched", + environ: map[string]string{ + "AWS_ACCESS_KEY_ID": "literal-key", + "RCLONE_BWLIMIT": "1M", + }, + want: map[string]string{ + "AWS_ACCESS_KEY_ID": "literal-key", + "RCLONE_BWLIMIT": "1M", + }, + }, + { + name: "mixed commands and literals", + environ: map[string]string{ + "AWS_ACCESS_KEY_ID_COMMAND": "echo resolved-id", + "AWS_SECRET_ACCESS_KEY_COMMAND": "echo resolved-secret", + "RCLONE_BWLIMIT": "1M", + "RESTIC_PASSWORD_COMMAND": "pass show backup", + }, + want: map[string]string{ + "AWS_ACCESS_KEY_ID": "resolved-id", + "AWS_SECRET_ACCESS_KEY": "resolved-secret", + "RCLONE_BWLIMIT": "1M", + "RESTIC_PASSWORD_COMMAND": "pass show backup", + }, + }, + { + name: "command failure returns error", + environ: map[string]string{ + "BAD_KEY_COMMAND": "false", + }, + wantError: "environ BAD_KEY_COMMAND", + }, + { + name: "command stderr included in error", + environ: map[string]string{ + "BAD_KEY_COMMAND": "echo 'oh no' >&2; exit 1", + }, + wantError: "oh no", + }, + { + name: "bare _COMMAND key with empty base is ignored", + environ: map[string]string{"_COMMAND": "echo nope"}, + want: map[string]string{"_COMMAND": "echo nope"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := resolveEnvironCommands(tt.environ) + + if tt.wantError != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantError) + } + if !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.wantError) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !reflect.DeepEqual(tt.environ, tt.want) { + t.Fatalf("environ mismatch:\n got: %v\n want: %v", tt.environ, tt.want) + } + }) + } +} + +func TestDryRunPreservesCommands(t *testing.T) { + t.Parallel() + + cfg := &config.ResolvedConfig{ + Command: "backup", + Environ: map[string]string{ + "AWS_ACCESS_KEY_ID_COMMAND": "echo test-key", + "RESTIC_PASSWORD_COMMAND": "pass show backup", + }, + Arguments: []string{"/home/alice"}, + } + + output := DryRun(cfg) + + // DryRun must NOT resolve _COMMAND keys — that would leak secrets. + if !strings.Contains(output, "AWS_ACCESS_KEY_ID_COMMAND=echo test-key") { + t.Fatalf("DryRun() should preserve AWS_ACCESS_KEY_ID_COMMAND as-is:\n%s", output) + } + + // Restic-native commands should also pass through unchanged. + if !strings.Contains(output, "RESTIC_PASSWORD_COMMAND=pass show backup") { + t.Fatalf("DryRun() should preserve RESTIC_PASSWORD_COMMAND:\n%s", output) + } +} + func containsEntry(values []string, needle string) bool { for _, value := range values { if value == needle {