| 1 | //go:build darwin |
| 2 | |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "os" |
| 8 | "os/exec" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "syscall" |
| 12 | "testing" |
| 13 | |
| 14 | "reasonix/internal/repair" |
| 15 | ) |
| 16 | |
| 17 | func TestMacUpdateHandoffPublishesPayloadDigestAcrossModeChange(t *testing.T) { |
| 18 | probeA := filepath.Join(t.TempDir(), "marker") |
| 19 | probeB := filepath.Join(t.TempDir(), "marker") |
| 20 | for _, path := range []string{probeA, probeB} { |
| 21 | if err := os.WriteFile(path, []byte("probe"), 0o600); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | } |
| 25 | if err := os.Chmod(probeB, 0o700); err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | requireDistinctPOSIXMode(t, probeA, probeB) |
| 29 | |
| 30 | root := t.TempDir() |
| 31 | oldApp := filepath.Join(root, "Reasonix.app") |
| 32 | newApp := filepath.Join(root, "staging", "Reasonix.app") |
| 33 | backupApp := oldApp + ".reasonix-update-backup" |
| 34 | pending := filepath.Join(root, "pending.json") |
| 35 | for _, dir := range []string{oldApp, newApp} { |
| 36 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | } |
| 40 | if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | payload, err := repair.AppBundlePayloadTreeDigest(newApp) |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | strict, err := repair.AppBundleTreeDigest(newApp) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | if payload == strict { |
| 58 | t.Fatal("payload digest collapsed onto the strict digest") |
| 59 | } |
| 60 | tx := &repair.UpdateTransaction{ |
| 61 | ToVersion: "v2", |
| 62 | CreatedAt: "2026-07-28T00:00:00Z", |
| 63 | TargetKind: "app-bundle", |
| 64 | TargetPath: oldApp, |
| 65 | BackupPath: backupApp, |
| 66 | HandoffAppPath: newApp, |
| 67 | HandoffAppTreeID: payload, |
| 68 | HandoffStagingPath: filepath.Dir(newApp), |
| 69 | HandoffOwnerPID: 99999999, |
| 70 | } |
| 71 | installMacHandoffTestDeps(t, tx, pending, filepath.Join(root, "update.log"), nil) |
| 72 | originalCopy := macHandoffCopy |
| 73 | macHandoffCopy = func(oldPath, newPath string) error { |
| 74 | if err := originalCopy(oldPath, newPath); err != nil { |
| 75 | return err |
| 76 | } |
| 77 | copied := filepath.Join(newPath, "marker") |
| 78 | return os.Chmod(copied, 0o700) |
| 79 | } |
| 80 | originalOpen := openCommand |
| 81 | openCommand = func(args ...string) *exec.Cmd { |
| 82 | return exec.Command("/bin/sh", "-c", "exit 0") |
| 83 | } |
| 84 | t.Cleanup(func() { |
| 85 | macHandoffCopy = originalCopy |
| 86 | openCommand = originalOpen |
| 87 | }) |
| 88 | |
| 89 | if code := runMacUpdateHandoff(macHandoffConfigFor(tx)); code != 0 { |
| 90 | t.Fatal("handoff rejected a mode-only copy of a payload digest") |
| 91 | } |
| 92 | if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "new" { |
| 93 | t.Fatalf("installed marker = %q, %v", got, err) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func requireDistinctPOSIXMode(t *testing.T, a, b string) { |
| 98 | t.Helper() |
| 99 | modeA, err := os.Lstat(a) |
| 100 | if err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | modeB, err := os.Lstat(b) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | if modeA.Mode() == modeB.Mode() { |
| 108 | t.Skip("filesystem does not preserve POSIX mode bits") |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestMacUpdateRenameFallsBackWhenExclusiveUnsupported(t *testing.T) { |
| 113 | for _, unsupported := range []error{syscall.ENOTSUP, syscall.ENOSYS} { |
| 114 | t.Run(unsupported.Error(), func(t *testing.T) { |
| 115 | dir := t.TempDir() |
| 116 | source := filepath.Join(dir, "source") |
| 117 | destination := filepath.Join(dir, "destination") |
| 118 | if err := os.WriteFile(source, []byte("payload"), 0o600); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | if err := macRenameNoReplace(func(string, string) error { return unsupported }, source, destination); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | got, err := os.ReadFile(destination) |
| 125 | if err != nil || string(got) != "payload" { |
| 126 | t.Fatalf("destination = %q, %v", got, err) |
| 127 | } |
| 128 | if _, err := os.Lstat(source); !os.IsNotExist(err) { |
| 129 | t.Fatalf("source still exists: %v", err) |
| 130 | } |
| 131 | }) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | func TestMacUpdateRenameFallbackDoesNotReplaceExisting(t *testing.T) { |
| 136 | dir := t.TempDir() |
| 137 | source := filepath.Join(dir, "source") |
| 138 | destination := filepath.Join(dir, "destination") |
| 139 | if err := os.WriteFile(source, []byte("source"), 0o600); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | if err := os.WriteFile(destination, []byte("destination"), 0o600); err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | if err := macRenameNoReplace(func(string, string) error { return syscall.ENOTSUP }, source, destination); err == nil { |
| 146 | t.Fatal("fallback renamed over an existing destination") |
| 147 | } else if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "best-effort under Reasonix mutation lock") { |
| 148 | t.Fatalf("fallback err = %v, want ErrExist", err) |
| 149 | } |
| 150 | for path, want := range map[string]string{source: "source", destination: "destination"} { |
| 151 | got, err := os.ReadFile(path) |
| 152 | if err != nil || string(got) != want { |
| 153 | t.Fatalf("%s = %q, %v; want %q", filepath.Base(path), got, err, want) |
| 154 | } |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func TestMacUpdateRenameDoesNotFallbackOnOtherErrors(t *testing.T) { |
| 159 | dir := t.TempDir() |
| 160 | source := filepath.Join(dir, "source") |
| 161 | destination := filepath.Join(dir, "destination") |
| 162 | if err := os.WriteFile(source, []byte("payload"), 0o600); err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | err := macRenameNoReplace(func(string, string) error { return syscall.EPERM }, source, destination) |
| 166 | if !errors.Is(err, syscall.EPERM) { |
| 167 | t.Fatalf("err = %v, want EPERM", err) |
| 168 | } |
| 169 | got, readErr := os.ReadFile(source) |
| 170 | if readErr != nil || string(got) != "payload" { |
| 171 | t.Fatalf("source = %q, %v", got, readErr) |
| 172 | } |
| 173 | if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { |
| 174 | t.Fatalf("destination created after non-fallback error: %v", statErr) |
| 175 | } |
| 176 | } |
| 177 |