@@ -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()
@@ -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 {