返回 DeepSeek-Reasonix
update_security_test.go
根目录 / internal / repair / update_security_test.go
1 package repair
2
3 import (
4 "os"
5 "path/filepath"
6 "runtime"
7 "strings"
8 "testing"
9 "time"
10 )
11
12 func TestPendingUpdateRejectsTargetOutsideGuardInstall(t *testing.T) {
13 home := t.TempDir()
14 t.Setenv("REASONIX_HOME", home)
15 guardDir := t.TempDir()
16 target := filepath.Join(t.TempDir(), "reasonix-desktop")
17 backup := filepath.Join(home, "repair", "updates", "reasonix-desktop.previous")
18 if err := os.MkdirAll(filepath.Dir(backup), 0o700); err != nil {
19 t.Fatal(err)
20 }
21 if err := os.WriteFile(backup, []byte("old"), 0o700); err != nil {
22 t.Fatal(err)
23 }
24 tx := &UpdateTransaction{
25 SchemaVersion: 1,
26 ToVersion: "v2",
27 Platform: runtime.GOOS + "/" + runtime.GOARCH,
28 TargetKind: "file",
29 TargetPath: target,
30 BackupPath: backup,
31 CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
32 }
33 if err := overwritePendingUpdateForTest(tx); err != nil {
34 t.Fatal(err)
35 }
36 originalExecutable := repairExecutable
37 repairExecutable = func() (string, error) { return filepath.Join(guardDir, "reasonix-guard"), nil }
38 t.Cleanup(func() { repairExecutable = originalExecutable })
39 if _, err := ReadPendingUpdate(); err == nil {
40 t.Fatal("pending update outside Guard install was accepted")
41 }
42 }
43
44 func TestInstalledUpdateStateRejectsSymlinkedParentEscape(t *testing.T) {
45 if runtime.GOOS == "windows" {
46 t.Skip("creating symlinks requires elevated privileges on Windows CI")
47 }
48 home := t.TempDir()
49 t.Setenv("REASONIX_HOME", home)
50 dir := t.TempDir()
51 target := filepath.Join(dir, "reasonix-desktop")
52 guard := filepath.Join(dir, "reasonix-guard")
53 originalExecutable := repairExecutable
54 repairExecutable = func() (string, error) { return guard, nil }
55 t.Cleanup(func() { repairExecutable = originalExecutable })
56 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
57 t.Fatal(err)
58 }
59 tx, err := PrepareFileUpdate("v1", "v2", target)
60 if err != nil {
61 t.Fatal(err)
62 }
63 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
64 t.Fatal(err)
65 }
66 pendingBefore, err := os.ReadFile(PendingUpdatePath())
67 if err != nil {
68 t.Fatal(err)
69 }
70
71 updatesDir := filepath.Dir(tx.BackupPath)
72 outside := filepath.Join(t.TempDir(), "moved-updates")
73 if err := os.Rename(updatesDir, outside); err != nil {
74 t.Fatal(err)
75 }
76 if err := os.Symlink(outside, updatesDir); err != nil {
77 t.Fatal(err)
78 }
79 sidecarOutside := filepath.Join(outside, filepath.Base(installedFileUpdateStatePath(tx)))
80 record := &installedFileUpdateState{
81 SchemaVersion: 1,
82 UpdateTransactionID: UpdateTransactionID(tx),
83 InstalledStateIDs: []string{repairPlanReleaseNodeState(target)},
84 }
85
86 if err := createInstalledFileUpdateState(tx, record); err == nil ||
87 !strings.Contains(err.Error(), "resolves outside the repair directory") {
88 t.Fatalf("record installed state through parent symlink = %v", err)
89 }
90 if _, err := os.Lstat(sidecarOutside); !os.IsNotExist(err) {
91 t.Fatalf("sidecar escaped the repair directory: %v", err)
92 }
93 if pendingAfter, err := os.ReadFile(PendingUpdatePath()); err != nil ||
94 string(pendingAfter) != string(pendingBefore) {
95 t.Fatalf("rejected sidecar write changed pending recovery state: %q, %v", pendingAfter, err)
96 }
97 }
98
99 func TestPendingUpdateRejectsUnexpectedReleaseFile(t *testing.T) {
100 home := t.TempDir()
101 t.Setenv("REASONIX_HOME", home)
102 dir := t.TempDir()
103 target := filepath.Join(dir, "reasonix-desktop")
104 backup := filepath.Join(home, "repair", "updates", "reasonix-desktop.previous")
105 if err := os.MkdirAll(filepath.Dir(backup), 0o700); err != nil {
106 t.Fatal(err)
107 }
108 if err := os.WriteFile(backup, []byte("old"), 0o700); err != nil {
109 t.Fatal(err)
110 }
111 originalExecutable := repairExecutable
112 repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
113 t.Cleanup(func() { repairExecutable = originalExecutable })
114 const hash = "deadbeef"
115 bad := []UpdateTransactionFile{
116 {TargetPath: filepath.Join(dir, "evil.exe"), BackupPath: backup, SHA256: hash},
117 {TargetPath: filepath.Join(t.TempDir(), "reasonix-guard"), BackupPath: backup, SHA256: hash},
118 {TargetPath: filepath.Join(dir, "reasonix-guard"), BackupPath: filepath.Join(t.TempDir(), "loose.previous"), SHA256: hash},
119 {TargetPath: filepath.Join(dir, "reasonix-guard"), BackupPath: backup}, // missing hash
120 {TargetPath: filepath.Join(dir, "reasonix-guard"), BackupPath: backup, SHA256: hash, MissingBefore: true},
121 {TargetPath: target, MissingBefore: true},
122 }
123 for _, file := range bad {
124 tx := &UpdateTransaction{
125 SchemaVersion: 1,
126 ToVersion: "v2",
127 Platform: runtime.GOOS + "/" + runtime.GOARCH,
128 TargetKind: "file",
129 TargetPath: target,
130 BackupPath: backup,
131 BackupSHA256: hash,
132 Files: []UpdateTransactionFile{{TargetPath: target, BackupPath: backup, SHA256: hash}, file},
133 CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
134 }
135 if err := overwritePendingUpdateForTest(tx); err != nil {
136 t.Fatal(err)
137 }
138 if _, err := ReadPendingUpdate(); err == nil {
139 t.Fatalf("release file entry %+v was accepted", file)
140 }
141 }
142 }
143
144 func TestPendingUpdateRejectsBackupSymlinkEscape(t *testing.T) {
145 if runtime.GOOS == "windows" {
146 t.Skip("creating symlinks requires elevated privileges on Windows CI")
147 }
148 home := t.TempDir()
149 t.Setenv("REASONIX_HOME", home)
150 dir, err := filepath.EvalSymlinks(t.TempDir())
151 if err != nil {
152 t.Fatal(err)
153 }
154 target := filepath.Join(dir, "reasonix-desktop")
155 guard := filepath.Join(dir, "reasonix-guard")
156 originalExecutable := repairExecutable
157 repairExecutable = func() (string, error) { return guard, nil }
158 t.Cleanup(func() { repairExecutable = originalExecutable })
159
160 repairDir := filepath.Join(home, "repair")
161 if err := os.MkdirAll(repairDir, 0o700); err != nil {
162 t.Fatal(err)
163 }
164 outside := t.TempDir()
165 if err := os.Symlink(outside, filepath.Join(repairDir, "updates")); err != nil {
166 t.Fatal(err)
167 }
168 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
169 t.Fatal(err)
170 }
171 if _, err := PrepareFileUpdate("v1", "v2", target); err == nil {
172 t.Fatal("prepare update wrote a backup through a symlink outside the repair directory")
173 }
174 backup := filepath.Join(repairDir, "updates", "reasonix-desktop.previous")
175 if err := os.WriteFile(backup, []byte("old"), 0o700); err != nil {
176 t.Fatal(err)
177 }
178 tx := &UpdateTransaction{
179 SchemaVersion: updateTransactionVersion,
180 ToVersion: "v2",
181 Platform: runtime.GOOS + "/" + runtime.GOARCH,
182 TargetKind: "file",
183 TargetPath: target,
184 BackupPath: backup,
185 BackupSHA256: "deadbeef",
186 CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
187 }
188 if err := overwritePendingUpdateForTest(tx); err != nil {
189 t.Fatal(err)
190 }
191 if _, err := ReadPendingUpdate(); err == nil {
192 t.Fatal("pending update accepted a backup that resolves outside the repair directory")
193 }
194 }
195
196 func TestPrepareFileUpdateRejectsSymlinkReleaseFile(t *testing.T) {
197 if runtime.GOOS == "windows" {
198 t.Skip("creating symlinks requires elevated privileges on Windows CI")
199 }
200 t.Setenv("REASONIX_HOME", t.TempDir())
201 dir, err := filepath.EvalSymlinks(t.TempDir())
202 if err != nil {
203 t.Fatal(err)
204 }
205 outside := filepath.Join(t.TempDir(), "outside-binary")
206 if err := os.WriteFile(outside, []byte("outside"), 0o700); err != nil {
207 t.Fatal(err)
208 }
209 target := filepath.Join(dir, "reasonix-desktop")
210 if err := os.Symlink(outside, target); err != nil {
211 t.Fatal(err)
212 }
213 if _, err := PrepareFileUpdate("v1", "v2", target); err == nil {
214 t.Fatal("prepare update accepted a symlinked release executable")
215 }
216 if _, err := os.Lstat(target); err != nil {
217 t.Fatalf("rejected release symlink was modified: %v", err)
218 }
219 if got, err := os.ReadFile(outside); err != nil || string(got) != "outside" {
220 t.Fatalf("rejected release symlink referent changed: %q, %v", got, err)
221 }
222 }
223
224 func TestCopyFileWithHashRejectsSymlinkSource(t *testing.T) {
225 if runtime.GOOS == "windows" {
226 t.Skip("creating symlinks requires elevated privileges on Windows CI")
227 }
228 dir := t.TempDir()
229 outside := filepath.Join(t.TempDir(), "outside")
230 if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil {
231 t.Fatal(err)
232 }
233 source := filepath.Join(dir, "source")
234 if err := os.Symlink(outside, source); err != nil {
235 t.Fatal(err)
236 }
237 if _, err := copyFileWithHashCreate(source, filepath.Join(dir, "backup"), 0o600); err == nil {
238 t.Fatal("copyFileWithHashCreate followed a symlink source")
239 }
240 if _, err := os.Lstat(filepath.Join(dir, "backup")); !os.IsNotExist(err) {
241 t.Fatalf("symlink source created a backup: %v", err)
242 }
243 }
244
245 func TestRenameRepairNodeNoReplacePreservesDestination(t *testing.T) {
246 dir := t.TempDir()
247 source := filepath.Join(dir, "source")
248 destination := filepath.Join(dir, "destination")
249 if err := os.WriteFile(source, []byte("source"), 0o600); err != nil {
250 t.Fatal(err)
251 }
252 if err := os.WriteFile(destination, []byte("destination"), 0o600); err != nil {
253 t.Fatal(err)
254 }
255 if err := renameRepairNodeNoReplace(source, destination); err == nil {
256 t.Fatal("no-replace rename overwrote an existing destination")
257 }
258 for path, want := range map[string]string{source: "source", destination: "destination"} {
259 got, err := os.ReadFile(path)
260 if err != nil || string(got) != want {
261 t.Fatalf("%s = %q, %v; want %q", filepath.Base(path), got, err, want)
262 }
263 }
264 }
265
266 func TestPendingUpdateAcceptsMissingReleaseSibling(t *testing.T) {
267 home := t.TempDir()
268 t.Setenv("REASONIX_HOME", home)
269 dir, err := filepath.EvalSymlinks(t.TempDir())
270 if err != nil {
271 t.Fatal(err)
272 }
273 target := filepath.Join(dir, "reasonix-desktop")
274 backup := filepath.Join(home, "repair", "updates", "reasonix-desktop.previous")
275 if err := os.MkdirAll(filepath.Dir(backup), 0o700); err != nil {
276 t.Fatal(err)
277 }
278 if err := os.WriteFile(backup, []byte("old"), 0o700); err != nil {
279 t.Fatal(err)
280 }
281 originalExecutable := repairExecutable
282 repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
283 t.Cleanup(func() { repairExecutable = originalExecutable })
284 tx := &UpdateTransaction{
285 SchemaVersion: 1,
286 ToVersion: "v2",
287 Platform: runtime.GOOS + "/" + runtime.GOARCH,
288 TargetKind: "file",
289 TargetPath: target,
290 BackupPath: backup,
291 BackupSHA256: "deadbeef",
292 Files: []UpdateTransactionFile{
293 {TargetPath: target, BackupPath: backup, SHA256: "deadbeef"},
294 {TargetPath: filepath.Join(dir, "Reasonix.exe"), MissingBefore: true},
295 },
296 CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
297 }
298 if err := overwritePendingUpdateForTest(tx); err != nil {
299 t.Fatal(err)
300 }
301 if _, err := ReadPendingUpdate(); err != nil {
302 t.Fatalf("valid missing release sibling was rejected: %v", err)
303 }
304 }
305
306 func TestPendingUpdateAcceptsWindowsReleaseUnit(t *testing.T) {
307 home := t.TempDir()
308 t.Setenv("REASONIX_HOME", home)
309 dir, err := filepath.EvalSymlinks(t.TempDir())
310 if err != nil {
311 t.Fatal(err)
312 }
313 originalExecutable := repairExecutable
314 repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-launcher.exe"), nil }
315 t.Cleanup(func() { repairExecutable = originalExecutable })
316
317 names := []string{
318 "reasonix-desktop.exe",
319 "reasonix-guard.exe",
320 "reasonix-launcher.exe",
321 "reasonix-update-helper.exe",
322 "reasonix-cli.exe",
323 "Reasonix.exe",
324 }
325 paths := make([]string, 0, len(names))
326 for _, name := range names {
327 path := filepath.Join(dir, name)
328 if err := os.WriteFile(path, []byte(name), 0o700); err != nil {
329 t.Fatal(err)
330 }
331 paths = append(paths, path)
332 }
333 if _, err := PrepareFileUpdate("v1", "v2", paths[0], paths[1:]...); err != nil {
334 t.Fatalf("prepare Windows release unit: %v", err)
335 }
336 tx, err := ReadPendingUpdate()
337 if err != nil {
338 t.Fatalf("read Windows release unit: %v", err)
339 }
340 if len(tx.Files) != len(names) {
341 t.Fatalf("release unit files = %d, want %d: %+v", len(tx.Files), len(names), tx.Files)
342 }
343 for i, file := range tx.Files {
344 if got := filepath.Base(file.TargetPath); got != names[i] {
345 t.Fatalf("release unit file %d = %q, want %q", i, got, names[i])
346 }
347 }
348 }
349
350 func TestPendingUpdateAcceptsLinuxReleaseUnit(t *testing.T) {
351 home := t.TempDir()
352 t.Setenv("REASONIX_HOME", home)
353 dir, err := filepath.EvalSymlinks(t.TempDir())
354 if err != nil {
355 t.Fatal(err)
356 }
357 originalExecutable := repairExecutable
358 repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
359 t.Cleanup(func() { repairExecutable = originalExecutable })
360
361 names := []string{"reasonix-desktop", "reasonix-guard", "reasonix"}
362 paths := make([]string, 0, len(names))
363 for _, name := range names {
364 path := filepath.Join(dir, name)
365 if err := os.WriteFile(path, []byte(name), 0o700); err != nil {
366 t.Fatal(err)
367 }
368 paths = append(paths, path)
369 }
370 if _, err := PrepareFileUpdate("v1", "v2", paths[0], paths[1:]...); err != nil {
371 t.Fatalf("prepare Linux release unit: %v", err)
372 }
373 tx, err := ReadPendingUpdate()
374 if err != nil {
375 t.Fatalf("read Linux release unit: %v", err)
376 }
377 if len(tx.Files) != len(names) {
378 t.Fatalf("release unit files = %d, want %d: %+v", len(tx.Files), len(names), tx.Files)
379 }
380 }
381
382 func TestPendingUpdateRejectsHashlessOrPrimaryLessTransactions(t *testing.T) {
383 home := t.TempDir()
384 t.Setenv("REASONIX_HOME", home)
385 dir, err := filepath.EvalSymlinks(t.TempDir())
386 if err != nil {
387 t.Fatal(err)
388 }
389 target := filepath.Join(dir, "reasonix-desktop")
390 backup := filepath.Join(home, "repair", "updates", "reasonix-desktop.previous")
391 if err := os.MkdirAll(filepath.Dir(backup), 0o700); err != nil {
392 t.Fatal(err)
393 }
394 if err := os.WriteFile(backup, []byte("old"), 0o700); err != nil {
395 t.Fatal(err)
396 }
397 originalExecutable := repairExecutable
398 repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
399 t.Cleanup(func() { repairExecutable = originalExecutable })
400 guardBackup := filepath.Join(home, "repair", "updates", "reasonix-guard.previous")
401 txs := map[string]*UpdateTransaction{
402 "missing primary hash": {
403 SchemaVersion: 1, ToVersion: "v2", TargetKind: "file",
404 TargetPath: target, BackupPath: backup,
405 Platform: runtime.GOOS + "/" + runtime.GOARCH, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
406 },
407 "release unit omits primary executable": {
408 SchemaVersion: 1, ToVersion: "v2", TargetKind: "file",
409 TargetPath: target, BackupPath: backup, BackupSHA256: "deadbeef",
410 Files: []UpdateTransactionFile{{TargetPath: filepath.Join(dir, "reasonix-guard"), BackupPath: guardBackup, SHA256: "deadbeef"}},
411 Platform: runtime.GOOS + "/" + runtime.GOARCH, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
412 },
413 }
414 for name, tx := range txs {
415 if err := overwritePendingUpdateForTest(tx); err != nil {
416 t.Fatal(err)
417 }
418 if _, err := ReadPendingUpdate(); err == nil {
419 t.Fatalf("%s: transaction was accepted", name)
420 }
421 }
422 }
423
424 func TestPendingUpdateRejectsPortableAliasAsPrimaryTarget(t *testing.T) {
425 home := t.TempDir()
426 t.Setenv("REASONIX_HOME", home)
427 dir := t.TempDir()
428 target := filepath.Join(dir, "Reasonix.exe")
429 guard := filepath.Join(dir, "reasonix-guard.exe")
430 backup := filepath.Join(home, "repair", "updates", "Reasonix.exe.previous")
431 if err := os.MkdirAll(filepath.Dir(backup), 0o700); err != nil {
432 t.Fatal(err)
433 }
434 if err := os.WriteFile(backup, []byte("launcher"), 0o700); err != nil {
435 t.Fatal(err)
436 }
437 originalExecutable := repairExecutable
438 repairExecutable = func() (string, error) { return guard, nil }
439 t.Cleanup(func() { repairExecutable = originalExecutable })
440 tx := &UpdateTransaction{
441 SchemaVersion: updateTransactionVersion,
442 ToVersion: "v2",
443 Platform: runtime.GOOS + "/" + runtime.GOARCH,
444 TargetKind: "file",
445 TargetPath: target,
446 BackupPath: backup,
447 BackupSHA256: "deadbeef",
448 Files: []UpdateTransactionFile{{
449 TargetPath: target,
450 BackupPath: backup,
451 SHA256: "deadbeef",
452 }},
453 CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
454 }
455 if err := overwritePendingUpdateForTest(tx); err != nil {
456 t.Fatal(err)
457 }
458 if _, err := ReadPendingUpdate(); err == nil ||
459 !strings.Contains(err.Error(), "not a Reasonix executable") {
460 t.Fatalf("portable alias was accepted as primary target: %v", err)
461 }
462 }
463
464 func TestPendingUpdateRejectsIncompleteOrInconsistentIdentity(t *testing.T) {
465 for _, tc := range []struct {
466 name string
467 mutate func(*UpdateTransaction)
468 want string
469 }{
470 {
471 name: "missing platform",
472 mutate: func(tx *UpdateTransaction) {
473 tx.Platform = ""
474 },
475 want: "transaction identity is incomplete",
476 },
477 {
478 name: "invalid creation identity",
479 mutate: func(tx *UpdateTransaction) {
480 tx.CreatedAt = "not-a-timestamp"
481 },
482 want: "creation identity is invalid",
483 },
484 {
485 name: "primary backup path mismatch",
486 mutate: func(tx *UpdateTransaction) {
487 tx.BackupPath = tx.Files[1].BackupPath
488 },
489 want: "primary backup metadata is inconsistent",
490 },
491 {
492 name: "primary backup hash mismatch",
493 mutate: func(tx *UpdateTransaction) {
494 tx.BackupSHA256 = "deadbeef"
495 },
496 want: "primary backup metadata is inconsistent",
497 },
498 {
499 name: "duplicate backup path",
500 mutate: func(tx *UpdateTransaction) {
501 tx.Files[1].BackupPath = tx.Files[0].BackupPath
502 },
503 want: "duplicate release backup",
504 },
505 } {
506 t.Run(tc.name, func(t *testing.T) {
507 t.Setenv("REASONIX_HOME", t.TempDir())
508 dir, err := filepath.EvalSymlinks(t.TempDir())
509 if err != nil {
510 t.Fatal(err)
511 }
512 target := filepath.Join(dir, "reasonix-desktop")
513 guard := filepath.Join(dir, "reasonix-guard")
514 originalExecutable := repairExecutable
515 repairExecutable = func() (string, error) { return guard, nil }
516 t.Cleanup(func() { repairExecutable = originalExecutable })
517 for path, body := range map[string]string{target: "old-desktop", guard: "old-guard"} {
518 if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
519 t.Fatal(err)
520 }
521 }
522 tx, err := PrepareFileUpdate("v1", "v2", target, guard)
523 if err != nil {
524 t.Fatal(err)
525 }
526 tc.mutate(tx)
527 if err := overwritePendingUpdateForTest(tx); err != nil {
528 t.Fatal(err)
529 }
530 if _, err := ReadPendingUpdate(); err == nil || !strings.Contains(err.Error(), tc.want) {
531 t.Fatalf("read error = %v, want %q", err, tc.want)
532 }
533 })
534 }
535 }
536
536 lines GO