Support _COMMAND suffix in environ sections

Amolith created

Add resolveEnvironCommands to internal/restic/exec.go. When an
.environ key ends in _COMMAND, keld executes its value via sh -c,
captures stdout (stripping trailing newlines), and sets the base
key to the result.

RESTIC_PASSWORD_COMMAND and RESTIC_FROM_PASSWORD_COMMAND are passed
through to restic as-is since restic handles those natively.

DryRun does NOT resolve _COMMAND keys, avoiding secret leakage in
--show-command output. Environ keys are sorted for deterministic
dry-run output.

Change summary

internal/restic/exec.go      |  69 +++++++++++++++
internal/restic/exec_test.go | 164 ++++++++++++++++++++++++++++++++++++++
2 files changed, 231 insertions(+), 2 deletions(-)

Detailed changes

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

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 {