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}