返回 DeepSeek-Reasonix
main_linux_test.go
根目录 / desktop / cmd / update-helper / main_linux_test.go
1 //go:build linux
2
3 package main
4
5 import (
6 "crypto/rand"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "aead.dev/minisign"
15 )
16
17 func TestSanitizeHelperErrorStripsPaths(t *testing.T) {
18 err := errors.New("/home/user/cache/foo.deb locked by /var/lib/dpkg/lock")
19 got := sanitizeHelperError(err)
20 if strings.Contains(got, "/home/") || strings.Contains(got, "/var/") {
21 t.Fatalf("path leaked: %q", got)
22 }
23 if !strings.Contains(got, "<path>") {
24 t.Fatalf("expected path redaction, got %q", got)
25 }
26 }
27
28 func TestIsPackageManagerBusy(t *testing.T) {
29 if !isPackageManagerBusy(errors.New("Could not get lock /var/lib/dpkg/lock-frontend")) {
30 t.Fatal("expected busy detection")
31 }
32 if isPackageManagerBusy(errors.New("dependency problems prevent configuration")) {
33 t.Fatal("dependency failure is not busy")
34 }
35 }
36
37 func TestAcceptDebIdentity(t *testing.T) {
38 cases := []struct {
39 name string
40 id debIdentity
41 goArch string
42 wantErr string
43 }{
44 {
45 name: "ok amd64",
46 id: debIdentity{Package: packageName, Version: "1.2.3", Arch: "amd64"},
47 goArch: "amd64",
48 },
49 {
50 name: "wrong package",
51 id: debIdentity{Package: "evil", Version: "1.2.3", Arch: "amd64"},
52 goArch: "amd64",
53 wantErr: "package name rejected",
54 },
55 {
56 name: "wrong arch",
57 id: debIdentity{Package: packageName, Version: "1.2.3", Arch: "arm64"},
58 goArch: "amd64",
59 wantErr: "package architecture rejected",
60 },
61 {
62 name: "arch all allowed",
63 id: debIdentity{Package: packageName, Version: "1.2.3", Arch: "all"},
64 goArch: "amd64",
65 },
66 {
67 name: "missing version",
68 id: debIdentity{Package: packageName, Version: "", Arch: "amd64"},
69 goArch: "amd64",
70 wantErr: "package version missing",
71 },
72 }
73 for _, tc := range cases {
74 t.Run(tc.name, func(t *testing.T) {
75 err := acceptDebIdentity(tc.id, tc.goArch)
76 if tc.wantErr == "" {
77 if err != nil {
78 t.Fatalf("unexpected err: %v", err)
79 }
80 return
81 }
82 if err == nil || err.Error() != tc.wantErr {
83 t.Fatalf("err = %v, want %q", err, tc.wantErr)
84 }
85 })
86 }
87 }
88
89 func TestAcceptVersionTransition(t *testing.T) {
90 tests := []struct {
91 name string
92 cmp int
93 candidate string
94 installed string
95 wantDowngrade bool
96 wantErr bool
97 }{
98 {name: "ordinary upgrade", cmp: 1, candidate: "1.3.0", installed: "1.2.0"},
99 {name: "equal rejected", cmp: 0, candidate: "1.2.0", installed: "1.2.0", wantErr: true},
100 {name: "preview to older stable", cmp: -1, candidate: "1.2.0", installed: "1.3.0~preview.1", wantDowngrade: true},
101 {name: "stable to older stable rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0", wantErr: true},
102 {name: "preview to older preview rejected", cmp: -1, candidate: "1.2.0~preview.1", installed: "1.3.0~preview.1", wantErr: true},
103 {name: "rc to stable rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0~rc.1", wantErr: true},
104 {name: "internal preview rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0~preview.1+internal", wantErr: true},
105 {name: "malformed stable rejected", cmp: -1, candidate: "01.2.0", installed: "1.3.0~preview.1", wantErr: true},
106 {name: "malformed preview rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0~preview.01", wantErr: true},
107 }
108 for _, tt := range tests {
109 t.Run(tt.name, func(t *testing.T) {
110 allowDowngrade, err := acceptVersionTransition(tt.cmp, tt.candidate, tt.installed)
111 if (err != nil) != tt.wantErr {
112 t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
113 }
114 if allowDowngrade != tt.wantDowngrade {
115 t.Fatalf("allowDowngrade = %v, want %v", allowDowngrade, tt.wantDowngrade)
116 }
117 })
118 }
119 }
120
121 func TestAptInstallArgvFixed(t *testing.T) {
122 argv := aptInstallArgv("/tmp/pkg.deb", false)
123 want := []string{
124 "/usr/bin/apt-get",
125 "install",
126 "--assume-yes",
127 "--only-upgrade",
128 "--no-remove",
129 "/tmp/pkg.deb",
130 }
131 if len(argv) != len(want) {
132 t.Fatalf("argv = %v", argv)
133 }
134 for i := range want {
135 if argv[i] != want[i] {
136 t.Fatalf("argv[%d] = %q, want %q", i, argv[i], want[i])
137 }
138 }
139 joined := strings.Join(argv, " ")
140 for _, bad := range []string{"--allow-unauthenticated", "--allow-downgrades", "--force-yes", "sh -c"} {
141 if strings.Contains(joined, bad) {
142 t.Fatalf("forbidden apt option present: %s in %v", bad, argv)
143 }
144 }
145
146 downgradeArgv := aptInstallArgv("/tmp/pkg.deb", true)
147 if strings.Count(strings.Join(downgradeArgv, " "), "--allow-downgrades") != 1 {
148 t.Fatalf("constrained downgrade argv = %v", downgradeArgv)
149 }
150 if downgradeArgv[len(downgradeArgv)-1] != "/tmp/pkg.deb" {
151 t.Fatalf("package path must remain the final fixed argument: %v", downgradeArgv)
152 }
153 }
154
155 func TestParsePhaseLine(t *testing.T) {
156 phase, ok := parsePhaseLine(phasePrefix + "installing")
157 if !ok || phase != "installing" {
158 t.Fatalf("got %q %v", phase, ok)
159 }
160 if _, ok := parsePhaseLine("noise"); ok {
161 t.Fatal("noise should not parse")
162 }
163 }
164
165 func TestCopyOwnedRegularFileRejectsSymlinkFailClosed(t *testing.T) {
166 dir := t.TempDir()
167 target := filepath.Join(dir, "real.deb")
168 if err := os.WriteFile(target, []byte("deb"), 0o600); err != nil {
169 t.Fatal(err)
170 }
171 link := filepath.Join(dir, "link.deb")
172 if err := os.Symlink(target, link); err != nil {
173 t.Fatal(err)
174 }
175 dst := filepath.Join(dir, "out.deb")
176 // Any UID — O_NOFOLLOW must fail before ownership is checked.
177 if err := copyOwnedRegularFile(link, dst, 0o600, os.Getuid(), maxInputBytes); err == nil {
178 t.Fatal("symlink must be rejected without fallback open")
179 }
180 }
181
182 func TestCopyOwnedRegularFileRejectsWrongOwner(t *testing.T) {
183 if os.Geteuid() == 0 {
184 t.Skip("running as root; cannot construct a non-self-owned file cheaply")
185 }
186 dir := t.TempDir()
187 src := filepath.Join(dir, "in.deb")
188 if err := os.WriteFile(src, []byte("package-bytes"), 0o600); err != nil {
189 t.Fatal(err)
190 }
191 dst := filepath.Join(dir, "out.deb")
192 // Claim the file must be owned by root while the test user owns it.
193 if err := copyOwnedRegularFile(src, dst, 0o600, 0, maxInputBytes); err == nil {
194 t.Fatal("wrong owner must be rejected")
195 }
196 }
197
198 func TestCopyOwnedRegularFileRejectsOversize(t *testing.T) {
199 dir := t.TempDir()
200 src := filepath.Join(dir, "in.deb")
201 if err := os.WriteFile(src, []byte("0123456789"), 0o600); err != nil {
202 t.Fatal(err)
203 }
204 dst := filepath.Join(dir, "out.deb")
205 if err := copyOwnedRegularFile(src, dst, 0o600, os.Getuid(), 4); err == nil {
206 t.Fatal("oversize input must be rejected")
207 }
208 }
209
210 func TestCopyOwnedRegularFileCopiesMatchingOwner(t *testing.T) {
211 dir := t.TempDir()
212 src := filepath.Join(dir, "in.deb")
213 payload := []byte("package-bytes")
214 if err := os.WriteFile(src, payload, 0o600); err != nil {
215 t.Fatal(err)
216 }
217 dst := filepath.Join(dir, "out.deb")
218 if err := copyOwnedRegularFile(src, dst, 0o600, os.Getuid(), maxInputBytes); err != nil {
219 t.Fatal(err)
220 }
221 got, err := os.ReadFile(dst)
222 if err != nil {
223 t.Fatal(err)
224 }
225 if string(got) != string(payload) {
226 t.Fatalf("copy mismatch: %q", got)
227 }
228 }
229
230 // --- runInstall table-driven coverage via injectable deps ---
231
232 type installProbe struct {
233 phases []string
234 results []helperResult
235 aptCalls []string
236 }
237
238 func baseFakeDeps(p *installProbe) installDeps {
239 return installDeps{
240 geteuid: func() int { return 0 },
241 getenv: func(k string) string {
242 if k == "PKEXEC_UID" {
243 return "1000"
244 }
245 return ""
246 },
247 mkTempDir: func() (string, error) {
248 return os.MkdirTemp("", "reasonix-helper-test-*")
249 },
250 removeAll: os.RemoveAll,
251 copyOwnedRegular: func(src, dst string, mode os.FileMode, ownerUID int, maxBytes int64) error {
252 data, err := os.ReadFile(src)
253 if err != nil {
254 return err
255 }
256 return os.WriteFile(dst, data, mode)
257 },
258 readFile: os.ReadFile,
259 verify: func(data, sig []byte) error { return nil },
260 inspectDeb: func(path string) (debIdentity, error) {
261 return debIdentity{Package: packageName, Version: "1.2.0", Arch: "amd64"}, nil
262 },
263 installedVersion: func() (string, error) { return "1.1.0", nil },
264 compareVersions: func(a, b string) (int, error) { return 1, nil },
265 aptInstall: func(pkgPath string, allowDowngrade bool) error {
266 p.aptCalls = append(p.aptCalls, pkgPath)
267 // Assert argv shape through the pure helper used by production.
268 argv := aptInstallArgv(pkgPath, allowDowngrade)
269 if argv[0] != aptGetPath || argv[1] != "install" {
270 return fmt.Errorf("bad argv: %v", argv)
271 }
272 return nil
273 },
274 verifyInstalled: func(want string) error {
275 if want != "1.2.0" {
276 return errors.New("installed version mismatch")
277 }
278 return nil
279 },
280 writePhase: func(phase string) { p.phases = append(p.phases, phase) },
281 writeResult: func(r helperResult) { p.results = append(p.results, r) },
282 goArch: "amd64",
283 maxInputBytes: maxInputBytes,
284 }
285 }
286
287 func writeInstallInputs(t *testing.T) (pkg, sig string) {
288 t.Helper()
289 dir := t.TempDir()
290 pkg = filepath.Join(dir, "Reasonix.deb")
291 sig = filepath.Join(dir, "Reasonix.deb.minisig")
292 if err := os.WriteFile(pkg, []byte("deb-payload"), 0o600); err != nil {
293 t.Fatal(err)
294 }
295 if err := os.WriteFile(sig, []byte("sig-payload"), 0o600); err != nil {
296 t.Fatal(err)
297 }
298 return pkg, sig
299 }
300
301 func TestRunInstallSuccessEmitsInstallingPhaseBeforeApt(t *testing.T) {
302 pkg, sig := writeInstallInputs(t)
303 var probe installProbe
304 d := baseFakeDeps(&probe)
305 aptStarted := false
306 d.aptInstall = func(pkgPath string, allowDowngrade bool) error {
307 aptStarted = true
308 if allowDowngrade {
309 t.Fatal("ordinary upgrade unexpectedly enabled downgrades")
310 }
311 if len(probe.phases) == 0 || probe.phases[0] != "installing" {
312 t.Fatalf("phase installing must be emitted before apt; phases=%v", probe.phases)
313 }
314 probe.aptCalls = append(probe.aptCalls, pkgPath)
315 return nil
316 }
317 code := runWith(d, []string{"install", "--package", pkg, "--signature", sig})
318 if code != exitOK {
319 t.Fatalf("exit = %d, results=%+v", code, probe.results)
320 }
321 if !aptStarted || len(probe.aptCalls) != 1 {
322 t.Fatalf("apt not called: %+v", probe.aptCalls)
323 }
324 if len(probe.results) != 1 || !probe.results[0].OK || probe.results[0].Version != "1.2.0" {
325 t.Fatalf("result = %+v", probe.results)
326 }
327 }
328
329 func TestRunInstallTable(t *testing.T) {
330 pkg, sig := writeInstallInputs(t)
331
332 type tc struct {
333 name string
334 mutate func(*installDeps, *installProbe)
335 args []string
336 wantCode int
337 wantCodeStr string
338 wantPhase bool // installing emitted only on success path past validation
339 wantOK bool
340 }
341 cases := []tc{
342 {
343 name: "usage missing flags",
344 args: []string{"install"},
345 wantCode: exitUsage,
346 wantCodeStr: "usage",
347 },
348 {
349 name: "not root",
350 mutate: func(d *installDeps, _ *installProbe) {
351 d.geteuid = func() int { return 1000 }
352 },
353 args: []string{"install", "--package", pkg, "--signature", sig},
354 wantCode: exitNotRoot,
355 wantCodeStr: "not_root",
356 },
357 {
358 name: "missing PKEXEC_UID",
359 mutate: func(d *installDeps, _ *installProbe) {
360 d.getenv = func(string) string { return "" }
361 },
362 args: []string{"install", "--package", pkg, "--signature", sig},
363 wantCode: exitNotRoot,
364 wantCodeStr: "not_root",
365 },
366 {
367 name: "bad input copy",
368 mutate: func(d *installDeps, _ *installProbe) {
369 d.copyOwnedRegular = func(string, string, os.FileMode, int, int64) error {
370 return errors.New("owner mismatch")
371 }
372 },
373 args: []string{"install", "--package", pkg, "--signature", sig},
374 wantCode: exitBadInput,
375 wantCodeStr: "bad_input",
376 },
377 {
378 name: "verify failed",
379 mutate: func(d *installDeps, _ *installProbe) {
380 d.verify = func([]byte, []byte) error { return errors.New("bad sig") }
381 },
382 args: []string{"install", "--package", pkg, "--signature", sig},
383 wantCode: exitVerifyFailed,
384 wantCodeStr: "verify_failed",
385 },
386 {
387 name: "wrong package name",
388 mutate: func(d *installDeps, _ *installProbe) {
389 d.inspectDeb = func(string) (debIdentity, error) {
390 return debIdentity{Package: "other", Version: "2.0", Arch: "amd64"}, nil
391 }
392 },
393 args: []string{"install", "--package", pkg, "--signature", sig},
394 wantCode: exitPackageRejected,
395 wantCodeStr: "package_rejected",
396 },
397 {
398 name: "wrong architecture",
399 mutate: func(d *installDeps, _ *installProbe) {
400 d.inspectDeb = func(string) (debIdentity, error) {
401 return debIdentity{Package: packageName, Version: "2.0", Arch: "arm64"}, nil
402 }
403 },
404 args: []string{"install", "--package", pkg, "--signature", sig},
405 wantCode: exitPackageRejected,
406 wantCodeStr: "package_rejected",
407 },
408 {
409 name: "same version rejected",
410 mutate: func(d *installDeps, _ *installProbe) {
411 d.compareVersions = func(a, b string) (int, error) { return 0, nil }
412 },
413 args: []string{"install", "--package", pkg, "--signature", sig},
414 wantCode: exitPackageRejected,
415 wantCodeStr: "package_rejected",
416 },
417 {
418 name: "downgrade rejected",
419 mutate: func(d *installDeps, _ *installProbe) {
420 d.compareVersions = func(a, b string) (int, error) { return -1, nil }
421 },
422 args: []string{"install", "--package", pkg, "--signature", sig},
423 wantCode: exitPackageRejected,
424 wantCodeStr: "package_rejected",
425 },
426 {
427 name: "preview to stable downgrade allowed",
428 mutate: func(d *installDeps, p *installProbe) {
429 d.inspectDeb = func(string) (debIdentity, error) {
430 return debIdentity{Package: packageName, Version: "1.2.0", Arch: "amd64"}, nil
431 }
432 d.installedVersion = func() (string, error) { return "1.3.0~preview.1", nil }
433 d.compareVersions = func(a, b string) (int, error) { return -1, nil }
434 d.aptInstall = func(pkgPath string, allowDowngrade bool) error {
435 if !allowDowngrade {
436 return errors.New("preview-to-stable downgrade flag missing")
437 }
438 p.aptCalls = append(p.aptCalls, pkgPath)
439 return nil
440 }
441 },
442 args: []string{"install", "--package", pkg, "--signature", sig},
443 wantCode: exitOK,
444 wantPhase: true,
445 wantOK: true,
446 },
447 {
448 name: "apt busy",
449 mutate: func(d *installDeps, _ *installProbe) {
450 d.aptInstall = func(string, bool) error {
451 return errors.New("Could not get lock /var/lib/dpkg/lock-frontend")
452 }
453 },
454 args: []string{"install", "--package", pkg, "--signature", sig},
455 wantCode: exitBusy,
456 wantCodeStr: "package_manager_busy",
457 wantPhase: true,
458 },
459 {
460 name: "apt non-zero",
461 mutate: func(d *installDeps, _ *installProbe) {
462 d.aptInstall = func(string, bool) error {
463 return errors.New("E: Sub-process /usr/bin/dpkg returned an error code (1)")
464 }
465 },
466 args: []string{"install", "--package", pkg, "--signature", sig},
467 wantCode: exitInstallFailed,
468 wantCodeStr: "install_failed",
469 wantPhase: true,
470 },
471 {
472 name: "post install version mismatch",
473 mutate: func(d *installDeps, _ *installProbe) {
474 d.verifyInstalled = func(string) error { return errors.New("installed version mismatch") }
475 },
476 args: []string{"install", "--package", pkg, "--signature", sig},
477 wantCode: exitPostVerify,
478 wantCodeStr: "package_verify_failed",
479 wantPhase: true,
480 },
481 }
482
483 for _, tc := range cases {
484 t.Run(tc.name, func(t *testing.T) {
485 var probe installProbe
486 d := baseFakeDeps(&probe)
487 if tc.mutate != nil {
488 tc.mutate(&d, &probe)
489 }
490 code := runWith(d, tc.args)
491 if code != tc.wantCode {
492 t.Fatalf("exit = %d, want %d; results=%+v", code, tc.wantCode, probe.results)
493 }
494 if len(probe.results) == 0 {
495 t.Fatal("expected a written result")
496 }
497 last := probe.results[len(probe.results)-1]
498 if last.OK != tc.wantOK {
499 t.Fatalf("result OK = %v, want %v: %+v", last.OK, tc.wantOK, last)
500 }
501 if tc.wantCodeStr != "" && last.Code != tc.wantCodeStr {
502 t.Fatalf("code = %q, want %q", last.Code, tc.wantCodeStr)
503 }
504 if tc.wantPhase {
505 if len(probe.phases) == 0 || probe.phases[0] != "installing" {
506 t.Fatalf("expected installing phase before failure after validation, got %v", probe.phases)
507 }
508 } else if len(probe.phases) > 0 {
509 // Validation failures must not claim apt started.
510 t.Fatalf("unexpected phase before validation failure: %v", probe.phases)
511 }
512 })
513 }
514 }
515
516 func TestRunInstallCleansTempDirOnFailure(t *testing.T) {
517 pkg, sig := writeInstallInputs(t)
518 var probe installProbe
519 d := baseFakeDeps(&probe)
520 var created, removed string
521 d.mkTempDir = func() (string, error) {
522 dir, err := os.MkdirTemp("", "reasonix-helper-test-*")
523 created = dir
524 return dir, err
525 }
526 d.removeAll = func(path string) error {
527 removed = path
528 return os.RemoveAll(path)
529 }
530 d.verify = func([]byte, []byte) error { return errors.New("fail after tmp") }
531 code := runWith(d, []string{"install", "--package", pkg, "--signature", sig})
532 if code != exitVerifyFailed {
533 t.Fatalf("exit = %d", code)
534 }
535 if created == "" || removed != created {
536 t.Fatalf("temp dir not cleaned: created=%q removed=%q", created, removed)
537 }
538 if _, err := os.Stat(created); !os.IsNotExist(err) {
539 t.Fatalf("temp dir still on disk: %v", err)
540 }
541 }
542
543 func TestRunInstallRejectsUnknownCommand(t *testing.T) {
544 var probe installProbe
545 d := baseFakeDeps(&probe)
546 code := runWith(d, []string{"upgrade"})
547 if code != exitUsage {
548 t.Fatalf("exit = %d", code)
549 }
550 }
551
552 // TestMinisignRejectsWrongKeyAndTamper pins that the shared verify path used by
553 // the helper rejects throwaway-key signatures and tampered payloads. Production
554 // uses update.Verify with the embedded key; here we exercise the same minisign
555 // library contract the helper depends on.
556 func TestMinisignRejectsWrongKeyAndTamper(t *testing.T) {
557 pub, priv, err := minisign.GenerateKey(rand.Reader)
558 if err != nil {
559 t.Fatal(err)
560 }
561 data := []byte("artifact")
562 sig := minisign.Sign(priv, data)
563 if !minisign.Verify(pub, data, sig) {
564 t.Fatal("genuine signature under matching key must verify")
565 }
566 if minisign.Verify(pub, []byte("tampered"), sig) {
567 t.Fatal("tampered payload must not verify")
568 }
569 otherPub, _, err := minisign.GenerateKey(rand.Reader)
570 if err != nil {
571 t.Fatal(err)
572 }
573 if minisign.Verify(otherPub, data, sig) {
574 t.Fatal("signature under a different public key must not verify")
575 }
576 // Garbage signature must fail under the matching public key.
577 if minisign.Verify(pub, data, []byte("not-a-sig")) {
578 t.Fatal("garbage signature must not verify")
579 }
580 // Wire the same contract into installDeps.verify so a wrong-key path fails
581 // runInstall with exitVerifyFailed.
582 var probe installProbe
583 d := baseFakeDeps(&probe)
584 d.verify = func(payload, signature []byte) error {
585 if !minisign.Verify(pub, payload, signature) {
586 return errors.New("signature verification failed")
587 }
588 return nil
589 }
590 pkg, sigPath := writeInstallInputs(t)
591 // Input files contain "deb-payload"/"sig-payload", not a real signature.
592 code := runWith(d, []string{"install", "--package", pkg, "--signature", sigPath})
593 if code != exitVerifyFailed {
594 t.Fatalf("exit = %d, want verify failed; results=%+v", code, probe.results)
595 }
596 }
597
597 lines GO