exec_test.go

  1package restic
  2
  3import (
  4	"os"
  5	"path/filepath"
  6	"reflect"
  7	"strings"
  8	"testing"
  9
 10	"git.secluded.site/keld/internal/config"
 11)
 12
 13func TestBuildArgv(t *testing.T) {
 14	t.Parallel()
 15
 16	tests := []struct {
 17		name string
 18		exe  string
 19		cfg  *config.ResolvedConfig
 20		want []string
 21	}{
 22		{
 23			name: "command flags and args",
 24			exe:  "restic",
 25			cfg: &config.ResolvedConfig{
 26				Command: "backup",
 27				Flags: []config.Flag{
 28					{Name: "--repo", Value: "/repo"},
 29					{Name: "--json"},
 30					{Name: "-o", Value: "s3.connections=5"},
 31				},
 32				Arguments: []string{"/home/alice", "/var/lib"},
 33			},
 34			want: []string{"restic", "backup", "--repo", "/repo", "--json", "-o", "s3.connections=5", "/home/alice", "/var/lib"},
 35		},
 36		{
 37			name: "no command still includes executable",
 38			exe:  "restic",
 39			cfg: &config.ResolvedConfig{
 40				Flags: []config.Flag{{Name: "--json"}},
 41			},
 42			want: []string{"restic", "--json"},
 43		},
 44	}
 45
 46	for _, tt := range tests {
 47		t.Run(tt.name, func(t *testing.T) {
 48			t.Parallel()
 49
 50			got := buildArgv(tt.exe, tt.cfg)
 51			if !reflect.DeepEqual(got, tt.want) {
 52				t.Fatalf("buildArgv() mismatch: got %#v, want %#v", got, tt.want)
 53			}
 54		})
 55	}
 56}
 57
 58func TestBuildEnv(t *testing.T) {
 59	t.Parallel()
 60
 61	const key = "KELD_TEST_BUILD_ENV"
 62	const val = "set-by-test"
 63
 64	env := buildEnv(map[string]string{key: val})
 65
 66	if !containsEntry(env, key+"="+val) {
 67		t.Fatalf("buildEnv() missing %s=%s in %#v", key, val, env)
 68	}
 69}
 70
 71func TestDryRunOutput(t *testing.T) {
 72	t.Parallel()
 73
 74	cfg := &config.ResolvedConfig{
 75		Command:      "backup",
 76		SectionsRead: []string{"global", "global.backup", "home", "home.backup"},
 77		Workdir:      "/tmp/work",
 78		Environ: map[string]string{
 79			"RESTIC_REPOSITORY": "/repos/home",
 80			"RESTIC_PASSWORD":   "secret",
 81		},
 82		Flags: []config.Flag{
 83			{Name: "--repo", Value: "/repos/home"},
 84			{Name: "--json"},
 85		},
 86		Arguments: []string{"/home/alice"},
 87	}
 88
 89	output := DryRun(cfg)
 90
 91	for _, fragment := range []string{
 92		"config sections: global → global.backup → home → home.backup",
 93		"workdir: /tmp/work",
 94		"environ:",
 95		"RESTIC_REPOSITORY=/repos/home",
 96		"RESTIC_PASSWORD=secret",
 97		"command: \"restic\" \"backup\" \"--repo\" \"/repos/home\" \"--json\" \"/home/alice\"",
 98	} {
 99		if !strings.Contains(output, fragment) {
100			t.Fatalf("DryRun() missing fragment %q in output:\n%s", fragment, output)
101		}
102	}
103}
104
105func TestDryRunExecutableOverride(t *testing.T) {
106	tmpDir := t.TempDir()
107	homeDir := filepath.Join(tmpDir, "home")
108	if err := os.MkdirAll(homeDir, 0o755); err != nil {
109		t.Fatalf("creating temp HOME: %v", err)
110	}
111
112	t.Setenv("HOME", homeDir)
113	t.Setenv("KELD_EXECUTABLE", "~/bin/restic-alt")
114
115	cfg := &config.ResolvedConfig{Command: "snapshots"}
116	output := DryRun(cfg)
117
118	expectedPrefix := `command: "` + filepath.Join(homeDir, "bin", "restic-alt") + `" "snapshots"`
119	if !strings.Contains(output, expectedPrefix) {
120		t.Fatalf("DryRun() command mismatch: want fragment %q in output %q", expectedPrefix, output)
121	}
122}
123
124func TestResolveEnvironCommands(t *testing.T) {
125	t.Parallel()
126
127	tests := []struct {
128		name      string
129		environ   map[string]string
130		want      map[string]string
131		wantError string
132	}{
133		{
134			name: "command stdout becomes env var value",
135			environ: map[string]string{
136				"AWS_ACCESS_KEY_ID_COMMAND": "echo my-key-id",
137			},
138			want: map[string]string{
139				"AWS_ACCESS_KEY_ID": "my-key-id",
140			},
141		},
142		{
143			name: "trailing newline stripped",
144			environ: map[string]string{
145				"MY_SECRET_COMMAND": "printf 'hello\\n\\n'",
146			},
147			want: map[string]string{
148				"MY_SECRET": "hello",
149			},
150		},
151		{
152			name: "multi-line output preserves internal newlines",
153			environ: map[string]string{
154				"MY_CERT_COMMAND": "printf 'line1\\nline2\\nline3\\n'",
155			},
156			want: map[string]string{
157				"MY_CERT": "line1\nline2\nline3",
158			},
159		},
160		{
161			name: "command takes precedence over literal value",
162			environ: map[string]string{
163				"MY_KEY":         "old-literal",
164				"MY_KEY_COMMAND": "echo from-command",
165			},
166			want: map[string]string{
167				"MY_KEY": "from-command",
168			},
169		},
170		{
171			name: "restic native RESTIC_PASSWORD_COMMAND passes through",
172			environ: map[string]string{
173				"RESTIC_PASSWORD_COMMAND": "op read 'op://Vault/Backup/password'",
174			},
175			want: map[string]string{
176				"RESTIC_PASSWORD_COMMAND": "op read 'op://Vault/Backup/password'",
177			},
178		},
179		{
180			name: "restic native RESTIC_FROM_PASSWORD_COMMAND passes through",
181			environ: map[string]string{
182				"RESTIC_FROM_PASSWORD_COMMAND": "pass show backup/source",
183			},
184			want: map[string]string{
185				"RESTIC_FROM_PASSWORD_COMMAND": "pass show backup/source",
186			},
187		},
188		{
189			name: "non-command keys left untouched",
190			environ: map[string]string{
191				"AWS_ACCESS_KEY_ID": "literal-key",
192				"RCLONE_BWLIMIT":    "1M",
193			},
194			want: map[string]string{
195				"AWS_ACCESS_KEY_ID": "literal-key",
196				"RCLONE_BWLIMIT":    "1M",
197			},
198		},
199		{
200			name: "mixed commands and literals",
201			environ: map[string]string{
202				"AWS_ACCESS_KEY_ID_COMMAND":     "echo resolved-id",
203				"AWS_SECRET_ACCESS_KEY_COMMAND": "echo resolved-secret",
204				"RCLONE_BWLIMIT":                "1M",
205				"RESTIC_PASSWORD_COMMAND":       "pass show backup",
206			},
207			want: map[string]string{
208				"AWS_ACCESS_KEY_ID":       "resolved-id",
209				"AWS_SECRET_ACCESS_KEY":   "resolved-secret",
210				"RCLONE_BWLIMIT":          "1M",
211				"RESTIC_PASSWORD_COMMAND": "pass show backup",
212			},
213		},
214		{
215			name: "command failure returns error",
216			environ: map[string]string{
217				"BAD_KEY_COMMAND": "false",
218			},
219			wantError: "environ BAD_KEY_COMMAND",
220		},
221		{
222			name: "command stderr included in error",
223			environ: map[string]string{
224				"BAD_KEY_COMMAND": "echo 'oh no' >&2; exit 1",
225			},
226			wantError: "oh no",
227		},
228		{
229			name:    "bare _COMMAND key with empty base is ignored",
230			environ: map[string]string{"_COMMAND": "echo nope"},
231			want:    map[string]string{"_COMMAND": "echo nope"},
232		},
233	}
234
235	for _, tt := range tests {
236		t.Run(tt.name, func(t *testing.T) {
237			t.Parallel()
238
239			err := resolveEnvironCommands(tt.environ, "")
240
241			if tt.wantError != "" {
242				if err == nil {
243					t.Fatalf("expected error containing %q, got nil", tt.wantError)
244				}
245				if !strings.Contains(err.Error(), tt.wantError) {
246					t.Fatalf("error %q does not contain %q", err.Error(), tt.wantError)
247				}
248				return
249			}
250
251			if err != nil {
252				t.Fatalf("unexpected error: %v", err)
253			}
254
255			if !reflect.DeepEqual(tt.environ, tt.want) {
256				t.Fatalf("environ mismatch:\n  got:  %v\n  want: %v", tt.environ, tt.want)
257			}
258		})
259	}
260}
261
262func TestDryRunPreservesCommands(t *testing.T) {
263	t.Parallel()
264
265	cfg := &config.ResolvedConfig{
266		Command: "backup",
267		Environ: map[string]string{
268			"AWS_ACCESS_KEY_ID_COMMAND": "echo test-key",
269			"RESTIC_PASSWORD_COMMAND":   "pass show backup",
270		},
271		Arguments: []string{"/home/alice"},
272	}
273
274	output := DryRun(cfg)
275
276	// DryRun must NOT resolve _COMMAND keys — that would leak secrets.
277	if !strings.Contains(output, "AWS_ACCESS_KEY_ID_COMMAND=echo test-key") {
278		t.Fatalf("DryRun() should preserve AWS_ACCESS_KEY_ID_COMMAND as-is:\n%s", output)
279	}
280
281	// Restic-native commands should also pass through unchanged.
282	if !strings.Contains(output, "RESTIC_PASSWORD_COMMAND=pass show backup") {
283		t.Fatalf("DryRun() should preserve RESTIC_PASSWORD_COMMAND:\n%s", output)
284	}
285}
286
287func containsEntry(values []string, needle string) bool {
288	for _, value := range values {
289		if value == needle {
290			return true
291		}
292	}
293	return false
294}