| 1 | package repair |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "runtime" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/installlayout" |
| 12 | ) |
| 13 | |
| 14 | type supersededUpdateFixture struct { |
| 15 | home string |
| 16 | root string |
| 17 | target string |
| 18 | active string |
| 19 | backup string |
| 20 | pendingBody []byte |
| 21 | transaction *UpdateTransaction |
| 22 | running string |
| 23 | } |
| 24 | |
| 25 | type supersededAppUpdateFixture struct { |
| 26 | home string |
| 27 | target string |
| 28 | backup string |
| 29 | marker string |
| 30 | pendingBody []byte |
| 31 | transaction *UpdateTransaction |
| 32 | } |
| 33 | |
| 34 | func prepareSupersededAppUpdateFixture(t *testing.T, withBackup, withBackupIdentity bool) supersededAppUpdateFixture { |
| 35 | t.Helper() |
| 36 | home := t.TempDir() |
| 37 | t.Setenv("REASONIX_HOME", home) |
| 38 | root := t.TempDir() |
| 39 | if resolved, err := filepath.EvalSymlinks(root); err == nil { |
| 40 | root = resolved |
| 41 | } |
| 42 | target := filepath.Join(root, "Reasonix.app") |
| 43 | marker := filepath.Join(target, "Contents", "Resources", "release.txt") |
| 44 | executable := filepath.Join(target, "Contents", "MacOS", "reasonix-desktop") |
| 45 | for _, path := range []string{marker, executable} { |
| 46 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if err := os.WriteFile(path, []byte("healthy current app"), 0o700); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | } |
| 53 | backup := target + ".reasonix-update-backup" |
| 54 | if withBackup { |
| 55 | backupMarker := filepath.Join(backup, "Contents", "Resources", "release.txt") |
| 56 | if err := os.MkdirAll(filepath.Dir(backupMarker), 0o755); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | if err := os.WriteFile(backupMarker, []byte("preserved previous app"), 0o600); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | } |
| 63 | originalExecutable := repairExecutable |
| 64 | repairExecutable = func() (string, error) { return executable, nil } |
| 65 | t.Cleanup(func() { repairExecutable = originalExecutable }) |
| 66 | tx := &UpdateTransaction{ |
| 67 | SchemaVersion: updateTransactionVersion, |
| 68 | FromVersion: "v1.19.5", |
| 69 | ToVersion: "v1.19.7", |
| 70 | Platform: runtime.GOOS + "/amd64", |
| 71 | TargetKind: "app-bundle", |
| 72 | TargetPath: target, |
| 73 | BackupPath: backup, |
| 74 | CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), |
| 75 | } |
| 76 | if withBackupIdentity { |
| 77 | var err error |
| 78 | tx.BackupTreeID, err = repairPlanTreeContentStateID(backup) |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | } |
| 83 | if err := createPendingUpdate(tx); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | pendingBody, err := os.ReadFile(PendingUpdatePath()) |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | return supersededAppUpdateFixture{ |
| 91 | home: home, target: target, backup: backup, marker: marker, |
| 92 | pendingBody: pendingBody, transaction: tx, |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | func TestArchiveSupersededPendingAppBundleUpdatePreservesLegacyBackup(t *testing.T) { |
| 97 | fixture := prepareSupersededAppUpdateFixture(t, true, false) |
| 98 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5") |
| 99 | if err != nil || !archived { |
| 100 | t.Fatalf("archived=%v err=%v", archived, err) |
| 101 | } |
| 102 | if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) { |
| 103 | t.Fatalf("pending transaction survived: %v", err) |
| 104 | } |
| 105 | if _, err := os.Lstat(fixture.backup); !os.IsNotExist(err) { |
| 106 | t.Fatalf("public rollback path survived: %v", err) |
| 107 | } |
| 108 | backups, err := filepath.Glob(fixture.backup + ".reasonix-retired-*") |
| 109 | if err != nil || len(backups) != 1 { |
| 110 | t.Fatalf("archived backups=%v err=%v", backups, err) |
| 111 | } |
| 112 | if body, err := os.ReadFile(filepath.Join(backups[0], "Contents", "Resources", "release.txt")); err != nil || string(body) != "preserved previous app" { |
| 113 | t.Fatalf("archived backup body=%q err=%v", body, err) |
| 114 | } |
| 115 | entries, err := os.ReadDir(filepath.Join(fixture.home, "repair", "legacy-updates")) |
| 116 | if err != nil || len(entries) != 1 { |
| 117 | t.Fatalf("transaction archives=%v err=%v", entries, err) |
| 118 | } |
| 119 | if body, err := os.ReadFile(fixture.marker); err != nil || string(body) != "healthy current app" { |
| 120 | t.Fatalf("running app changed=%q err=%v", body, err) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestArchiveSupersededPendingAppBundleUpdateHealsMissingBackup(t *testing.T) { |
| 125 | fixture := prepareSupersededAppUpdateFixture(t, false, false) |
| 126 | fixture.transaction.BackupTreeID = strings.Repeat("a", 64) |
| 127 | if err := overwritePendingUpdateForTest(fixture.transaction); err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5") |
| 131 | if err != nil || !archived { |
| 132 | t.Fatalf("archived=%v err=%v", archived, err) |
| 133 | } |
| 134 | if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) { |
| 135 | t.Fatalf("missing-backup transaction survived: %v", err) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // recordHandoff gives tx the metadata PrepareAppBundleUpdateHandoff writes, so |
| 140 | // ReadPendingUpdate accepts it. |
| 141 | func recordHandoff(t *testing.T, tx *UpdateTransaction, ownerPID int, appTreeID string) { |
| 142 | t.Helper() |
| 143 | tx.HandoffOwnerPID = ownerPID |
| 144 | tx.HandoffStagingPath = filepath.Join(os.TempDir(), "reasonix-mac-update-legacy-test") |
| 145 | tx.HandoffAppPath = filepath.Join(tx.HandoffStagingPath, "Reasonix.app") |
| 146 | tx.HandoffAppTreeID = appTreeID |
| 147 | tx.HandoffStagingTreeID = strings.Repeat("c", 64) |
| 148 | if err := overwritePendingUpdateForTest(tx); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | if _, err := ReadPendingUpdate(); err != nil { |
| 152 | t.Fatalf("recorded handoff is not readable: %v", err) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | func TestArchiveSupersededPendingAppBundleUpdateLeavesModernRollbackForHealthCommit(t *testing.T) { |
| 157 | fixture := prepareSupersededAppUpdateFixture(t, true, true) |
| 158 | installed, err := repairPlanTreeContentStateID(fixture.target) |
| 159 | if err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | recordHandoff(t, fixture.transaction, 1<<30, installed) |
| 163 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.7") |
| 164 | if err != nil || archived { |
| 165 | t.Fatalf("modern transaction archived=%v err=%v", archived, err) |
| 166 | } |
| 167 | if _, err := ReadPendingUpdate(); err != nil { |
| 168 | t.Fatalf("modern pending transaction changed: %v", err) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestArchiveSupersededPendingAppBundleUpdateLeavesCurrentProcessHandoff(t *testing.T) { |
| 173 | fixture := prepareSupersededAppUpdateFixture(t, false, false) |
| 174 | recordHandoff(t, fixture.transaction, os.Getpid(), strings.Repeat("b", 64)) |
| 175 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5") |
| 176 | if err != nil || archived { |
| 177 | t.Fatalf("current handoff archived=%v err=%v", archived, err) |
| 178 | } |
| 179 | if _, err := ReadPendingUpdate(); err != nil { |
| 180 | t.Fatalf("current handoff transaction changed: %v", err) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // In host mode the recorded owner is the Electron parent, not the service |
| 185 | // process that runs the shutdown archival (#10732). |
| 186 | func TestArchiveSupersededPendingAppBundleUpdateLeavesHostParentHandoff(t *testing.T) { |
| 187 | fixture := prepareSupersededAppUpdateFixture(t, false, false) |
| 188 | recordHandoff(t, fixture.transaction, os.Getppid(), strings.Repeat("b", 64)) |
| 189 | eligible, err := validateSupersededPendingAppBundleUpdate(fixture.transaction, "v1.19.5") |
| 190 | if err != nil || eligible { |
| 191 | t.Fatalf("host-parent handoff eligible=%v err=%v", eligible, err) |
| 192 | } |
| 193 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5") |
| 194 | if err != nil || archived { |
| 195 | t.Fatalf("host-parent handoff archived=%v err=%v", archived, err) |
| 196 | } |
| 197 | if _, err := ReadPendingUpdate(); err != nil { |
| 198 | t.Fatalf("host-parent handoff transaction changed: %v", err) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | func TestArchiveSupersededPendingAppBundleUpdateRetiresUnverifiableBackupAtTarget(t *testing.T) { |
| 203 | fixture := prepareSupersededAppUpdateFixture(t, true, true) |
| 204 | if err := os.WriteFile(filepath.Join(fixture.backup, ".DS_Store"), []byte("finder"), 0o600); err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.7") |
| 208 | if err != nil || !archived { |
| 209 | t.Fatalf("archived=%v err=%v", archived, err) |
| 210 | } |
| 211 | if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) { |
| 212 | t.Fatalf("pending transaction survived: %v", err) |
| 213 | } |
| 214 | backups, err := filepath.Glob(fixture.backup + ".reasonix-retired-*") |
| 215 | if err != nil || len(backups) != 1 { |
| 216 | t.Fatalf("archived backups=%v err=%v", backups, err) |
| 217 | } |
| 218 | if _, err := os.Stat(filepath.Join(backups[0], ".DS_Store")); err != nil { |
| 219 | t.Fatalf("archived backup lost its contents: %v", err) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | func TestArchiveSupersededPendingAppBundleUpdateRetiresSurvivingBackupPastTarget(t *testing.T) { |
| 224 | prepareSupersededAppUpdateFixture(t, true, true) |
| 225 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.20.0") |
| 226 | if err != nil || !archived { |
| 227 | t.Fatalf("archived=%v err=%v", archived, err) |
| 228 | } |
| 229 | if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) { |
| 230 | t.Fatalf("pending transaction survived: %v", err) |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | func TestArchiveSupersededPendingAppBundleUpdateRejectsUnrelatedOlderVersion(t *testing.T) { |
| 235 | fixture := prepareSupersededAppUpdateFixture(t, true, false) |
| 236 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.4") |
| 237 | if err == nil || archived { |
| 238 | t.Fatalf("older running version archived=%v err=%v", archived, err) |
| 239 | } |
| 240 | assertPendingTransactionUnchanged(t, fixture.pendingBody) |
| 241 | } |
| 242 | |
| 243 | func TestArchiveSupersededPendingAppBundleUpdateRestoresStateOnTargetDrift(t *testing.T) { |
| 244 | fixture := prepareSupersededAppUpdateFixture(t, true, false) |
| 245 | originalHook := supersededAppUpdateAfterBackupArchive |
| 246 | supersededAppUpdateAfterBackupArchive = func(string) { |
| 247 | if err := os.WriteFile(fixture.marker, []byte("changed concurrently"), 0o600); err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | } |
| 251 | t.Cleanup(func() { supersededAppUpdateAfterBackupArchive = originalHook }) |
| 252 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5") |
| 253 | if err == nil || archived { |
| 254 | t.Fatalf("drifted target archived=%v err=%v", archived, err) |
| 255 | } |
| 256 | if _, err := ReadPendingUpdate(); err != nil { |
| 257 | t.Fatalf("pending transaction was not restored: %v", err) |
| 258 | } |
| 259 | if body, err := os.ReadFile(filepath.Join(fixture.backup, "Contents", "Resources", "release.txt")); err != nil || string(body) != "preserved previous app" { |
| 260 | t.Fatalf("rollback backup was not restored=%q err=%v", body, err) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func TestArchiveSupersededPendingAppBundleUpdatePreservesRecreatedBackupPath(t *testing.T) { |
| 265 | fixture := prepareSupersededAppUpdateFixture(t, true, false) |
| 266 | originalHook := supersededAppUpdateAfterBackupArchive |
| 267 | supersededAppUpdateAfterBackupArchive = func(string) { |
| 268 | marker := filepath.Join(fixture.backup, "Contents", "Resources", "release.txt") |
| 269 | if err := os.MkdirAll(filepath.Dir(marker), 0o755); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | if err := os.WriteFile(marker, []byte("concurrently recreated backup"), 0o600); err != nil { |
| 273 | t.Fatal(err) |
| 274 | } |
| 275 | } |
| 276 | t.Cleanup(func() { supersededAppUpdateAfterBackupArchive = originalHook }) |
| 277 | |
| 278 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5") |
| 279 | if err == nil || archived { |
| 280 | t.Fatalf("recreated backup archived=%v err=%v", archived, err) |
| 281 | } |
| 282 | if _, err := ReadPendingUpdate(); err != nil { |
| 283 | t.Fatalf("pending transaction was not restored: %v", err) |
| 284 | } |
| 285 | if body, err := os.ReadFile(filepath.Join(fixture.backup, "Contents", "Resources", "release.txt")); err != nil || string(body) != "concurrently recreated backup" { |
| 286 | t.Fatalf("recreated public backup changed=%q err=%v", body, err) |
| 287 | } |
| 288 | backups, err := filepath.Glob(fixture.backup + ".reasonix-retired-*") |
| 289 | if err != nil || len(backups) != 1 { |
| 290 | t.Fatalf("preserved archived backups=%v err=%v", backups, err) |
| 291 | } |
| 292 | if body, err := os.ReadFile(filepath.Join(backups[0], "Contents", "Resources", "release.txt")); err != nil || string(body) != "preserved previous app" { |
| 293 | t.Fatalf("archived original backup changed=%q err=%v", body, err) |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | func prepareSupersededUpdateFixture(t *testing.T, pendingVersion, runningVersion string) supersededUpdateFixture { |
| 298 | t.Helper() |
| 299 | home := t.TempDir() |
| 300 | t.Setenv("REASONIX_HOME", home) |
| 301 | root := t.TempDir() |
| 302 | originalExecutable := repairExecutable |
| 303 | t.Cleanup(func() { repairExecutable = originalExecutable }) |
| 304 | |
| 305 | target := filepath.Join(root, installlayout.DesktopBinaryName()) |
| 306 | if err := os.WriteFile(target, []byte("old desktop"), 0o700); err != nil { |
| 307 | t.Fatal(err) |
| 308 | } |
| 309 | guardName := "reasonix-guard" |
| 310 | if runtime.GOOS == "windows" { |
| 311 | guardName += ".exe" |
| 312 | } |
| 313 | guard := filepath.Join(root, guardName) |
| 314 | if err := os.WriteFile(guard, []byte("old guard"), 0o700); err != nil { |
| 315 | t.Fatal(err) |
| 316 | } |
| 317 | repairExecutable = func() (string, error) { return guard, nil } |
| 318 | tx, err := PrepareFileUpdate("v1.18.0", pendingVersion, target, guard) |
| 319 | if err != nil { |
| 320 | t.Fatal(err) |
| 321 | } |
| 322 | pendingBody, err := os.ReadFile(PendingUpdatePath()) |
| 323 | if err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | |
| 327 | runningVersion = canonicalSemver(runningVersion) |
| 328 | activeDir := filepath.Join(root, filepath.FromSlash(installlayout.VersionDirRelative(runningVersion))) |
| 329 | if err := os.MkdirAll(activeDir, 0o755); err != nil { |
| 330 | t.Fatal(err) |
| 331 | } |
| 332 | active := filepath.Join(activeDir, installlayout.DesktopBinaryName()) |
| 333 | if err := os.WriteFile(active, []byte("healthy versioned desktop"), 0o700); err != nil { |
| 334 | t.Fatal(err) |
| 335 | } |
| 336 | if err := installlayout.WriteCurrent(root, installlayout.CurrentPointer{ |
| 337 | ActiveVersion: runningVersion, |
| 338 | ActiveDir: installlayout.VersionDirRelative(runningVersion), |
| 339 | }); err != nil { |
| 340 | t.Fatal(err) |
| 341 | } |
| 342 | repairExecutable = func() (string, error) { return active, nil } |
| 343 | |
| 344 | return supersededUpdateFixture{ |
| 345 | home: home, root: root, target: target, active: active, |
| 346 | backup: tx.BackupPath, pendingBody: pendingBody, transaction: tx, |
| 347 | running: runningVersion, |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | func TestArchiveSupersededPendingFileUpdateHealsGuardDirectoryMismatch(t *testing.T) { |
| 352 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 353 | if _, err := ReadPendingUpdate(); err == nil || !strings.Contains(err.Error(), "outside the current Guard installation") { |
| 354 | t.Fatalf("ReadPendingUpdate error = %v, want the reported Guard-directory mismatch", err) |
| 355 | } |
| 356 | |
| 357 | archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root) |
| 358 | if err != nil || !archived { |
| 359 | t.Fatalf("archived=%v err=%v", archived, err) |
| 360 | } |
| 361 | if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) { |
| 362 | t.Fatalf("pending transaction survived: %v", err) |
| 363 | } |
| 364 | entries, err := os.ReadDir(filepath.Join(fixture.home, "repair", "legacy-updates")) |
| 365 | if err != nil || len(entries) != 1 { |
| 366 | t.Fatalf("archive entries=%v err=%v", entries, err) |
| 367 | } |
| 368 | archivedBody, err := os.ReadFile(filepath.Join(fixture.home, "repair", "legacy-updates", entries[0].Name())) |
| 369 | if err != nil || string(archivedBody) != string(fixture.pendingBody) { |
| 370 | t.Fatalf("archived transaction changed: err=%v\n got=%q\nwant=%q", err, archivedBody, fixture.pendingBody) |
| 371 | } |
| 372 | if body, err := os.ReadFile(fixture.backup); err != nil || string(body) != "old desktop" { |
| 373 | t.Fatalf("rollback backup=%q err=%v", body, err) |
| 374 | } |
| 375 | if len(fixture.transaction.Files) != 2 { |
| 376 | t.Fatalf("release-unit files=%d, want desktop and Guard", len(fixture.transaction.Files)) |
| 377 | } |
| 378 | if body, err := os.ReadFile(fixture.transaction.Files[1].BackupPath); err != nil || string(body) != "old guard" { |
| 379 | t.Fatalf("Guard rollback backup=%q err=%v", body, err) |
| 380 | } |
| 381 | if body, err := os.ReadFile(fixture.active); err != nil || string(body) != "healthy versioned desktop" { |
| 382 | t.Fatalf("active desktop=%q err=%v", body, err) |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | func TestArchiveSupersededPendingFileUpdateAcceptsSameVersionReinstall(t *testing.T) { |
| 387 | fixture := prepareSupersededUpdateFixture(t, "v1.19.3", "v1.19.3") |
| 388 | archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root) |
| 389 | if err != nil || !archived { |
| 390 | t.Fatalf("same-version healthy reinstall archived=%v err=%v", archived, err) |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | func TestArchiveSupersededPendingFileUpdateRejectsOlderRunningVersion(t *testing.T) { |
| 395 | fixture := prepareSupersededUpdateFixture(t, "v1.20.0", "v1.19.3") |
| 396 | if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root); err == nil || archived { |
| 397 | t.Fatalf("archived=%v err=%v", archived, err) |
| 398 | } |
| 399 | assertPendingTransactionUnchanged(t, fixture.pendingBody) |
| 400 | } |
| 401 | |
| 402 | func TestArchiveSupersededPendingFileUpdateRejectsForeignInstall(t *testing.T) { |
| 403 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 404 | if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, t.TempDir()); err == nil || archived { |
| 405 | t.Fatalf("archived=%v err=%v", archived, err) |
| 406 | } |
| 407 | assertPendingTransactionUnchanged(t, fixture.pendingBody) |
| 408 | } |
| 409 | |
| 410 | func TestArchiveSupersededPendingFileUpdateRejectsNonActiveExecutable(t *testing.T) { |
| 411 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 412 | other := filepath.Join(filepath.Dir(fixture.active), "other-desktop") |
| 413 | if err := os.WriteFile(other, []byte("other"), 0o700); err != nil { |
| 414 | t.Fatal(err) |
| 415 | } |
| 416 | repairExecutable = func() (string, error) { return other, nil } |
| 417 | if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root); err == nil || archived { |
| 418 | t.Fatalf("archived=%v err=%v", archived, err) |
| 419 | } |
| 420 | assertPendingTransactionUnchanged(t, fixture.pendingBody) |
| 421 | } |
| 422 | |
| 423 | func TestArchiveSupersededPendingFileUpdateRejectsPlatformMismatch(t *testing.T) { |
| 424 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 425 | changed := *fixture.transaction |
| 426 | changed.Platform = "foreign/amd64" |
| 427 | if err := overwritePendingUpdateForTest(&changed); err != nil { |
| 428 | t.Fatal(err) |
| 429 | } |
| 430 | changedBody, err := os.ReadFile(PendingUpdatePath()) |
| 431 | if err != nil { |
| 432 | t.Fatal(err) |
| 433 | } |
| 434 | if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root); err == nil || archived { |
| 435 | t.Fatalf("archived=%v err=%v", archived, err) |
| 436 | } |
| 437 | assertPendingTransactionUnchanged(t, changedBody) |
| 438 | } |
| 439 | |
| 440 | func TestArchiveSupersededPendingFileUpdateRejectsNestedLegacyTarget(t *testing.T) { |
| 441 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 442 | nestedDir := filepath.Join(fixture.root, "other") |
| 443 | if err := os.MkdirAll(nestedDir, 0o755); err != nil { |
| 444 | t.Fatal(err) |
| 445 | } |
| 446 | nestedTarget := filepath.Join(nestedDir, installlayout.DesktopBinaryName()) |
| 447 | changed := *fixture.transaction |
| 448 | changed.TargetPath = nestedTarget |
| 449 | changed.Files = append([]UpdateTransactionFile(nil), fixture.transaction.Files...) |
| 450 | for i := range changed.Files { |
| 451 | changed.Files[i].TargetPath = nestedTarget |
| 452 | } |
| 453 | if err := overwritePendingUpdateForTest(&changed); err != nil { |
| 454 | t.Fatal(err) |
| 455 | } |
| 456 | changedBody, err := os.ReadFile(PendingUpdatePath()) |
| 457 | if err != nil { |
| 458 | t.Fatal(err) |
| 459 | } |
| 460 | if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root); err == nil || archived { |
| 461 | t.Fatalf("archived=%v err=%v", archived, err) |
| 462 | } |
| 463 | assertPendingTransactionUnchanged(t, changedBody) |
| 464 | } |
| 465 | |
| 466 | func TestArchiveSupersededPendingFileUpdateDetectsConcurrentReplacement(t *testing.T) { |
| 467 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 468 | originalAcquire := acquirePendingUpdateLock |
| 469 | attempted := make(chan struct{}) |
| 470 | release := make(chan struct{}) |
| 471 | acquirePendingUpdateLock = func() (func(), error) { |
| 472 | close(attempted) |
| 473 | <-release |
| 474 | return func() {}, nil |
| 475 | } |
| 476 | t.Cleanup(func() { acquirePendingUpdateLock = originalAcquire }) |
| 477 | |
| 478 | type outcome struct { |
| 479 | archived bool |
| 480 | err error |
| 481 | } |
| 482 | done := make(chan outcome, 1) |
| 483 | go func() { |
| 484 | archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root) |
| 485 | done <- outcome{archived: archived, err: err} |
| 486 | }() |
| 487 | <-attempted |
| 488 | changed := *fixture.transaction |
| 489 | changed.CreatedAt = time.Now().UTC().Add(time.Second).Format(time.RFC3339Nano) |
| 490 | if err := overwritePendingUpdateForTest(&changed); err != nil { |
| 491 | t.Fatal(err) |
| 492 | } |
| 493 | close(release) |
| 494 | result := <-done |
| 495 | if result.err == nil || result.archived || !strings.Contains(result.err.Error(), "transaction changed while waiting") { |
| 496 | t.Fatalf("archived=%v err=%v", result.archived, result.err) |
| 497 | } |
| 498 | current, err := readPendingUpdateUnchecked() |
| 499 | if err != nil || UpdateTransactionID(current) != UpdateTransactionID(&changed) { |
| 500 | t.Fatalf("concurrent transaction was not preserved: current=%+v err=%v", current, err) |
| 501 | } |
| 502 | } |
| 503 | |
| 504 | func TestArchiveSupersededPendingFileUpdateRestoresLateConcurrentRewrite(t *testing.T) { |
| 505 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 506 | changed := *fixture.transaction |
| 507 | changed.CreatedAt = time.Now().UTC().Add(2 * time.Second).Format(time.RFC3339Nano) |
| 508 | originalHook := supersededUpdateBeforeArchive |
| 509 | supersededUpdateBeforeArchive = func(string) { |
| 510 | if err := overwritePendingUpdateForTest(&changed); err != nil { |
| 511 | t.Fatalf("rewrite pending transaction: %v", err) |
| 512 | } |
| 513 | } |
| 514 | t.Cleanup(func() { supersededUpdateBeforeArchive = originalHook }) |
| 515 | |
| 516 | archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root) |
| 517 | if err == nil || archived || !strings.Contains(err.Error(), "transaction changed before archival") { |
| 518 | t.Fatalf("archived=%v err=%v", archived, err) |
| 519 | } |
| 520 | current, readErr := readPendingUpdateUnchecked() |
| 521 | if readErr != nil || UpdateTransactionID(current) != UpdateTransactionID(&changed) { |
| 522 | t.Fatalf("late replacement was not restored: current=%+v err=%v", current, readErr) |
| 523 | } |
| 524 | entries, readDirErr := os.ReadDir(filepath.Join(fixture.home, "repair", "legacy-updates")) |
| 525 | if readDirErr != nil || len(entries) != 0 { |
| 526 | t.Fatalf("unexpected archive after restored rewrite: entries=%v err=%v", entries, readDirErr) |
| 527 | } |
| 528 | } |
| 529 | |
| 530 | func TestArchiveSupersededPendingFileUpdateSerializesConcurrentRecovery(t *testing.T) { |
| 531 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 532 | start := make(chan struct{}) |
| 533 | type outcome struct { |
| 534 | archived bool |
| 535 | err error |
| 536 | } |
| 537 | done := make(chan outcome, 2) |
| 538 | for range 2 { |
| 539 | go func() { |
| 540 | <-start |
| 541 | archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root) |
| 542 | done <- outcome{archived: archived, err: err} |
| 543 | }() |
| 544 | } |
| 545 | close(start) |
| 546 | archivedCount := 0 |
| 547 | for range 2 { |
| 548 | result := <-done |
| 549 | if result.err != nil { |
| 550 | t.Fatalf("concurrent recovery error: %v", result.err) |
| 551 | } |
| 552 | if result.archived { |
| 553 | archivedCount++ |
| 554 | } |
| 555 | } |
| 556 | if archivedCount != 1 { |
| 557 | t.Fatalf("archived count=%d, want exactly one", archivedCount) |
| 558 | } |
| 559 | entries, err := os.ReadDir(filepath.Join(fixture.home, "repair", "legacy-updates")) |
| 560 | if err != nil || len(entries) != 1 { |
| 561 | t.Fatalf("archive entries=%v err=%v", entries, err) |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | func TestArchiveSupersededPendingFileUpdateRejectsMalformedTransaction(t *testing.T) { |
| 566 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 567 | body := `{"schemaVersion":1,"toVersion":` |
| 568 | writePendingUpdateRaw(t, body) |
| 569 | if archived, err := ArchiveSupersededPendingFileUpdate("v1.20.0", t.TempDir()); err == nil || archived { |
| 570 | t.Fatalf("archived=%v err=%v", archived, err) |
| 571 | } |
| 572 | got, err := os.ReadFile(PendingUpdatePath()) |
| 573 | if err != nil || string(got) != body { |
| 574 | t.Fatalf("malformed transaction changed: got=%q err=%v", got, err) |
| 575 | } |
| 576 | } |
| 577 | |
| 578 | func assertPendingTransactionUnchanged(t *testing.T, want []byte) { |
| 579 | t.Helper() |
| 580 | got, err := os.ReadFile(PendingUpdatePath()) |
| 581 | if err != nil || string(got) != string(want) { |
| 582 | t.Fatalf("pending transaction changed: err=%v\n got=%q\nwant=%q", err, got, want) |
| 583 | } |
| 584 | } |
| 585 | |
| 586 | func TestSupersededUpdateFixtureUsesCurrentPlatform(t *testing.T) { |
| 587 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 588 | if !strings.HasPrefix(fixture.transaction.Platform, runtime.GOOS+"/") { |
| 589 | t.Fatalf("transaction platform=%q, want %s", fixture.transaction.Platform, runtime.GOOS) |
| 590 | } |
| 591 | } |
| 592 |