返回 DeepSeek-Reasonix
update.go
根目录 / internal / repair / update.go
1 package repair
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log/slog"
12 "os"
13 "path/filepath"
14 "reflect"
15 "runtime"
16 "sort"
17 "strings"
18 "sync"
19 "time"
20
21 "reasonix/internal/config"
22 "reasonix/internal/fileutil"
23 filelock "reasonix/internal/identitylock"
24 )
25
26 const updateTransactionVersion = 1
27 const pendingUpdateLockTimeout = 5 * time.Second
28
29 var repairExecutable = os.Executable
30 var updateBackupAfterQuarantine = func(string, string) {}
31
32 type UpdateTransaction struct {
33 SchemaVersion int `json:"schemaVersion"`
34 FromVersion string `json:"fromVersion,omitempty"`
35 ToVersion string `json:"toVersion"`
36 Platform string `json:"platform"`
37 TargetKind string `json:"targetKind"` // file | app-bundle
38 TargetPath string `json:"targetPath"`
39 BackupPath string `json:"backupPath"`
40 BackupSHA256 string `json:"backupSha256,omitempty"`
41 // Files lists every binary of the release unit the update replaces
42 // (main executable first, then Guard/launcher siblings). Rollback must
43 // restore all of them together: restoring only the main binary would
44 // leave a mixed old-desktop/new-Guard install. Empty on transactions
45 // recorded by kinds that back up a single unit (macOS app bundles).
46 Files []UpdateTransactionFile `json:"files,omitempty"`
47 CreatedAt string `json:"createdAt"`
48 // Handoff fields authorize the detached macOS updater to act on paths
49 // recorded by the live desktop process. They are optional so pending
50 // transactions written by older releases remain readable.
51 HandoffAppPath string `json:"handoffAppPath,omitempty"`
52 HandoffStagingPath string `json:"handoffStagingPath,omitempty"`
53 HandoffAppTreeID string `json:"handoffAppTreeId,omitempty"`
54 HandoffStagingTreeID string `json:"handoffStagingTreeId,omitempty"`
55 HandoffOwnerPID int `json:"handoffOwnerPid,omitempty"`
56 // BackupTreeID binds a macOS rollback backup to the bundle captured before
57 // the update. It remains optional for legacy transactions.
58 BackupTreeID string `json:"backupTreeId,omitempty"`
59 // OrphanedBackupPath and OrphanedBackupTreeID bind a quarantined backup to
60 // the transaction that displaced it. Terminal transaction cleanup removes
61 // only this exact tree after re-verifying its digest; older transactions
62 // without these optional fields retain their existing behavior.
63 OrphanedBackupPath string `json:"orphanedBackupPath,omitempty"`
64 OrphanedBackupTreeID string `json:"orphanedBackupTreeId,omitempty"`
65 }
66
67 type UpdateTransactionFile struct {
68 TargetPath string `json:"targetPath"`
69 BackupPath string `json:"backupPath,omitempty"`
70 SHA256 string `json:"sha256,omitempty"`
71 InstalledStateID string `json:"installedStateId,omitempty"`
72 MissingBefore bool `json:"missingBefore,omitempty"`
73 }
74
75 type installedFileUpdateState struct {
76 SchemaVersion int `json:"schemaVersion"`
77 UpdateTransactionID string `json:"updateTransactionId"`
78 InstalledStateIDs []string `json:"installedStateIds"`
79 }
80
81 // FileUpdateInstallReceipt binds one published release-unit member to the exact
82 // transaction, target, node type, mode, and bytes that were staged and verified.
83 // RecordClaimedFileUpdateInstalled accepts only these receipts, so a replacement
84 // that appears after publish verification cannot be adopted by the transaction.
85 type FileUpdateInstallReceipt struct {
86 UpdateTransactionID string
87 TargetPath string
88 InstalledStateID string
89 }
90
91 type UpdateRollbackResult struct {
92 RolledBack bool `json:"rolledBack"`
93 FromVersion string `json:"fromVersion,omitempty"`
94 ToVersion string `json:"toVersion,omitempty"`
95 TargetPath string `json:"targetPath,omitempty"`
96 // MixedInstall reports that a failed rollback could not be compensated:
97 // the install now mixes binaries from two releases. Launchers must not
98 // start the desktop in this state.
99 MixedInstall bool `json:"mixedInstall,omitempty"`
100 }
101
102 // ErrPendingUpdateAwaitingHealth reports that the currently running release is
103 // still the probationary target of a prior update. Callers must not cancel or
104 // roll it back merely to start another update; the normal startup health
105 // confirmation owns that transition.
106 var ErrPendingUpdateAwaitingHealth = errors.New("previous update is awaiting startup health confirmation")
107
108 var errPendingUpdateForeignInstall = errors.New("pending update belongs to a different installation")
109
110 // pendingUpdateHealthStaleAfter bounds how long Reconcile waits for startup
111 // health before auto-committing a still-running probationary target.
112 var pendingUpdateHealthStaleAfter = 24 * time.Hour
113
114 // PendingUpdateReconcileResult describes the safe transition performed before
115 // startup or a new install. Cleared = pre-publish cancel; RolledBack = verified
116 // restore; Healthy = probationary target committed after install evidence.
117 type PendingUpdateReconcileResult struct {
118 Pending bool `json:"pending"`
119 Cleared bool `json:"cleared,omitempty"`
120 RolledBack bool `json:"rolledBack,omitempty"`
121 MixedInstall bool `json:"mixedInstall,omitempty"`
122 AwaitingHealth bool `json:"awaitingHealth,omitempty"`
123 Healthy bool `json:"healthy,omitempty"`
124 FromVersion string `json:"fromVersion,omitempty"`
125 ToVersion string `json:"toVersion,omitempty"`
126 TargetPath string `json:"targetPath,omitempty"`
127 }
128
129 // UpdateVersionsEqual reports whether two release version strings name the same
130 // release, normalizing an optional leading "v"/"V" prefix.
131 func UpdateVersionsEqual(a, b string) bool {
132 a = strings.TrimSpace(a)
133 b = strings.TrimSpace(b)
134 if a == "" || b == "" {
135 return false
136 }
137 if a == b {
138 return true
139 }
140 return normalizeUpdateVersion(a) == normalizeUpdateVersion(b)
141 }
142
143 func normalizeUpdateVersion(v string) string {
144 v = strings.TrimSpace(v)
145 if v == "" {
146 return ""
147 }
148 if !strings.HasPrefix(v, "v") && !strings.HasPrefix(v, "V") {
149 return "v" + v
150 }
151 return "v" + strings.TrimPrefix(strings.TrimPrefix(v, "v"), "V")
152 }
153
154 // pendingUpdateHealthIsStaleOverride forces the stale decision in tests without
155 // rewriting CreatedAt (part of transaction identity).
156 var pendingUpdateHealthIsStaleOverride func(*UpdateTransaction) bool
157
158 func pendingUpdateHealthIsStale(tx *UpdateTransaction) bool {
159 if tx == nil {
160 return false
161 }
162 if pendingUpdateHealthIsStaleOverride != nil {
163 return pendingUpdateHealthIsStaleOverride(tx)
164 }
165 if pendingUpdateHealthStaleAfter <= 0 {
166 return false
167 }
168 created, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(tx.CreatedAt))
169 if err != nil {
170 created, err = time.Parse(time.RFC3339, strings.TrimSpace(tx.CreatedAt))
171 }
172 if err != nil {
173 return false
174 }
175 return time.Since(created) >= pendingUpdateHealthStaleAfter
176 }
177
178 // UpdateTransactionID returns a stable, opaque identity for the complete
179 // transaction. Platform handoff processes use it so copied scalar fields such
180 // as version and creation time cannot authorize a rewritten pending update.
181 func UpdateTransactionID(tx *UpdateTransaction) string {
182 if tx == nil {
183 return ""
184 }
185 return repairPlanStateID(tx)
186 }
187
188 func PendingUpdatePath() string {
189 root := config.MemoryUserDir()
190 if root == "" {
191 return ""
192 }
193 return filepath.Join(root, "repair", "pending-update.json")
194 }
195
196 // lockPendingUpdateStrict serializes cross-process pending-update transitions:
197 // prepare, rollback, commit, and cancel. Two launchers can run recovery at
198 // once — a failed update makes startup slow, so a double-clicked Guard is
199 // realistic — and restoreReleaseUnit's fixed staging/aside paths assume a
200 // single restorer; unserialized, the loser's compensation can re-install the
201 // new binaries over the winner's completed rollback.
202 func lockPendingUpdateStrict() (func(), error) {
203 path := PendingUpdatePath()
204 if path == "" {
205 return nil, fmt.Errorf("pending update: Reasonix state directory is unavailable")
206 }
207 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
208 return nil, err
209 }
210 ctx, cancel := context.WithTimeout(context.Background(), pendingUpdateLockTimeout)
211 defer cancel()
212 unlock, err := filelock.Acquire(ctx, path+".lock")
213 if err != nil {
214 return nil, err
215 }
216 return unlock, nil
217 }
218
219 var acquirePendingUpdateLock = lockPendingUpdateStrict
220
221 // PrepareFileUpdate snapshots the current desktop executable — plus any sibling
222 // binaries of the release unit the installer also replaces (Guard, launcher,
223 // update helper) — and records an update transaction before an updater applies
224 // the replacement. Sibling paths that do not exist are recorded explicitly so
225 // rollback can remove files introduced by the replacement release.
226 func PrepareFileUpdate(fromVersion, toVersion, targetPath string, siblingPaths ...string) (*UpdateTransaction, error) {
227 targetPath = filepath.Clean(strings.TrimSpace(targetPath))
228 if targetPath == "" || targetPath == "." {
229 return nil, fmt.Errorf("prepare update: empty target path")
230 }
231 root := config.MemoryUserDir()
232 if root == "" {
233 return nil, fmt.Errorf("prepare update: Reasonix state directory is unavailable")
234 }
235 unlock, err := acquirePendingUpdateLock()
236 if err != nil {
237 return nil, fmt.Errorf("prepare update: lock pending transaction: %w", err)
238 }
239 defer unlock()
240 if err := ensureNoPendingUpdate(); err != nil {
241 return nil, err
242 }
243 // Hold the same target locks as rollback so prepare/snapshot cannot race
244 // a concurrent Guard restore of the release unit.
245 lockPaths := append([]string{targetPath}, siblingPaths...)
246 unlockTargets, lockErr := lockRepairMutations(lockPaths...)
247 if lockErr != nil {
248 return nil, fmt.Errorf("prepare update: lock targets: %w", lockErr)
249 }
250 defer unlockTargets()
251 backupDir := filepath.Join(root, "repair", "updates")
252 if err := os.MkdirAll(backupDir, 0o700); err != nil {
253 return nil, err
254 }
255 if !pathInsideResolvedRoot(filepath.Join(root, "repair"), backupDir) {
256 return nil, fmt.Errorf("prepare update: backup directory resolves outside the repair directory")
257 }
258 tx := &UpdateTransaction{
259 SchemaVersion: updateTransactionVersion,
260 FromVersion: fromVersion,
261 ToVersion: toVersion,
262 Platform: runtime.GOOS + "/" + runtime.GOARCH,
263 TargetKind: "file",
264 TargetPath: targetPath,
265 CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
266 }
267 seen := map[string]bool{}
268 for i, path := range append([]string{targetPath}, siblingPaths...) {
269 path = filepath.Clean(strings.TrimSpace(path))
270 key := canonicalRepairPath(path)
271 if path == "" || path == "." || key == "" || seen[key] {
272 continue
273 }
274 seen[key] = true
275 info, statErr := os.Lstat(path)
276 if statErr != nil {
277 if i > 0 && os.IsNotExist(statErr) {
278 tx.Files = append(tx.Files, UpdateTransactionFile{TargetPath: path, MissingBefore: true})
279 continue
280 }
281 return nil, fmt.Errorf("prepare update backup: %w", statErr)
282 }
283 if !info.Mode().IsRegular() {
284 return nil, fmt.Errorf("prepare update backup: release file %s is not a regular file", filepath.Base(path))
285 }
286 backupIdentity := repairPlanStateID(struct {
287 CreatedAt string `json:"createdAt"`
288 TargetPath string `json:"targetPath"`
289 Index int `json:"index"`
290 }{
291 CreatedAt: tx.CreatedAt,
292 TargetPath: canonicalRepairPath(path),
293 Index: i,
294 })
295 backupPath := filepath.Join(
296 backupDir,
297 fmt.Sprintf("%s.%s.previous", filepath.Base(path), backupIdentity[:16]),
298 )
299 hash, err := copyFileWithHashCreate(path, backupPath, 0o700)
300 if err != nil {
301 return nil, fmt.Errorf("prepare update backup: %w", err)
302 }
303 tx.Files = append(tx.Files, UpdateTransactionFile{TargetPath: path, BackupPath: backupPath, SHA256: hash})
304 if i == 0 {
305 tx.BackupPath = backupPath
306 tx.BackupSHA256 = hash
307 }
308 }
309 if err := verifyPreparedFileUpdateTargets(tx); err != nil {
310 return nil, fmt.Errorf("prepare update: %w", err)
311 }
312 if err := ensureNoPendingUpdate(); err != nil {
313 return nil, err
314 }
315 if err := createPendingUpdate(tx); err != nil {
316 return nil, err
317 }
318 return tx, nil
319 }
320
321 // PrepareAppBundleUpdate records the sibling bundle backup that the macOS
322 // handoff script creates. The script performs the directory move after exit.
323 func PrepareAppBundleUpdate(fromVersion, toVersion, appPath, backupPath string) (*UpdateTransaction, error) {
324 tx, err := newAppBundleUpdateTransaction(fromVersion, toVersion, appPath, backupPath)
325 if err != nil {
326 return nil, err
327 }
328 unlock, err := acquirePendingUpdateLock()
329 if err != nil {
330 return nil, fmt.Errorf("prepare update: lock pending transaction: %w", err)
331 }
332 defer unlock()
333 if err := ensureNoPendingUpdate(); err != nil {
334 return nil, err
335 }
336 unlockTargets, lockErr := lockRepairMutations(tx.TargetPath, tx.BackupPath)
337 if lockErr != nil {
338 return nil, fmt.Errorf("prepare update: lock targets: %w", lockErr)
339 }
340 defer unlockTargets()
341 tx.BackupTreeID, err = repairPlanTreeContentStateID(tx.TargetPath)
342 if err != nil {
343 return nil, fmt.Errorf("prepare update: current bundle digest: %w", err)
344 }
345 if err := ensureNoPendingUpdate(); err != nil {
346 return nil, err
347 }
348 if err := createPendingUpdate(tx); err != nil {
349 return nil, err
350 }
351 return tx, nil
352 }
353
354 // PrepareAppBundleUpdateHandoff records every path the detached macOS updater
355 // may mutate. The child receives only the transaction identity and must claim
356 // these recorded paths under the pending-update and mutation locks.
357 func PrepareAppBundleUpdateHandoff(fromVersion, toVersion, appPath, backupPath, stagedAppPath, stagingPath string, ownerPID int) (*UpdateTransaction, error) {
358 tx, err := newAppBundleUpdateTransaction(fromVersion, toVersion, appPath, backupPath)
359 if err != nil {
360 return nil, err
361 }
362 if !filepath.IsAbs(tx.TargetPath) {
363 return nil, fmt.Errorf("prepare update: invalid macOS bundle paths")
364 }
365 tx.HandoffAppPath = filepath.Clean(strings.TrimSpace(stagedAppPath))
366 tx.HandoffStagingPath = filepath.Clean(strings.TrimSpace(stagingPath))
367 tx.HandoffOwnerPID = ownerPID
368 if err := validateAppBundleHandoffMetadata(tx); err != nil {
369 return nil, fmt.Errorf("prepare update: %w", err)
370 }
371
372 unlock, err := acquirePendingUpdateLock()
373 if err != nil {
374 return nil, fmt.Errorf("prepare update: lock pending transaction: %w", err)
375 }
376 defer unlock()
377 if err := ensureNoPendingUpdate(); err != nil {
378 return nil, err
379 }
380 unlockTargets, err := lockRepairMutations(tx.TargetPath, tx.BackupPath)
381 if err != nil {
382 return nil, fmt.Errorf("prepare update: lock targets: %w", err)
383 }
384 defer unlockTargets()
385 tx.HandoffAppTreeID, err = repairPlanTreePayloadStateID(tx.HandoffAppPath)
386 if err != nil {
387 return nil, fmt.Errorf("prepare update: stage bundle digest: %w", err)
388 }
389 tx.HandoffStagingTreeID, err = repairPlanTreeContentStateID(tx.HandoffStagingPath)
390 if err != nil {
391 return nil, fmt.Errorf("prepare update: staging directory digest: %w", err)
392 }
393 tx.BackupTreeID, err = repairPlanTreeContentStateID(tx.TargetPath)
394 if err != nil {
395 return nil, fmt.Errorf("prepare update: current bundle digest: %w", err)
396 }
397 if err := VerifyAppBundleUpdateHandoffSource(tx); err != nil {
398 return nil, fmt.Errorf("prepare update: %w", err)
399 }
400 if err := VerifyAppBundleUpdateHandoffOriginal(tx); err != nil {
401 return nil, fmt.Errorf("prepare update: %w", err)
402 }
403 orphanedBackup, orphanedTreeID, err := quarantineExistingAppBundleUpdateBackup(tx)
404 if err != nil {
405 return nil, fmt.Errorf("prepare update: recover existing handoff backup: %w", err)
406 }
407 tx.OrphanedBackupPath = orphanedBackup
408 tx.OrphanedBackupTreeID = orphanedTreeID
409 // An uncooperative writer is not covered by Reasonix's mutation lock. Recheck
410 // the public path after quarantine so a recreated node is never adopted as the
411 // rollback backup of the new transaction.
412 if err := verifyAppBundleUpdateHandoffBackupAbsent(tx); err != nil {
413 return nil, fmt.Errorf("prepare update: %w", err)
414 }
415 if err := ensureNoPendingUpdate(); err != nil {
416 return nil, err
417 }
418 if err := createPendingUpdate(tx); err != nil {
419 return nil, err
420 }
421 return tx, nil
422 }
423
424 func newAppBundleUpdateTransaction(fromVersion, toVersion, appPath, backupPath string) (*UpdateTransaction, error) {
425 tx := &UpdateTransaction{
426 SchemaVersion: updateTransactionVersion,
427 FromVersion: fromVersion,
428 ToVersion: toVersion,
429 Platform: runtime.GOOS + "/" + runtime.GOARCH,
430 TargetKind: "app-bundle",
431 TargetPath: filepath.Clean(strings.TrimSpace(appPath)),
432 BackupPath: filepath.Clean(strings.TrimSpace(backupPath)),
433 CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
434 }
435 if !strings.HasSuffix(strings.ToLower(tx.TargetPath), ".app") ||
436 tx.BackupPath != tx.TargetPath+".reasonix-update-backup" {
437 return nil, fmt.Errorf("prepare update: invalid macOS bundle paths")
438 }
439 return tx, nil
440 }
441
442 // ClaimPendingAppBundleUpdateHandoff authorizes a detached child to perform the
443 // recorded bundle swap. It returns with both the pending transaction lock and
444 // the target mutation locks held; release must be called on every path.
445 func ClaimPendingAppBundleUpdateHandoff(expectedToVersion, expectedCreatedAt string, timeout time.Duration) (*UpdateTransaction, func(), error) {
446 tx, err := ReadPendingUpdate()
447 if err != nil {
448 return nil, nil, fmt.Errorf("claim update handoff: read pending transaction: %w", err)
449 }
450 if tx.TargetKind != "app-bundle" ||
451 strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) ||
452 strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) {
453 return nil, nil, fmt.Errorf("claim update handoff: pending transaction does not match")
454 }
455 return claimPendingAppBundleUpdateHandoff(
456 expectedToVersion,
457 expectedCreatedAt,
458 UpdateTransactionID(tx),
459 timeout,
460 )
461 }
462
463 // ClaimPendingAppBundleUpdateHandoffExact additionally binds the detached
464 // updater to the complete transaction prepared by the parent process.
465 func ClaimPendingAppBundleUpdateHandoffExact(
466 expectedToVersion, expectedCreatedAt, expectedTransactionID string,
467 timeout time.Duration,
468 ) (*UpdateTransaction, func(), error) {
469 expectedTransactionID = strings.TrimSpace(expectedTransactionID)
470 if expectedTransactionID == "" {
471 return nil, nil, fmt.Errorf("claim update handoff: transaction identity is incomplete")
472 }
473 return claimPendingAppBundleUpdateHandoff(
474 expectedToVersion,
475 expectedCreatedAt,
476 expectedTransactionID,
477 timeout,
478 )
479 }
480
481 func claimPendingAppBundleUpdateHandoff(
482 expectedToVersion, expectedCreatedAt, expectedTransactionID string,
483 timeout time.Duration,
484 ) (*UpdateTransaction, func(), error) {
485 expectedToVersion = strings.TrimSpace(expectedToVersion)
486 expectedCreatedAt = strings.TrimSpace(expectedCreatedAt)
487 if expectedToVersion == "" || expectedCreatedAt == "" {
488 return nil, nil, fmt.Errorf("claim update handoff: transaction identity is incomplete")
489 }
490 unlockPending, err := acquirePendingUpdateLock()
491 if err != nil {
492 return nil, nil, fmt.Errorf("claim update handoff: lock pending transaction: %w", err)
493 }
494 fail := func(err error) (*UpdateTransaction, func(), error) {
495 unlockPending()
496 return nil, nil, err
497 }
498
499 tx, err := ReadPendingUpdate()
500 if err != nil {
501 return fail(fmt.Errorf("claim update handoff: read pending transaction: %w", err))
502 }
503 if tx.TargetKind != "app-bundle" ||
504 strings.TrimSpace(tx.ToVersion) != expectedToVersion ||
505 strings.TrimSpace(tx.CreatedAt) != expectedCreatedAt {
506 return fail(fmt.Errorf("claim update handoff: pending transaction does not match"))
507 }
508 if expectedTransactionID != "" && UpdateTransactionID(tx) != expectedTransactionID {
509 return fail(fmt.Errorf("claim update handoff: pending transaction changed"))
510 }
511 if tx.Platform != runtime.GOOS+"/"+runtime.GOARCH {
512 return fail(fmt.Errorf("claim update handoff: pending transaction platform does not match"))
513 }
514 if err := validateAppBundleHandoffMetadata(tx); err != nil {
515 return fail(fmt.Errorf("claim update handoff: %w", err))
516 }
517 if strings.TrimSpace(tx.HandoffAppPath) == "" ||
518 strings.TrimSpace(tx.HandoffStagingPath) == "" ||
519 tx.HandoffOwnerPID <= 0 {
520 return fail(fmt.Errorf("claim update handoff: handoff metadata is missing"))
521 }
522
523 unlockTargets, err := lockRepairMutationsTimeout(timeout, pendingUpdateTargetPaths(tx)...)
524 if err != nil {
525 return fail(fmt.Errorf("claim update handoff: lock targets: %w", err))
526 }
527 current, err := ReadPendingUpdate()
528 if err != nil {
529 unlockTargets()
530 return fail(fmt.Errorf("claim update handoff: re-read pending transaction: %w", err))
531 }
532 if !reflect.DeepEqual(tx, current) {
533 unlockTargets()
534 return fail(fmt.Errorf("claim update handoff: pending transaction changed while waiting"))
535 }
536 if err := verifyAppBundleUpdateHandoffBackupAbsent(current); err != nil {
537 unlockTargets()
538 return fail(fmt.Errorf("claim update handoff: %w", err))
539 }
540 if err := VerifyAppBundleUpdateHandoffSource(current); err != nil {
541 unlockTargets()
542 return fail(fmt.Errorf("claim update handoff: %w", err))
543 }
544 if err := VerifyAppBundleUpdateHandoffOriginal(current); err != nil {
545 unlockTargets()
546 return fail(fmt.Errorf("claim update handoff: %w", err))
547 }
548
549 var once sync.Once
550 release := func() {
551 once.Do(func() {
552 unlockTargets()
553 unlockPending()
554 })
555 }
556 return current, release, nil
557 }
558
559 // ClaimPendingFileUpdate binds an updater's actual replacement window to the
560 // exact transaction and release-unit paths prepared by the desktop. The
561 // launcher path is explicit because the Windows helper runs from a cache
562 // directory rather than from the installation it is authorized to replace.
563 func ClaimPendingFileUpdate(
564 expectedToVersion, expectedCreatedAt, launcherPath string,
565 expectedTargetPaths []string,
566 timeout time.Duration,
567 ) (*UpdateTransaction, func(), error) {
568 tx, err := readPendingUpdateForLauncher(launcherPath)
569 if err != nil {
570 return nil, nil, fmt.Errorf("claim file update: read pending transaction: %w", err)
571 }
572 if tx.TargetKind != "file" ||
573 strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) ||
574 strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) {
575 return nil, nil, fmt.Errorf("claim file update: pending transaction does not match")
576 }
577 return claimPendingFileUpdate(
578 expectedToVersion,
579 expectedCreatedAt,
580 UpdateTransactionID(tx),
581 launcherPath,
582 expectedTargetPaths,
583 timeout,
584 )
585 }
586
587 // ClaimPendingFileUpdateExact additionally binds the updater to every field in
588 // the transaction prepared by the desktop process.
589 func ClaimPendingFileUpdateExact(
590 expectedToVersion, expectedCreatedAt, expectedTransactionID, launcherPath string,
591 expectedTargetPaths []string,
592 timeout time.Duration,
593 ) (*UpdateTransaction, func(), error) {
594 expectedTransactionID = strings.TrimSpace(expectedTransactionID)
595 if expectedTransactionID == "" {
596 return nil, nil, fmt.Errorf("claim file update: transaction identity is incomplete")
597 }
598 return claimPendingFileUpdate(
599 expectedToVersion,
600 expectedCreatedAt,
601 expectedTransactionID,
602 launcherPath,
603 expectedTargetPaths,
604 timeout,
605 )
606 }
607
608 func claimPendingFileUpdate(
609 expectedToVersion, expectedCreatedAt, expectedTransactionID, launcherPath string,
610 expectedTargetPaths []string,
611 timeout time.Duration,
612 ) (*UpdateTransaction, func(), error) {
613 expectedToVersion = strings.TrimSpace(expectedToVersion)
614 expectedCreatedAt = strings.TrimSpace(expectedCreatedAt)
615 launcherPath = filepath.Clean(strings.TrimSpace(launcherPath))
616 if expectedToVersion == "" || expectedCreatedAt == "" || launcherPath == "" || launcherPath == "." {
617 return nil, nil, fmt.Errorf("claim file update: transaction identity is incomplete")
618 }
619 if len(expectedTargetPaths) == 0 {
620 return nil, nil, fmt.Errorf("claim file update: release unit is empty")
621 }
622
623 unlockPending, err := acquirePendingUpdateLock()
624 if err != nil {
625 return nil, nil, fmt.Errorf("claim file update: lock pending transaction: %w", err)
626 }
627 fail := func(err error) (*UpdateTransaction, func(), error) {
628 unlockPending()
629 return nil, nil, err
630 }
631 tx, err := readPendingUpdateForLauncher(launcherPath)
632 if err != nil {
633 return fail(fmt.Errorf("claim file update: read pending transaction: %w", err))
634 }
635 if tx.TargetKind != "file" ||
636 strings.TrimSpace(tx.ToVersion) != expectedToVersion ||
637 strings.TrimSpace(tx.CreatedAt) != expectedCreatedAt {
638 return fail(fmt.Errorf("claim file update: pending transaction does not match"))
639 }
640 if expectedTransactionID != "" && UpdateTransactionID(tx) != expectedTransactionID {
641 return fail(fmt.Errorf("claim file update: pending transaction changed"))
642 }
643 if tx.Platform != runtime.GOOS+"/"+runtime.GOARCH {
644 return fail(fmt.Errorf("claim file update: pending transaction platform does not match"))
645 }
646 targetPaths := pendingUpdateTargetPaths(tx)
647 if !sameRepairMutationPaths(targetPaths, expectedTargetPaths) {
648 return fail(fmt.Errorf("claim file update: release unit does not match"))
649 }
650
651 unlockTargets, err := lockRepairMutationsTimeout(timeout, targetPaths...)
652 if err != nil {
653 return fail(fmt.Errorf("claim file update: lock targets: %w", err))
654 }
655 current, err := readPendingUpdateForLauncher(launcherPath)
656 if err != nil {
657 unlockTargets()
658 return fail(fmt.Errorf("claim file update: re-read pending transaction: %w", err))
659 }
660 if !reflect.DeepEqual(tx, current) {
661 unlockTargets()
662 return fail(fmt.Errorf("claim file update: pending transaction changed while waiting"))
663 }
664 if err := verifyPreparedFileUpdateTargets(current); err != nil {
665 unlockTargets()
666 return fail(fmt.Errorf("claim file update: %w", err))
667 }
668
669 var once sync.Once
670 release := func() {
671 once.Do(func() {
672 unlockTargets()
673 unlockPending()
674 })
675 }
676 return current, release, nil
677 }
678
679 // verifyPreparedFileUpdateTargets proves that the release unit still matches
680 // the exact files snapshotted by PrepareFileUpdate. Path and transaction
681 // identity alone are insufficient: another installer can replace the binaries
682 // between prepare and claim while leaving pending-update.json untouched.
683 func verifyPreparedFileUpdateTargets(tx *UpdateTransaction) error {
684 if err := verifyPreparedFileUpdateBackups(tx); err != nil {
685 return err
686 }
687 for _, f := range pendingUpdateFiles(tx) {
688 info, err := os.Lstat(f.TargetPath)
689 if f.MissingBefore {
690 if os.IsNotExist(err) {
691 continue
692 }
693 if err != nil {
694 return fmt.Errorf("inspect prepared release file %s: %w", filepath.Base(f.TargetPath), err)
695 }
696 return fmt.Errorf("prepared release file %s appeared after backup", filepath.Base(f.TargetPath))
697 }
698 if err != nil {
699 return fmt.Errorf("inspect prepared release file %s: %w", filepath.Base(f.TargetPath), err)
700 }
701 if !info.Mode().IsRegular() {
702 return fmt.Errorf("prepared release file %s changed type", filepath.Base(f.TargetPath))
703 }
704 got, err := hashFile(f.TargetPath)
705 if err != nil {
706 return fmt.Errorf("hash prepared release file %s: %w", filepath.Base(f.TargetPath), err)
707 }
708 if !strings.EqualFold(got, f.SHA256) {
709 return fmt.Errorf("prepared release file %s changed after backup", filepath.Base(f.TargetPath))
710 }
711 }
712 return nil
713 }
714
715 func verifyPreparedFileUpdateBackups(tx *UpdateTransaction) error {
716 for _, f := range pendingUpdateFiles(tx) {
717 if f.MissingBefore {
718 continue
719 }
720 backupInfo, err := os.Lstat(f.BackupPath)
721 if err != nil {
722 return fmt.Errorf("inspect prepared backup for %s: %w", filepath.Base(f.TargetPath), err)
723 }
724 if !backupInfo.Mode().IsRegular() {
725 return fmt.Errorf("prepared backup for %s changed type", filepath.Base(f.TargetPath))
726 }
727 backupHash, err := hashFile(f.BackupPath)
728 if err != nil {
729 return fmt.Errorf("hash prepared backup for %s: %w", filepath.Base(f.TargetPath), err)
730 }
731 if !strings.EqualFold(backupHash, f.SHA256) {
732 return fmt.Errorf("prepared backup for %s changed after backup", filepath.Base(f.TargetPath))
733 }
734 }
735 return nil
736 }
737
738 // PublishClaimedFileUpdateMember replaces one release-unit member without ever
739 // overwriting an unverified node. The platform updater must hold the claim
740 // returned by ClaimPendingFileUpdateExact for the whole release-unit operation.
741 // A concurrent recreation after the prepared node moves aside wins; the new
742 // bytes and the verified prior node remain staged for recovery.
743 func PublishClaimedFileUpdateMember(claimed *UpdateTransaction, targetPath string, content []byte, mode os.FileMode) error {
744 _, err := PublishClaimedFileUpdateMemberExact(claimed, targetPath, content, mode)
745 return err
746 }
747
748 // PublishClaimedFileUpdateMemberExact returns proof of the exact node it
749 // published. Callers must retain every receipt and pass them to
750 // RecordClaimedFileUpdateInstalled before releasing the update claim.
751 func PublishClaimedFileUpdateMemberExact(
752 claimed *UpdateTransaction,
753 targetPath string,
754 content []byte,
755 mode os.FileMode,
756 ) (FileUpdateInstallReceipt, error) {
757 if claimed == nil || claimed.TargetKind != "file" {
758 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: transaction identity is incomplete")
759 }
760 current, err := readPendingUpdateForLauncher(claimed.TargetPath)
761 if err != nil {
762 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: read pending transaction: %w", err)
763 }
764 if !reflect.DeepEqual(claimed, current) {
765 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: pending transaction changed")
766 }
767 targetPath = filepath.Clean(strings.TrimSpace(targetPath))
768 targetKey := canonicalRepairPath(targetPath)
769 var member *UpdateTransactionFile
770 for i := range current.Files {
771 if canonicalRepairPath(current.Files[i].TargetPath) == targetKey {
772 member = &current.Files[i]
773 break
774 }
775 }
776 if targetKey == "" || member == nil {
777 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: target is outside the claimed release unit")
778 }
779 preparedState := ""
780 if !member.MissingBefore {
781 if err := verifyUpdateFileMatchesPrepared(*member); err != nil {
782 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: %w", err)
783 }
784 if err := verifyUpdateBackupFile(*member); err != nil {
785 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: %w", err)
786 }
787 preparedState = repairPlanReleaseNodeState(member.TargetPath)
788 } else if _, err := os.Lstat(member.TargetPath); err == nil {
789 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: prepared release file %s appeared after backup", filepath.Base(member.TargetPath))
790 } else if !os.IsNotExist(err) {
791 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: inspect prepared release file %s: %w", filepath.Base(member.TargetPath), err)
792 }
793
794 stage, expectedHash, installedStateID, err := stageFileUpdateContent(member.TargetPath, content, mode)
795 if err != nil {
796 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: stage %s: %w", filepath.Base(member.TargetPath), err)
797 }
798 stagePublished := false
799 defer func() {
800 if !stagePublished {
801 _ = removeUpdateBackupFileMatching(stage, expectedHash)
802 }
803 }()
804
805 retained := ""
806 if !member.MissingBefore {
807 transactionID := UpdateTransactionID(claimed)
808 if len(transactionID) < 16 {
809 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: transaction identity is incomplete")
810 }
811 retained = member.TargetPath + ".reasonix-update-aside-" + transactionID[:16]
812 if err := renameRepairNodeNoReplace(member.TargetPath, retained); err != nil {
813 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: retain %s: %w", filepath.Base(member.TargetPath), err)
814 }
815 fileUpdateAfterRetain(member.TargetPath, retained)
816 if err := verifyRepairPlanReleaseNodeStateFor(retained, member.TargetPath, preparedState); err != nil {
817 if restoreErr := restoreRepairNodeIfAbsent(retained, member.TargetPath); restoreErr != nil {
818 return FileUpdateInstallReceipt{}, fmt.Errorf("%w; verified prior release file retained at %s: %w", err, retained, restoreErr)
819 }
820 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: prepared release file changed during retain: %w", err)
821 }
822 }
823 if err := renameRepairNodeNoReplace(stage, member.TargetPath); err != nil {
824 if retained != "" {
825 if restoreErr := restoreRepairNodeIfAbsent(retained, member.TargetPath); restoreErr != nil {
826 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: publish %s: %w; verified prior release file retained at %s: %w", filepath.Base(member.TargetPath), err, retained, restoreErr)
827 }
828 }
829 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: publish %s: %w", filepath.Base(member.TargetPath), err)
830 }
831 stagePublished = true
832 if err := verifyRepairPlanReleaseNodeStateFor(member.TargetPath, member.TargetPath, installedStateID); err != nil {
833 rejected, retainErr := moveRepairNodeToUniqueCleanup(member.TargetPath)
834 if retainErr != nil || rejected == "" {
835 if retainErr != nil {
836 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: installed %s changed: %w; retain rejected file: %w", filepath.Base(member.TargetPath), err, retainErr)
837 }
838 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: installed %s changed: %w; rejected file disappeared before compensation", filepath.Base(member.TargetPath), err)
839 }
840 if retained == "" {
841 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: installed %s changed: %w; rejected file retained at %s", filepath.Base(member.TargetPath), err, rejected)
842 }
843 if verifyErr := verifyRepairPlanReleaseNodeStateFor(retained, member.TargetPath, preparedState); verifyErr != nil {
844 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: installed %s changed: %w; rejected file retained at %s; prepared file changed at %s: %w", filepath.Base(member.TargetPath), err, rejected, retained, verifyErr)
845 }
846 if restoreErr := restoreRepairNodeIfAbsent(retained, member.TargetPath); restoreErr != nil {
847 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: installed %s changed: %w; rejected file retained at %s; restore prepared file: %w", filepath.Base(member.TargetPath), err, rejected, restoreErr)
848 }
849 return FileUpdateInstallReceipt{}, fmt.Errorf("publish file update: installed %s changed: %w; rejected file retained at %s and prepared file restored", filepath.Base(member.TargetPath), err, rejected)
850 }
851 if retained != "" {
852 _ = removeUpdateNodeMatching(retained, func(moved string) error {
853 return verifyRepairPlanReleaseNodeStateFor(moved, member.TargetPath, preparedState)
854 }, false)
855 }
856 return FileUpdateInstallReceipt{
857 UpdateTransactionID: UpdateTransactionID(current),
858 TargetPath: member.TargetPath,
859 InstalledStateID: installedStateID,
860 }, nil
861 }
862
863 func verifyUpdateFileMatchesPrepared(f UpdateTransactionFile) error {
864 info, err := os.Lstat(f.TargetPath)
865 if err != nil {
866 return fmt.Errorf("inspect prepared release file %s: %w", filepath.Base(f.TargetPath), err)
867 }
868 if !info.Mode().IsRegular() {
869 return fmt.Errorf("prepared release file %s changed type", filepath.Base(f.TargetPath))
870 }
871 if err := verifyRegularFileHash(f.TargetPath, f.SHA256); err != nil {
872 return fmt.Errorf("prepared release file %s changed after backup: %w", filepath.Base(f.TargetPath), err)
873 }
874 return nil
875 }
876
877 func verifyUpdateBackupFile(f UpdateTransactionFile) error {
878 info, err := os.Lstat(f.BackupPath)
879 if err != nil {
880 return fmt.Errorf("inspect prepared backup for %s: %w", filepath.Base(f.TargetPath), err)
881 }
882 if !info.Mode().IsRegular() {
883 return fmt.Errorf("prepared backup for %s changed type", filepath.Base(f.TargetPath))
884 }
885 if err := verifyRegularFileHash(f.BackupPath, f.SHA256); err != nil {
886 return fmt.Errorf("prepared backup for %s changed after backup: %w", filepath.Base(f.TargetPath), err)
887 }
888 return nil
889 }
890
891 func verifyRegularFileHash(path, expected string) error {
892 info, err := os.Lstat(path)
893 if err != nil {
894 return err
895 }
896 if !info.Mode().IsRegular() {
897 return fmt.Errorf("not a regular file")
898 }
899 actual, err := hashFile(path)
900 if err != nil {
901 return err
902 }
903 if !strings.EqualFold(actual, expected) {
904 return fmt.Errorf("hash mismatch")
905 }
906 return nil
907 }
908
909 func stageFileUpdateContent(targetPath string, content []byte, mode os.FileMode) (string, string, string, error) {
910 tmp, err := os.CreateTemp(filepath.Dir(targetPath), "."+filepath.Base(targetPath)+".reasonix-update-stage-*")
911 if err != nil {
912 return "", "", "", err
913 }
914 path := tmp.Name()
915 cleanup := func(err error) (string, string, string, error) {
916 _ = tmp.Close()
917 _ = os.Remove(path)
918 return "", "", "", err
919 }
920 if _, err := tmp.Write(content); err != nil {
921 return cleanup(err)
922 }
923 if err := tmp.Sync(); err != nil {
924 return cleanup(err)
925 }
926 if err := tmp.Chmod(mode); err != nil {
927 return cleanup(err)
928 }
929 info, err := tmp.Stat()
930 if err != nil {
931 return cleanup(err)
932 }
933 installedStateID := repairPlanReadStateIDFor(
934 targetPath,
935 info.Mode(),
936 "file",
937 "",
938 content,
939 true,
940 )
941 if err := tmp.Close(); err != nil {
942 _ = os.Remove(path)
943 return "", "", "", err
944 }
945 sum := sha256.Sum256(content)
946 return path, hex.EncodeToString(sum[:]), installedStateID, nil
947 }
948
949 // RecordClaimedFileUpdateInstalled binds the complete post-install release unit
950 // while the platform updater still holds the claim's pending and target locks.
951 // The binding is a transaction-unique create-only sidecar: pending-update.json
952 // stays immutable, so a process crash can never strand rollback state in the
953 // gap between displacing the old pending file and publishing a replacement.
954 func RecordClaimedFileUpdateInstalled(
955 claimed *UpdateTransaction,
956 receipts ...FileUpdateInstallReceipt,
957 ) (*UpdateTransaction, error) {
958 if claimed == nil || claimed.TargetKind != "file" {
959 return nil, fmt.Errorf("record installed update: transaction identity is incomplete")
960 }
961 current, err := readPendingUpdateForLauncher(claimed.TargetPath)
962 if err != nil {
963 return nil, fmt.Errorf("record installed update: read pending transaction: %w", err)
964 }
965 if !reflect.DeepEqual(claimed, current) {
966 return nil, fmt.Errorf("record installed update: pending transaction changed")
967 }
968 if len(current.Files) == 0 {
969 return nil, fmt.Errorf("record installed update: release unit is incomplete")
970 }
971 record := &installedFileUpdateState{
972 SchemaVersion: 1,
973 UpdateTransactionID: UpdateTransactionID(current),
974 InstalledStateIDs: make([]string, len(current.Files)),
975 }
976 receiptStates := make(map[string]string, len(receipts))
977 for _, receipt := range receipts {
978 if strings.TrimSpace(receipt.UpdateTransactionID) != record.UpdateTransactionID {
979 return nil, fmt.Errorf("record installed update: publish receipt belongs to a different transaction")
980 }
981 targetKey := canonicalRepairPath(receipt.TargetPath)
982 if targetKey == "" {
983 return nil, fmt.Errorf("record installed update: publish receipt target is invalid")
984 }
985 stateID := strings.TrimSpace(receipt.InstalledStateID)
986 if len(stateID) != sha256.Size*2 {
987 return nil, fmt.Errorf("record installed update: publish receipt state is invalid")
988 }
989 if _, err := hex.DecodeString(stateID); err != nil {
990 return nil, fmt.Errorf("record installed update: publish receipt state is invalid")
991 }
992 if _, exists := receiptStates[targetKey]; exists {
993 return nil, fmt.Errorf("record installed update: duplicate publish receipt")
994 }
995 receiptStates[targetKey] = stateID
996 }
997 for i := range current.Files {
998 f := &current.Files[i]
999 targetKey := canonicalRepairPath(f.TargetPath)
1000 if stateID, ok := receiptStates[targetKey]; ok {
1001 record.InstalledStateIDs[i] = stateID
1002 delete(receiptStates, targetKey)
1003 continue
1004 }
1005 info, statErr := os.Lstat(f.TargetPath)
1006 if statErr != nil {
1007 if os.IsNotExist(statErr) && f.MissingBefore {
1008 record.InstalledStateIDs[i] = repairPlanReleaseNodeState(f.TargetPath)
1009 continue
1010 }
1011 return nil, fmt.Errorf("record installed update: inspect %s: %w", filepath.Base(f.TargetPath), statErr)
1012 }
1013 if !info.Mode().IsRegular() {
1014 return nil, fmt.Errorf("record installed update: %s is not a regular file", filepath.Base(f.TargetPath))
1015 }
1016 return nil, fmt.Errorf("record installed update: publish receipt is missing for %s", filepath.Base(f.TargetPath))
1017 }
1018 if len(receiptStates) != 0 {
1019 return nil, fmt.Errorf("record installed update: publish receipt target is outside the release unit")
1020 }
1021 for i, f := range current.Files {
1022 if err := verifyRepairPlanReleaseNodeStateFor(f.TargetPath, f.TargetPath, record.InstalledStateIDs[i]); err != nil {
1023 return nil, fmt.Errorf("record installed update: release unit changed while recording: %w", err)
1024 }
1025 }
1026 if err := createInstalledFileUpdateState(current, record); err != nil {
1027 return nil, fmt.Errorf("record installed update: %w", err)
1028 }
1029 installedUpdateAfterCreate(installedFileUpdateStatePath(current))
1030 latest, err := readPendingUpdateForLauncher(claimed.TargetPath)
1031 if err != nil {
1032 return nil, fmt.Errorf("record installed update: re-read pending transaction: %w", err)
1033 }
1034 if !reflect.DeepEqual(current, latest) {
1035 return nil, fmt.Errorf("record installed update: pending transaction changed")
1036 }
1037 if _, _, err := installedFileUpdateTargets(latest, true); err != nil {
1038 return nil, fmt.Errorf("record installed update: %w", err)
1039 }
1040 return latest, nil
1041 }
1042
1043 func installedFileUpdateStatePath(tx *UpdateTransaction) string {
1044 if tx == nil {
1045 return ""
1046 }
1047 transactionID := UpdateTransactionID(tx)
1048 root := config.MemoryUserDir()
1049 if root == "" || len(transactionID) != sha256.Size*2 {
1050 return ""
1051 }
1052 return filepath.Join(root, "repair", "updates", transactionID+".installed.json")
1053 }
1054
1055 func createInstalledFileUpdateState(tx *UpdateTransaction, record *installedFileUpdateState) error {
1056 if err := validateInstalledFileUpdateState(tx, record); err != nil {
1057 return err
1058 }
1059 path := installedFileUpdateStatePath(tx)
1060 if path == "" {
1061 return fmt.Errorf("installed release-unit state path is unavailable")
1062 }
1063 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1064 return err
1065 }
1066 if !repairNodeInsideResolvedRoot(filepath.Join(config.MemoryUserDir(), "repair"), path) {
1067 return fmt.Errorf("installed release-unit state resolves outside the repair directory")
1068 }
1069 b, err := json.MarshalIndent(record, "", " ")
1070 if err != nil {
1071 return err
1072 }
1073 if err := fileutil.AtomicCreateFile(path, append(b, '\n'), 0o600); err == nil {
1074 return nil
1075 } else if !os.IsExist(err) {
1076 return err
1077 }
1078 existing, err := readInstalledFileUpdateState(tx)
1079 if err != nil {
1080 return err
1081 }
1082 if !reflect.DeepEqual(existing, record) {
1083 return fmt.Errorf("installed release-unit state already exists with different content")
1084 }
1085 return nil
1086 }
1087
1088 func readInstalledFileUpdateState(tx *UpdateTransaction) (*installedFileUpdateState, error) {
1089 path := installedFileUpdateStatePath(tx)
1090 if path == "" {
1091 return nil, fmt.Errorf("installed release-unit state path is unavailable")
1092 }
1093 info, err := os.Lstat(path)
1094 if err != nil {
1095 return nil, err
1096 }
1097 if !info.Mode().IsRegular() {
1098 return nil, fmt.Errorf("installed release-unit state is not a regular file")
1099 }
1100 if !repairNodeInsideResolvedRoot(filepath.Join(config.MemoryUserDir(), "repair"), path) {
1101 return nil, fmt.Errorf("installed release-unit state resolves outside the repair directory")
1102 }
1103 b, err := readRepairRegularFile(path)
1104 if err != nil {
1105 return nil, err
1106 }
1107 var record installedFileUpdateState
1108 if err := json.Unmarshal(b, &record); err != nil {
1109 return nil, err
1110 }
1111 if err := validateInstalledFileUpdateState(tx, &record); err != nil {
1112 return nil, err
1113 }
1114 return &record, nil
1115 }
1116
1117 func validateInstalledFileUpdateState(tx *UpdateTransaction, record *installedFileUpdateState) error {
1118 if tx == nil || tx.TargetKind != "file" || len(tx.Files) == 0 ||
1119 record == nil || record.SchemaVersion != 1 ||
1120 record.UpdateTransactionID != UpdateTransactionID(tx) ||
1121 len(record.InstalledStateIDs) != len(tx.Files) {
1122 return fmt.Errorf("installed release-unit state is incomplete")
1123 }
1124 for _, stateID := range record.InstalledStateIDs {
1125 stateID = strings.TrimSpace(stateID)
1126 if len(stateID) != sha256.Size*2 {
1127 return fmt.Errorf("installed release-unit state is invalid")
1128 }
1129 if _, err := hex.DecodeString(stateID); err != nil {
1130 return fmt.Errorf("installed release-unit state is invalid")
1131 }
1132 }
1133 return nil
1134 }
1135
1136 func installedFileUpdateTargets(
1137 tx *UpdateTransaction,
1138 requireBinding bool,
1139 ) ([]UpdateTransactionFile, bool, error) {
1140 if tx == nil || tx.TargetKind != "file" || len(tx.Files) == 0 {
1141 if requireBinding {
1142 return nil, false, fmt.Errorf("installed release-unit state is missing")
1143 }
1144 return pendingUpdateFiles(tx), false, nil
1145 }
1146 files := append([]UpdateTransactionFile(nil), tx.Files...)
1147 bound := 0
1148 for _, f := range files {
1149 if strings.TrimSpace(f.InstalledStateID) != "" {
1150 bound++
1151 }
1152 }
1153 if bound != 0 && bound != len(files) {
1154 return nil, false, fmt.Errorf("installed release-unit state is incomplete")
1155 }
1156 if bound == 0 {
1157 record, err := readInstalledFileUpdateState(tx)
1158 if err != nil {
1159 if os.IsNotExist(err) {
1160 if requireBinding {
1161 return nil, false, fmt.Errorf("installed release-unit state is missing")
1162 }
1163 return files, false, nil
1164 }
1165 return nil, false, err
1166 }
1167 for i := range files {
1168 files[i].InstalledStateID = record.InstalledStateIDs[i]
1169 }
1170 }
1171 for _, f := range files {
1172 if err := verifyRepairPlanReleaseNodeStateFor(f.TargetPath, f.TargetPath, f.InstalledStateID); err != nil {
1173 return nil, true, fmt.Errorf("installed release file %s changed: %w", filepath.Base(f.TargetPath), err)
1174 }
1175 }
1176 return files, true, nil
1177 }
1178
1179 func removeInstalledFileUpdateState(tx *UpdateTransaction) error {
1180 record, err := readInstalledFileUpdateState(tx)
1181 if err != nil {
1182 if os.IsNotExist(err) {
1183 return nil
1184 }
1185 return err
1186 }
1187 path := installedFileUpdateStatePath(tx)
1188 expectedState := repairPlanFileState(path)
1189 return removeUpdateNodeMatching(path, func(moved string) error {
1190 if err := verifyRepairPlanStateIDFor(moved, path, expectedState); err != nil {
1191 return err
1192 }
1193 b, err := os.ReadFile(moved)
1194 if err != nil {
1195 return err
1196 }
1197 var actual installedFileUpdateState
1198 if err := json.Unmarshal(b, &actual); err != nil {
1199 return err
1200 }
1201 if !reflect.DeepEqual(&actual, record) {
1202 return fmt.Errorf("installed release-unit state changed before cleanup")
1203 }
1204 return nil
1205 }, false)
1206 }
1207
1208 // CancelPendingAppBundleUpdateHandoff abandons an exact handoff only when the
1209 // original installed bundle is still the tree captured during prepare. This is
1210 // the safe recovery path when source verification fails after the desktop has
1211 // exited but before any bundle swap occurred.
1212 func CancelPendingAppBundleUpdateHandoff(
1213 expectedToVersion, expectedCreatedAt string,
1214 timeout time.Duration,
1215 ) (*UpdateTransaction, error) {
1216 tx, err := ReadPendingUpdate()
1217 if err != nil {
1218 return nil, fmt.Errorf("cancel update handoff: read pending transaction: %w", err)
1219 }
1220 if tx.TargetKind != "app-bundle" ||
1221 strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) ||
1222 strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) {
1223 return nil, fmt.Errorf("cancel update handoff: pending transaction does not match")
1224 }
1225 return cancelPendingAppBundleUpdateHandoff(
1226 expectedToVersion,
1227 expectedCreatedAt,
1228 timeout,
1229 UpdateTransactionID(tx),
1230 )
1231 }
1232
1233 // CancelPendingAppBundleUpdateHandoffExact abandons only the full transaction
1234 // read or prepared by the caller. It is safe to use after a PID wait or failed
1235 // claim where pending state may have been rewritten with copied scalar IDs.
1236 func CancelPendingAppBundleUpdateHandoffExact(
1237 expected *UpdateTransaction,
1238 timeout time.Duration,
1239 ) (*UpdateTransaction, error) {
1240 if expected == nil {
1241 return nil, fmt.Errorf("cancel update handoff: transaction identity is incomplete")
1242 }
1243 return cancelPendingAppBundleUpdateHandoff(
1244 expected.ToVersion,
1245 expected.CreatedAt,
1246 timeout,
1247 repairPlanStateID(expected),
1248 )
1249 }
1250
1251 func cancelPendingAppBundleUpdateHandoff(
1252 expectedToVersion, expectedCreatedAt string,
1253 timeout time.Duration,
1254 expectedTransactionID string,
1255 ) (*UpdateTransaction, error) {
1256 expectedToVersion = strings.TrimSpace(expectedToVersion)
1257 expectedCreatedAt = strings.TrimSpace(expectedCreatedAt)
1258 if expectedToVersion == "" || expectedCreatedAt == "" {
1259 return nil, fmt.Errorf("cancel update handoff: transaction identity is incomplete")
1260 }
1261 unlockPending, err := acquirePendingUpdateLock()
1262 if err != nil {
1263 return nil, fmt.Errorf("cancel update handoff: lock pending transaction: %w", err)
1264 }
1265 defer unlockPending()
1266 tx, err := ReadPendingUpdate()
1267 if err != nil {
1268 return nil, fmt.Errorf("cancel update handoff: read pending transaction: %w", err)
1269 }
1270 if tx.TargetKind != "app-bundle" ||
1271 strings.TrimSpace(tx.ToVersion) != expectedToVersion ||
1272 strings.TrimSpace(tx.CreatedAt) != expectedCreatedAt {
1273 return nil, fmt.Errorf("cancel update handoff: pending transaction does not match")
1274 }
1275 if expected := strings.TrimSpace(expectedTransactionID); expected != "" && expected != repairPlanStateID(tx) {
1276 return nil, fmt.Errorf("cancel update handoff: pending transaction changed")
1277 }
1278 unlockTargets, err := lockRepairMutationsTimeout(timeout, pendingUpdateTargetPaths(tx)...)
1279 if err != nil {
1280 return nil, fmt.Errorf("cancel update handoff: lock targets: %w", err)
1281 }
1282 defer unlockTargets()
1283 current, err := ReadPendingUpdate()
1284 if err != nil {
1285 return nil, fmt.Errorf("cancel update handoff: re-read pending transaction: %w", err)
1286 }
1287 if !reflect.DeepEqual(tx, current) {
1288 return nil, fmt.Errorf("cancel update handoff: pending transaction changed while waiting")
1289 }
1290 if err := VerifyAppBundleUpdateHandoffOriginal(current); err != nil {
1291 return nil, fmt.Errorf("cancel update handoff: %w", err)
1292 }
1293 if err := verifyAppBundleUpdateHandoffBackupAbsent(current); err != nil {
1294 return nil, fmt.Errorf("cancel update handoff: %w", err)
1295 }
1296 if err := removePendingUpdateExactVerified(current, func() error {
1297 if err := VerifyAppBundleUpdateHandoffOriginal(current); err != nil {
1298 return fmt.Errorf("cancel update handoff: %w", err)
1299 }
1300 if err := verifyAppBundleUpdateHandoffBackupAbsent(current); err != nil {
1301 return fmt.Errorf("cancel update handoff: %w", err)
1302 }
1303 return nil
1304 }); err != nil {
1305 return nil, err
1306 }
1307 return current, nil
1308 }
1309
1310 func sameRepairMutationPaths(a, b []string) bool {
1311 keys := func(paths []string) []string {
1312 seen := make(map[string]struct{}, len(paths))
1313 result := make([]string, 0, len(paths))
1314 for _, path := range paths {
1315 key := canonicalRepairPath(path)
1316 if key == "" {
1317 continue
1318 }
1319 if _, ok := seen[key]; ok {
1320 continue
1321 }
1322 seen[key] = struct{}{}
1323 result = append(result, key)
1324 }
1325 sort.Strings(result)
1326 return result
1327 }
1328 return reflect.DeepEqual(keys(a), keys(b))
1329 }
1330
1331 // VerifyAppBundleUpdateHandoffSource checks the real staging containment and
1332 // the complete staged tree immediately before a handoff mutates the install.
1333 // The lexical metadata check remains readable after staging cleanup, while this
1334 // stronger check is only used while the source bundle still exists.
1335 func VerifyAppBundleUpdateHandoffSource(tx *UpdateTransaction) error {
1336 if tx == nil || tx.TargetKind != "app-bundle" {
1337 return fmt.Errorf("handoff source transaction is invalid")
1338 }
1339 if strings.TrimSpace(tx.HandoffAppTreeID) == "" {
1340 return fmt.Errorf("handoff source digest is missing")
1341 }
1342 if strings.TrimSpace(tx.HandoffStagingTreeID) == "" {
1343 return fmt.Errorf("handoff staging digest is missing")
1344 }
1345 if err := validateAppBundleHandoffSourcePaths(tx); err != nil {
1346 return err
1347 }
1348 matched, err := repairPlanTreeHandoffAppMatches(tx.HandoffAppPath, tx.HandoffAppTreeID)
1349 if err != nil {
1350 return fmt.Errorf("read staged bundle digest: %w", err)
1351 }
1352 if !matched {
1353 return fmt.Errorf("staged bundle changed after verification")
1354 }
1355 actual, err := repairPlanTreeContentStateID(tx.HandoffStagingPath)
1356 if err != nil {
1357 return fmt.Errorf("read staging directory digest: %w", err)
1358 }
1359 if actual != tx.HandoffStagingTreeID {
1360 return fmt.Errorf("staging directory changed after verification")
1361 }
1362 return nil
1363 }
1364
1365 // CleanupAppBundleUpdateHandoffStaging removes only the complete staging tree
1366 // recorded by the transaction. The root is first displaced to a unique sibling,
1367 // so a concurrent recreation at the public staging path survives.
1368 func CleanupAppBundleUpdateHandoffStaging(tx *UpdateTransaction) error {
1369 if tx == nil || strings.TrimSpace(tx.HandoffStagingTreeID) == "" {
1370 return fmt.Errorf("cleanup update staging: transaction identity is incomplete")
1371 }
1372 if err := validateAppBundleHandoffMetadata(tx); err != nil {
1373 return fmt.Errorf("cleanup update staging: %w", err)
1374 }
1375 relApp, err := filepath.Rel(tx.HandoffStagingPath, tx.HandoffAppPath)
1376 if err != nil || relApp == "." || relApp == ".." || strings.HasPrefix(relApp, ".."+string(filepath.Separator)) {
1377 return fmt.Errorf("cleanup update staging: app path is invalid")
1378 }
1379 return removeUpdateNodeMatching(tx.HandoffStagingPath, func(moved string) error {
1380 actual, err := repairPlanTreeContentStateID(moved)
1381 if err != nil {
1382 return err
1383 }
1384 if actual != tx.HandoffStagingTreeID {
1385 return fmt.Errorf("staging directory changed before cleanup")
1386 }
1387 return verifyAppBundleUpdateHandoffReplacement(
1388 tx,
1389 filepath.Join(moved, relApp),
1390 "staged",
1391 )
1392 }, true)
1393 }
1394
1395 // CleanupAppBundleUpdateReplacement removes a displaced replacement only when
1396 // its complete tree still matches the transaction's verified source.
1397 func CleanupAppBundleUpdateReplacement(tx *UpdateTransaction, path string) error {
1398 return removeUpdateNodeMatching(path, func(moved string) error {
1399 return verifyAppBundleUpdateHandoffReplacement(tx, moved, "replacement")
1400 }, true)
1401 }
1402
1403 // VerifyAppBundleUpdateHandoffTarget proves that the bytes copied into the
1404 // installed bundle are the same tree that was verified in staging.
1405 func VerifyAppBundleUpdateHandoffTarget(tx *UpdateTransaction) error {
1406 if tx == nil {
1407 return fmt.Errorf("handoff target transaction is invalid")
1408 }
1409 return verifyAppBundleUpdateHandoffReplacement(tx, tx.TargetPath, "installed")
1410 }
1411
1412 // VerifyAppBundleUpdateHandoffReplacement proves that a candidate replacement
1413 // tree matches the bundle captured during prepare. The macOS handoff uses this
1414 // before atomically publishing a sibling staging bundle at the install path.
1415 func VerifyAppBundleUpdateHandoffReplacement(tx *UpdateTransaction, path string) error {
1416 return verifyAppBundleUpdateHandoffReplacement(tx, path, "replacement")
1417 }
1418
1419 func verifyAppBundleUpdateHandoffReplacement(tx *UpdateTransaction, path, subject string) error {
1420 if tx == nil || tx.TargetKind != "app-bundle" {
1421 return fmt.Errorf("handoff target transaction is invalid")
1422 }
1423 if strings.TrimSpace(tx.HandoffAppTreeID) == "" {
1424 return fmt.Errorf("handoff target digest is missing")
1425 }
1426 matched, err := repairPlanTreeHandoffAppMatches(path, tx.HandoffAppTreeID)
1427 if err != nil {
1428 return fmt.Errorf("read %s bundle digest: %w", subject, err)
1429 }
1430 if !matched {
1431 return fmt.Errorf("%s bundle differs from verified staging", subject)
1432 }
1433 return nil
1434 }
1435
1436 // VerifyAppBundleUpdateHandoffOriginal checks that the installed bundle about
1437 // to become the rollback backup is still the tree captured during prepare.
1438 func VerifyAppBundleUpdateHandoffOriginal(tx *UpdateTransaction) error {
1439 if tx == nil || tx.TargetKind != "app-bundle" {
1440 return fmt.Errorf("handoff original transaction is invalid")
1441 }
1442 return verifyAppBundleUpdateTree(tx.TargetPath, tx.BackupTreeID, "installed bundle changed after prepare")
1443 }
1444
1445 // VerifyAppBundleUpdateHandoffBackup checks the node produced by the
1446 // target-to-backup rename before the replacement bundle is copied into place.
1447 func VerifyAppBundleUpdateHandoffBackup(tx *UpdateTransaction) error {
1448 if tx == nil || tx.TargetKind != "app-bundle" {
1449 return fmt.Errorf("handoff backup transaction is invalid")
1450 }
1451 return verifyAppBundleUpdateTree(tx.BackupPath, tx.BackupTreeID, "rollback backup differs from prepared bundle")
1452 }
1453
1454 func verifyAppBundleUpdateHandoffBackupAbsent(tx *UpdateTransaction) error {
1455 if tx == nil || tx.TargetKind != "app-bundle" || strings.TrimSpace(tx.BackupPath) == "" {
1456 return fmt.Errorf("handoff backup transaction is invalid")
1457 }
1458 if _, err := os.Lstat(tx.BackupPath); err == nil {
1459 return fmt.Errorf("handoff backup path already exists")
1460 } else if !os.IsNotExist(err) {
1461 return fmt.Errorf("inspect handoff backup path: %w", err)
1462 }
1463 return nil
1464 }
1465
1466 // quarantineExistingAppBundleUpdateBackup recovers the pre-v1.20 state where
1467 // a committed macOS update removed pending-update.json but its sibling rollback
1468 // bundle survived best-effort cleanup. Without the transaction there is no
1469 // trustworthy authority to delete or reuse that bundle, so preparation moves it
1470 // aside with a no-replace rename and preserves it for diagnosis.
1471 //
1472 // The caller holds both the pending-update lock and the target mutation locks.
1473 // The current executable binding prevents a crafted caller from quarantining a
1474 // similarly named bundle beside an unrelated application.
1475 func quarantineExistingAppBundleUpdateBackup(tx *UpdateTransaction) (string, string, error) {
1476 if tx == nil || tx.TargetKind != "app-bundle" ||
1477 tx.BackupPath != tx.TargetPath+".reasonix-update-backup" {
1478 return "", "", fmt.Errorf("handoff backup transaction is invalid")
1479 }
1480 info, err := os.Lstat(tx.BackupPath)
1481 if err != nil {
1482 if os.IsNotExist(err) {
1483 return "", "", nil
1484 }
1485 return "", "", fmt.Errorf("inspect existing handoff backup: %w", err)
1486 }
1487 if !info.IsDir() {
1488 return "", "", fmt.Errorf("existing handoff backup is not a directory")
1489 }
1490
1491 launcher, err := repairExecutable()
1492 if err != nil {
1493 return "", "", fmt.Errorf("resolve current Reasonix executable: %w", err)
1494 }
1495 resolvedTarget, err := filepath.EvalSymlinks(tx.TargetPath)
1496 if err != nil {
1497 return "", "", fmt.Errorf("resolve current app bundle: %w", err)
1498 }
1499 resolvedLauncher, err := filepath.EvalSymlinks(launcher)
1500 if err != nil {
1501 return "", "", fmt.Errorf("resolve current Reasonix executable: %w", err)
1502 }
1503 rel, err := filepath.Rel(resolvedTarget, resolvedLauncher)
1504 if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
1505 return "", "", fmt.Errorf("existing handoff backup is outside the current Reasonix installation")
1506 }
1507
1508 expectedTreeID, err := repairPlanTreeContentStateID(tx.BackupPath)
1509 if err != nil {
1510 return "", "", fmt.Errorf("read existing handoff backup digest: %w", err)
1511 }
1512 for attempt := range 16 {
1513 quarantine := fmt.Sprintf(
1514 "%s.reasonix-orphaned-%d-%d",
1515 tx.BackupPath,
1516 time.Now().UTC().UnixNano(),
1517 attempt,
1518 )
1519 if err := renameRepairNodeNoReplace(tx.BackupPath, quarantine); err != nil {
1520 if os.IsExist(err) {
1521 continue
1522 }
1523 return "", "", fmt.Errorf("quarantine existing handoff backup: %w", err)
1524 }
1525 updateBackupAfterQuarantine(tx.BackupPath, quarantine)
1526
1527 restore := func(cause error) error {
1528 if _, statErr := os.Lstat(tx.BackupPath); statErr == nil {
1529 return fmt.Errorf("%w; preserved quarantined backup at %s because the public path was recreated", cause, quarantine)
1530 } else if !os.IsNotExist(statErr) {
1531 return fmt.Errorf("%w; inspect recreated handoff backup: %w", cause, statErr)
1532 }
1533 if restoreErr := renameRepairNodeNoReplace(quarantine, tx.BackupPath); restoreErr != nil {
1534 return fmt.Errorf("%w; preserved quarantined backup at %s: %w", cause, quarantine, restoreErr)
1535 }
1536 return cause
1537 }
1538
1539 actualTreeID, digestErr := repairPlanTreeContentStateID(quarantine)
1540 if digestErr != nil {
1541 return "", "", restore(fmt.Errorf("read quarantined handoff backup digest: %w", digestErr))
1542 }
1543 if actualTreeID != expectedTreeID {
1544 return "", "", restore(fmt.Errorf("existing handoff backup changed during quarantine"))
1545 }
1546 if _, statErr := os.Lstat(tx.BackupPath); statErr == nil {
1547 return "", "", fmt.Errorf("handoff backup path was recreated during recovery; preserved quarantined backup at %s", quarantine)
1548 } else if !os.IsNotExist(statErr) {
1549 return "", "", fmt.Errorf("inspect recovered handoff backup path: %w", statErr)
1550 }
1551 return quarantine, actualTreeID, nil
1552 }
1553 return "", "", fmt.Errorf("cannot allocate handoff backup quarantine path")
1554 }
1555
1556 // cleanupOrphanedAppBundleUpdateBackup retires only the quarantine recorded by
1557 // a terminal transaction. The no-replace move and digest check keep a changed
1558 // or concurrently replaced directory intact for diagnosis instead of deleting
1559 // a path merely because its name resembles a Reasonix quarantine.
1560 func cleanupOrphanedAppBundleUpdateBackup(tx *UpdateTransaction) {
1561 if validateOrphanedAppBundleBackupMetadata(tx) != nil ||
1562 strings.TrimSpace(tx.OrphanedBackupPath) == "" {
1563 return
1564 }
1565 if err := removeUpdateBackupTreeMatching(tx.OrphanedBackupPath, tx.OrphanedBackupTreeID); err != nil {
1566 slog.Warn("repair: preserving quarantined app backup after cleanup failed",
1567 "path", tx.OrphanedBackupPath, "error", err)
1568 }
1569 }
1570
1571 func verifyAppBundleUpdateTree(path, expected, mismatch string) error {
1572 if strings.TrimSpace(expected) == "" {
1573 return fmt.Errorf("original bundle digest is missing")
1574 }
1575 actual, err := repairPlanTreeContentStateID(path)
1576 if err != nil {
1577 return fmt.Errorf("read original bundle digest: %w", err)
1578 }
1579 if actual != expected {
1580 return fmt.Errorf("%s", mismatch)
1581 }
1582 return nil
1583 }
1584
1585 // AppBundleTreeDigest exposes the deterministic bundle-content digest to the
1586 // desktop handoff tests and other platform glue without exposing path identity.
1587 func AppBundleTreeDigest(path string) (string, error) {
1588 return repairPlanTreeContentStateID(path)
1589 }
1590
1591 func validateAppBundleHandoffSourcePaths(tx *UpdateTransaction) error {
1592 staging, err := filepath.EvalSymlinks(tx.HandoffStagingPath)
1593 if err != nil {
1594 return fmt.Errorf("resolve handoff staging directory: %w", err)
1595 }
1596 app, err := filepath.EvalSymlinks(tx.HandoffAppPath)
1597 if err != nil {
1598 return fmt.Errorf("resolve handoff app bundle: %w", err)
1599 }
1600 tempRoot, err := filepath.EvalSymlinks(os.TempDir())
1601 if err != nil {
1602 return fmt.Errorf("resolve temporary directory: %w", err)
1603 }
1604 within := func(root, path string) bool {
1605 rel, relErr := filepath.Rel(root, path)
1606 return relErr == nil && rel != "." && rel != ".." &&
1607 !strings.HasPrefix(rel, ".."+string(filepath.Separator))
1608 }
1609 if !within(tempRoot, staging) {
1610 return fmt.Errorf("handoff staging directory resolves outside the system temporary directory")
1611 }
1612 if !within(staging, app) {
1613 return fmt.Errorf("handoff app bundle resolves outside its staging directory")
1614 }
1615 if info, statErr := os.Stat(app); statErr != nil || !info.IsDir() {
1616 if statErr != nil {
1617 return fmt.Errorf("handoff app bundle is unavailable: %w", statErr)
1618 }
1619 return fmt.Errorf("handoff app bundle is not a directory")
1620 }
1621 return nil
1622 }
1623
1624 // ClearClaimedAppBundleUpdateHandoff removes a failed handoff transaction.
1625 // The caller must still hold the claim returned above.
1626 func ClearClaimedAppBundleUpdateHandoff(claimed *UpdateTransaction) error {
1627 current, err := ReadPendingUpdate()
1628 if err != nil {
1629 return err
1630 }
1631 if !reflect.DeepEqual(claimed, current) {
1632 return fmt.Errorf("clear update handoff: pending transaction changed")
1633 }
1634 if err := VerifyAppBundleUpdateHandoffOriginal(current); err != nil {
1635 return fmt.Errorf("clear update handoff: %w", err)
1636 }
1637 if err := verifyAppBundleUpdateHandoffBackupAbsent(current); err != nil {
1638 return fmt.Errorf("clear update handoff: %w", err)
1639 }
1640 if err := removePendingUpdateExactVerified(current, func() error {
1641 if err := VerifyAppBundleUpdateHandoffOriginal(current); err != nil {
1642 return fmt.Errorf("clear update handoff: %w", err)
1643 }
1644 if err := verifyAppBundleUpdateHandoffBackupAbsent(current); err != nil {
1645 return fmt.Errorf("clear update handoff: %w", err)
1646 }
1647 return nil
1648 }); err != nil {
1649 return err
1650 }
1651 return nil
1652 }
1653
1654 // WritePendingUpdate is retained for source compatibility with older repair
1655 // callers. Pending transactions are immutable once created; callers that need
1656 // to start an update should use the prepare APIs, and callers that need to
1657 // transition one must use the exact transaction helpers below.
1658 //
1659 // Deprecated: this function only creates a pending transaction and refuses to
1660 // replace an existing one.
1661 func WritePendingUpdate(tx *UpdateTransaction) error {
1662 return createPendingUpdate(tx)
1663 }
1664
1665 func createPendingUpdate(tx *UpdateTransaction) error {
1666 return writePendingUpdate(tx, true)
1667 }
1668
1669 func writePendingUpdate(tx *UpdateTransaction, createOnly bool) error {
1670 if tx == nil {
1671 return fmt.Errorf("pending update: nil transaction")
1672 }
1673 path := PendingUpdatePath()
1674 if path == "" {
1675 return fmt.Errorf("pending update: Reasonix state directory is unavailable")
1676 }
1677 b, err := json.MarshalIndent(tx, "", " ")
1678 if err != nil {
1679 return err
1680 }
1681 if createOnly {
1682 return fileutil.AtomicCreateFile(path, append(b, '\n'), 0o600)
1683 }
1684 return fileutil.AtomicWriteFile(path, append(b, '\n'), 0o600)
1685 }
1686
1687 func removePendingUpdateExactVerified(expected *UpdateTransaction, verify func() error) error {
1688 if expected == nil {
1689 return fmt.Errorf("clear pending update: transaction identity is incomplete")
1690 }
1691 path := PendingUpdatePath()
1692 pendingUpdateBeforeCleanup(path)
1693 cleanup, err := moveRepairNodeToUniqueCleanup(path)
1694 if err != nil {
1695 return err
1696 }
1697 if cleanup == "" {
1698 return fmt.Errorf("clear pending update: pending transaction disappeared before commit")
1699 }
1700 updateCleanupAfterRename(path, cleanup)
1701 restore := func(cause error) error {
1702 if restoreErr := renameRepairNodeNoReplace(cleanup, path); restoreErr != nil {
1703 return fmt.Errorf("%w; pending transaction retained at %s: %w", cause, cleanup, restoreErr)
1704 }
1705 return cause
1706 }
1707 b, err := os.ReadFile(cleanup)
1708 if err != nil {
1709 return restore(err)
1710 }
1711 var actual UpdateTransaction
1712 if err := json.Unmarshal(b, &actual); err != nil {
1713 return restore(err)
1714 }
1715 if UpdateTransactionID(&actual) != UpdateTransactionID(expected) {
1716 return restore(fmt.Errorf("clear pending update: pending transaction changed"))
1717 }
1718 if verify != nil {
1719 if err := verify(); err != nil {
1720 return restore(err)
1721 }
1722 }
1723 if err := removePendingUpdateFile(cleanup); err != nil {
1724 return restore(err)
1725 }
1726 cleanupOrphanedAppBundleUpdateBackup(expected)
1727 return nil
1728 }
1729
1730 // ensureNoPendingUpdate runs with the pending-update lock held. Preparing a new
1731 // transaction over an existing one would overwrite fixed backup paths before
1732 // the new transaction is durable, destroying the previous rollback material if
1733 // preparation later fails.
1734 func ensureNoPendingUpdate() error {
1735 disposition, tx, err := classifyPendingUpdate()
1736 if err != nil {
1737 return fmt.Errorf("prepare update: %w", err)
1738 }
1739 switch disposition {
1740 case pendingUpdateActionable:
1741 if tx == nil {
1742 // Self-describing but not valid for this installation (see
1743 // ReconcilePendingUpdate): nothing can resume or roll it back, and
1744 // refusing here would refuse forever. Quarantine the marker so a
1745 // future update can proceed; target and rollback material are
1746 // untouched.
1747 if _, err := quarantinePendingUpdate("not valid for this installation"); err != nil {
1748 return fmt.Errorf("prepare update: quarantine unusable transaction: %w", err)
1749 }
1750 return nil
1751 }
1752 return fmt.Errorf("prepare update: a pending update already exists")
1753 case pendingUpdateDebris:
1754 // Refusing here would be refusing forever: debris cannot be resumed,
1755 // rolled back, or cleared by reconciliation, so every future update
1756 // would fail on a transaction nothing can act on.
1757 if _, err := quarantinePendingUpdate("blocked a new update"); err != nil {
1758 return fmt.Errorf("prepare update: quarantine unusable transaction: %w", err)
1759 }
1760 }
1761 return nil
1762 }
1763
1764 // pendingUpdateDisposition is what the pending-update marker on disk currently
1765 // means. Preparation and reconciliation both classify through it so they cannot
1766 // disagree about whether a transaction exists — when they did, a marker that
1767 // reconciliation could not act on still made preparation refuse, and updates
1768 // stayed blocked permanently (#7342).
1769 type pendingUpdateDisposition int
1770
1771 const (
1772 // pendingUpdateNone: no marker on disk.
1773 pendingUpdateNone pendingUpdateDisposition = iota
1774 // pendingUpdateActionable: a transaction that can still be resumed or
1775 // rolled back. Preparation must refuse over one of these — writing a new
1776 // transaction would overwrite fixed backup paths and destroy the rollback
1777 // material this one still owns.
1778 pendingUpdateActionable
1779 // pendingUpdateDebris: a marker that cannot describe a recoverable
1780 // transaction, so it owns no rollback material worth protecting.
1781 pendingUpdateDebris
1782 )
1783
1784 // classifyPendingUpdate reads the marker and decides what can be done with it.
1785 //
1786 // The line between debris and an actionable transaction is deliberately drawn
1787 // at self-description. A transaction that cannot be parsed, or that does not
1788 // say which release it targets, for which platform, and when it was opened,
1789 // names nothing to roll back to — discarding it loses nothing. Every other
1790 // validation failure is environment-relative (the launcher path, whether the
1791 // target sits inside this Guard installation, where the backup lives) and can
1792 // fail for a perfectly good transaction observed from the wrong install, so
1793 // those keep the old refusal rather than risking real rollback material.
1794 //
1795 // IO failures are errors, never debris: an unreadable marker is not an absent
1796 // one, and quarantining on a transient permission error would throw away a
1797 // recoverable transaction.
1798 func classifyPendingUpdate() (pendingUpdateDisposition, *UpdateTransaction, error) {
1799 path := PendingUpdatePath()
1800 if path == "" {
1801 return pendingUpdateNone, nil, fmt.Errorf("Reasonix state directory is unavailable")
1802 }
1803 if _, err := os.Lstat(path); err != nil {
1804 if os.IsNotExist(err) {
1805 return pendingUpdateNone, nil, nil
1806 }
1807 return pendingUpdateNone, nil, fmt.Errorf("inspect pending transaction: %w", err)
1808 }
1809 tx, err := readPendingUpdateUnchecked()
1810 if err != nil {
1811 switch {
1812 case os.IsNotExist(err):
1813 return pendingUpdateNone, nil, nil
1814 case isPendingUpdateContentError(err):
1815 return pendingUpdateDebris, nil, nil
1816 default:
1817 return pendingUpdateNone, nil, fmt.Errorf("read pending transaction: %w", err)
1818 }
1819 }
1820 if !pendingUpdateSelfDescribing(tx) {
1821 return pendingUpdateDebris, nil, nil
1822 }
1823 if err := validateUpdateTransaction(tx); err != nil {
1824 if errors.Is(err, errPendingUpdateForeignInstall) {
1825 return pendingUpdateActionable, nil, nil
1826 }
1827 return pendingUpdateNone, nil, fmt.Errorf("validate pending transaction: %w", err)
1828 }
1829 return pendingUpdateActionable, tx, nil
1830 }
1831
1832 // isPendingUpdateContentError reports whether err means the bytes on disk are
1833 // not a transaction, as opposed to the file being unreadable. A prepare
1834 // interrupted mid-write leaves a truncated object, which decodes to a syntax
1835 // error rather than an IO error.
1836 func isPendingUpdateContentError(err error) bool {
1837 var syntax *json.SyntaxError
1838 var unmarshalType *json.UnmarshalTypeError
1839 return errors.As(err, &syntax) || errors.As(err, &unmarshalType) || errors.Is(err, io.ErrUnexpectedEOF)
1840 }
1841
1842 // pendingUpdateSelfDescribing reports whether tx carries the identity any
1843 // recovery needs regardless of where Reasonix is installed: which release it
1844 // targets, for which platform, and when it was opened.
1845 func pendingUpdateSelfDescribing(tx *UpdateTransaction) bool {
1846 if tx == nil || tx.SchemaVersion != updateTransactionVersion || strings.TrimSpace(tx.ToVersion) == "" {
1847 return false
1848 }
1849 if strings.TrimSpace(tx.Platform) == "" || strings.TrimSpace(tx.CreatedAt) == "" {
1850 return false
1851 }
1852 _, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(tx.CreatedAt))
1853 return err == nil
1854 }
1855
1856 // quarantinePendingUpdate moves an unusable marker aside and returns where it
1857 // went. It is deliberately not a delete: the marker is the only evidence of
1858 // what went wrong, and a user who reports a stuck updater should still have it.
1859 // Callers hold the pending-update lock.
1860 func quarantinePendingUpdate(reason string) (string, error) {
1861 path := PendingUpdatePath()
1862 if path == "" {
1863 return "", fmt.Errorf("Reasonix state directory is unavailable")
1864 }
1865 base := path + ".unusable-" + time.Now().UTC().Format("20060102T150405Z")
1866 aside := base
1867 for i := 1; ; i++ {
1868 if _, err := os.Lstat(aside); os.IsNotExist(err) {
1869 break
1870 } else if err != nil {
1871 return "", err
1872 }
1873 aside = fmt.Sprintf("%s-%d", base, i)
1874 }
1875 if err := os.Rename(path, aside); err != nil {
1876 return "", err
1877 }
1878 slog.Warn("repair: quarantined an unusable pending update transaction",
1879 "path", aside, "reason", reason)
1880 return aside, nil
1881 }
1882
1883 func pendingUpdateMarkerDigest(path string) (string, error) {
1884 body, err := readRepairRegularFile(path)
1885 if err != nil {
1886 return "", err
1887 }
1888 digest := sha256.Sum256(body)
1889 return hex.EncodeToString(digest[:]), nil
1890 }
1891
1892 // quarantinePendingUpdateAfterReconcile rechecks an unusable marker while
1893 // holding the cross-process pending lock. Reconciliation initially classifies
1894 // without that lock because the normal cancel/rollback paths acquire it later;
1895 // the marker digest prevents a concurrent prepare from being quarantined after
1896 // it has replaced the marker.
1897 func quarantinePendingUpdateAfterReconcile(reason string, expectedDigest string, wantForeign bool) (bool, error) {
1898 unlock, err := acquirePendingUpdateLock()
1899 if err != nil {
1900 return false, fmt.Errorf("lock pending transaction: %w", err)
1901 }
1902 defer unlock()
1903
1904 path := PendingUpdatePath()
1905 actualDigest, err := pendingUpdateMarkerDigest(path)
1906 if err != nil {
1907 if os.IsNotExist(err) {
1908 return false, nil
1909 }
1910 return false, fmt.Errorf("read pending transaction: %w", err)
1911 }
1912 if actualDigest != expectedDigest {
1913 return false, fmt.Errorf("pending update changed while waiting")
1914 }
1915 disposition, tx, err := classifyPendingUpdate()
1916 if err != nil {
1917 return false, err
1918 }
1919 if wantForeign {
1920 if disposition != pendingUpdateActionable || tx != nil {
1921 return false, fmt.Errorf("pending update is no longer a foreign transaction")
1922 }
1923 } else if disposition != pendingUpdateDebris {
1924 return false, fmt.Errorf("pending update is no longer unusable debris")
1925 }
1926 if _, err := quarantinePendingUpdate(reason); err != nil {
1927 return false, err
1928 }
1929 return true, nil
1930 }
1931
1932 func ReadPendingUpdate() (*UpdateTransaction, error) {
1933 tx, err := readPendingUpdateUnchecked()
1934 if err != nil {
1935 return nil, err
1936 }
1937 if err := validateUpdateTransaction(tx); err != nil {
1938 return nil, err
1939 }
1940 return tx, nil
1941 }
1942
1943 func readPendingUpdateForLauncher(launcherPath string) (*UpdateTransaction, error) {
1944 tx, err := readPendingUpdateUnchecked()
1945 if err != nil {
1946 return nil, err
1947 }
1948 if err := validateUpdateTransactionForLauncher(tx, launcherPath); err != nil {
1949 return nil, err
1950 }
1951 return tx, nil
1952 }
1953
1954 func readPendingUpdateUnchecked() (*UpdateTransaction, error) {
1955 path := PendingUpdatePath()
1956 if path == "" {
1957 return nil, os.ErrNotExist
1958 }
1959 b, err := readRepairRegularFile(path)
1960 if err != nil {
1961 return nil, err
1962 }
1963 var tx UpdateTransaction
1964 if err := json.Unmarshal(b, &tx); err != nil {
1965 return nil, err
1966 }
1967 return &tx, nil
1968 }
1969
1970 func HasPendingUpdate() bool {
1971 _, err := ReadPendingUpdate()
1972 return err == nil
1973 }
1974
1975 // PendingUpdateExists reports the on-disk marker even when its contents are
1976 // malformed. It is intended only for progress/UI decisions; callers must use
1977 // ReadPendingUpdate or ReconcilePendingUpdate before authorizing mutations.
1978 func PendingUpdateExists() bool {
1979 path := PendingUpdatePath()
1980 if path == "" {
1981 return false
1982 }
1983 _, err := os.Lstat(path)
1984 return err == nil
1985 }
1986
1987 // ReconcilePendingUpdate resolves an older immutable update transaction before
1988 // startup or a new install. It first attempts the narrow cancellation path,
1989 // which succeeds only while every original target still matches the state
1990 // captured by prepare and no replacement state is durable. If publication has
1991 // started, it falls back to the exact verified rollback path. Both transitions
1992 // re-read the complete transaction under the pending and target mutation locks.
1993 //
1994 // A transaction targeting runningVersion is left untouched only when the
1995 // transaction also proves that its replacement release unit is installed and
1996 // its rollback state is intact. Version equality alone is not installation
1997 // evidence: a same-version/manual launch may observe an abandoned prepare.
1998 func ReconcilePendingUpdate(runningVersion string) (PendingUpdateReconcileResult, error) {
1999 disposition, tx, classifyErr := classifyPendingUpdate()
2000 if classifyErr != nil {
2001 return PendingUpdateReconcileResult{Pending: true}, fmt.Errorf("reconcile pending update: %w", classifyErr)
2002 }
2003 switch {
2004 case disposition == pendingUpdateNone:
2005 return PendingUpdateReconcileResult{}, nil
2006 case disposition == pendingUpdateDebris:
2007 // Nothing here can be resumed or rolled back. Leaving it in place is
2008 // what stranded users: startup kept failing to recover it while
2009 // preparation kept refusing to write over it.
2010 digest, digestErr := pendingUpdateMarkerDigest(PendingUpdatePath())
2011 if digestErr != nil {
2012 if os.IsNotExist(digestErr) {
2013 return PendingUpdateReconcileResult{}, nil
2014 }
2015 return PendingUpdateReconcileResult{Pending: true}, fmt.Errorf("reconcile pending update: read marker: %w", digestErr)
2016 }
2017 quarantined, err := quarantinePendingUpdateAfterReconcile("no recoverable transaction to reconcile", digest, false)
2018 if err != nil {
2019 return PendingUpdateReconcileResult{Pending: true}, fmt.Errorf("reconcile pending update: quarantine unusable transaction: %w", err)
2020 }
2021 if !quarantined {
2022 return PendingUpdateReconcileResult{}, nil
2023 }
2024 return PendingUpdateReconcileResult{Pending: true, Cleared: true}, nil
2025 case tx == nil:
2026 // Self-describing but not valid for this installation: the launcher and
2027 // target directories no longer match (install layout moved to
2028 // versions\<version>\ or the old install directory is gone). Nothing
2029 // here can be resumed or rolled back by this installation, and leaving
2030 // the marker blocks every future update permanently — recovery fails
2031 // here before preparation can act, and the marker lives in the state
2032 // directory so a reinstall does not clear it (#7391, #7416, #7407).
2033 // Quarantine the marker, never a delete: the target and rollback
2034 // material are untouched, so a genuine transaction observed from the
2035 // wrong install loses nothing and remains recoverable from the
2036 // .unusable-* file by hand.
2037 digest, digestErr := pendingUpdateMarkerDigest(PendingUpdatePath())
2038 if digestErr != nil {
2039 if os.IsNotExist(digestErr) {
2040 return PendingUpdateReconcileResult{}, nil
2041 }
2042 return PendingUpdateReconcileResult{Pending: true}, fmt.Errorf("reconcile pending update: read marker: %w", digestErr)
2043 }
2044 quarantined, err := quarantinePendingUpdateAfterReconcile("not valid for this installation", digest, true)
2045 if err != nil {
2046 return PendingUpdateReconcileResult{Pending: true}, fmt.Errorf("reconcile pending update: quarantine unusable transaction: %w", err)
2047 }
2048 if !quarantined {
2049 return PendingUpdateReconcileResult{}, nil
2050 }
2051 return PendingUpdateReconcileResult{Pending: true, Cleared: true}, nil
2052 }
2053 result := PendingUpdateReconcileResult{
2054 Pending: true,
2055 FromVersion: tx.FromVersion,
2056 ToVersion: tx.ToVersion,
2057 TargetPath: tx.TargetPath,
2058 }
2059 if UpdateVersionsEqual(runningVersion, tx.ToVersion) &&
2060 pendingUpdateInstalledForHealth(tx) {
2061 // After the stale window, auto-commit a still-running probationary target.
2062 if pendingUpdateHealthIsStale(tx) {
2063 if healErr := MarkUpdateHealthy(runningVersion); healErr == nil && !PendingUpdateExists() {
2064 result.Pending = false
2065 result.Healthy = true
2066 result.Cleared = true
2067 return result, nil
2068 } else if healErr != nil {
2069 slog.Warn("repair: stale probationary update could not be committed automatically",
2070 "toVersion", tx.ToVersion, "err", healErr)
2071 }
2072 }
2073 result.AwaitingHealth = true
2074 return result, ErrPendingUpdateAwaitingHealth
2075 }
2076
2077 // Cancel is deliberately attempted before rollback. For app bundles it
2078 // requires the original tree and an absent backup; for file release units it
2079 // requires every prepared target and no installed-state sidecar. A failed
2080 // cancel never mutates the transaction or release unit.
2081 if cancelErr := CancelPendingUpdateExact(tx); cancelErr == nil {
2082 // Exact cancellation historically treats a different target version or
2083 // creation time as an inert success. Re-check the public postcondition so
2084 // reconciliation never reports a newer transaction as cleared.
2085 current, currentErr := ReadPendingUpdate()
2086 if os.IsNotExist(currentErr) {
2087 result.Cleared = true
2088 cleanupPendingUpdateStaging(tx)
2089 return result, nil
2090 }
2091 if currentErr != nil {
2092 return result, fmt.Errorf("reconcile pending update: verify cancellation: %w", currentErr)
2093 }
2094 if !reflect.DeepEqual(tx, current) {
2095 return result, fmt.Errorf("reconcile pending update: pending transaction changed during cancellation")
2096 }
2097 return result, fmt.Errorf("reconcile pending update: transaction remained after cancellation")
2098 }
2099
2100 rollback, rollbackErr := RollbackPendingUpdateExact(tx)
2101 if rollbackErr != nil {
2102 result.RolledBack = rollback.RolledBack
2103 result.MixedInstall = rollback.MixedInstall
2104 return result, fmt.Errorf("reconcile pending update: %w", rollbackErr)
2105 }
2106 if !rollback.RolledBack {
2107 // Another exact owner may have committed or cancelled the transaction
2108 // between the invocation snapshot and the locked transition.
2109 if _, currentErr := ReadPendingUpdate(); os.IsNotExist(currentErr) {
2110 return PendingUpdateReconcileResult{}, nil
2111 }
2112 return result, fmt.Errorf("reconcile pending update: transaction could not be cancelled or rolled back")
2113 }
2114 result.RolledBack = true
2115 cleanupPendingUpdateStaging(tx)
2116 return result, nil
2117 }
2118
2119 // CommitProbationaryPendingUpdate commits a still-running probationary update
2120 // when install evidence matches. Returns true when the marker is gone.
2121 func CommitProbationaryPendingUpdate(runningVersion string) (bool, error) {
2122 if strings.TrimSpace(runningVersion) == "" {
2123 return false, nil
2124 }
2125 tx, err := ReadPendingUpdate()
2126 if err != nil {
2127 if os.IsNotExist(err) {
2128 return true, nil
2129 }
2130 return false, err
2131 }
2132 if !UpdateVersionsEqual(runningVersion, tx.ToVersion) || !pendingUpdateInstalledForHealth(tx) {
2133 return false, nil
2134 }
2135 if err := MarkUpdateHealthy(runningVersion); err != nil {
2136 return false, err
2137 }
2138 return !PendingUpdateExists(), nil
2139 }
2140
2141 // AbandonPendingUpdate is the user-initiated recovery path for a stuck
2142 // transaction: commit if possible, else reconcile, else force-retire.
2143 func AbandonPendingUpdate(runningVersion string) (PendingUpdateReconcileResult, error) {
2144 committed, commitErr := CommitProbationaryPendingUpdate(runningVersion)
2145 if commitErr == nil && committed {
2146 return PendingUpdateReconcileResult{Cleared: true, Healthy: true}, nil
2147 }
2148 if commitErr != nil {
2149 // Keep going: a drifted backup must not block explicit discard.
2150 slog.Debug("repair: probationary commit during abandon failed; continuing",
2151 "err", commitErr)
2152 }
2153 result, reconcileErr := ReconcilePendingUpdate(runningVersion)
2154 if reconcileErr == nil {
2155 return result, nil
2156 }
2157 // Force-retire when still AwaitingHealth with the live target installed.
2158 if errors.Is(reconcileErr, ErrPendingUpdateAwaitingHealth) {
2159 if retired, retireErr := forceRetireProbationaryPendingUpdate(runningVersion); retireErr != nil {
2160 return result, fmt.Errorf("abandon pending update: %w", retireErr)
2161 } else if retired {
2162 result.Pending = false
2163 result.AwaitingHealth = false
2164 result.Healthy = true
2165 result.Cleared = true
2166 return result, nil
2167 }
2168 }
2169 if commitErr != nil && reconcileErr != nil {
2170 return result, fmt.Errorf("abandon pending update: %w", errors.Join(reconcileErr, commitErr))
2171 }
2172 return result, reconcileErr
2173 }
2174
2175 // forceRetireProbationaryPendingUpdate retires a probationary marker when the
2176 // live target is installed but MarkUpdateHealthy cannot finish (e.g. bad backup).
2177 func forceRetireProbationaryPendingUpdate(runningVersion string) (bool, error) {
2178 tx, err := ReadPendingUpdate()
2179 if err != nil {
2180 if os.IsNotExist(err) {
2181 return true, nil
2182 }
2183 return false, err
2184 }
2185 // Target-only evidence: broken rollback backups must not block discard.
2186 if !UpdateVersionsEqual(runningVersion, tx.ToVersion) || !pendingUpdateTargetInstalled(tx) {
2187 return false, nil
2188 }
2189 unlock, err := acquirePendingUpdateLock()
2190 if err != nil {
2191 return false, fmt.Errorf("force retire probationary update: lock pending transaction: %w", err)
2192 }
2193 defer unlock()
2194 current, err := ReadPendingUpdate()
2195 if err != nil {
2196 if os.IsNotExist(err) {
2197 return true, nil
2198 }
2199 return false, err
2200 }
2201 if UpdateTransactionID(current) != UpdateTransactionID(tx) {
2202 return false, fmt.Errorf("force retire probationary update: pending transaction changed")
2203 }
2204 if !UpdateVersionsEqual(runningVersion, current.ToVersion) || !pendingUpdateTargetInstalled(current) {
2205 return false, nil
2206 }
2207 unlockTargets, lockErr := lockRepairMutations(pendingUpdateTargetPaths(current)...)
2208 if lockErr != nil {
2209 return false, fmt.Errorf("force retire probationary update: lock targets: %w", lockErr)
2210 }
2211 defer unlockTargets()
2212 recheck, err := ReadPendingUpdate()
2213 if err != nil {
2214 if os.IsNotExist(err) {
2215 return true, nil
2216 }
2217 return false, err
2218 }
2219 if !reflect.DeepEqual(current, recheck) {
2220 return false, fmt.Errorf("force retire probationary update: pending transaction changed while waiting")
2221 }
2222 verifyInstalled := func() error {
2223 if !pendingUpdateTargetInstalled(recheck) {
2224 return fmt.Errorf("installed target no longer matches the pending transaction")
2225 }
2226 return nil
2227 }
2228 if err := removePendingUpdateExactVerified(recheck, verifyInstalled); err != nil {
2229 return false, err
2230 }
2231 removeUpdateBackups(recheck)
2232 slog.Warn("repair: force-retired a probationary pending update after explicit abandon",
2233 "toVersion", recheck.ToVersion, "target", recheck.TargetPath)
2234 return true, nil
2235 }
2236
2237 // pendingUpdateTargetInstalled reports live replacement install evidence only
2238 // (no rollback backup requirement). Used by force-retire on explicit abandon.
2239 func pendingUpdateTargetInstalled(tx *UpdateTransaction) bool {
2240 if tx == nil {
2241 return false
2242 }
2243 switch tx.TargetKind {
2244 case "app-bundle":
2245 return VerifyAppBundleUpdateHandoffTarget(tx) == nil
2246 case "file":
2247 _, bound, err := installedFileUpdateTargets(tx, true)
2248 return err == nil && bound
2249 default:
2250 return false
2251 }
2252 }
2253
2254 // pendingUpdateInstalledForHealth requires transaction-bound evidence for the
2255 // complete replacement and rollback unit. It intentionally treats missing or
2256 // drifted evidence as uninstalled so reconciliation can take the existing
2257 // exact cancel/rollback paths instead of trusting a version string.
2258 func pendingUpdateInstalledForHealth(tx *UpdateTransaction) bool {
2259 if tx == nil {
2260 return false
2261 }
2262 switch tx.TargetKind {
2263 case "app-bundle":
2264 return VerifyAppBundleUpdateHandoffTarget(tx) == nil &&
2265 VerifyAppBundleUpdateHandoffBackup(tx) == nil
2266 case "file":
2267 _, bound, err := installedFileUpdateTargets(tx, true)
2268 return err == nil && bound
2269 default:
2270 return false
2271 }
2272 }
2273
2274 // cleanupPendingUpdateStaging is best-effort after the pending transaction has
2275 // been safely committed away. CleanupAppBundleUpdateHandoffStaging verifies the
2276 // complete recorded tree before removal, so drifted or recreated paths survive.
2277 func cleanupPendingUpdateStaging(tx *UpdateTransaction) {
2278 if tx == nil || tx.TargetKind != "app-bundle" ||
2279 strings.TrimSpace(tx.HandoffStagingPath) == "" ||
2280 strings.TrimSpace(tx.HandoffStagingTreeID) == "" {
2281 return
2282 }
2283 _ = CleanupAppBundleUpdateHandoffStaging(tx)
2284 }
2285
2286 func readPendingUpdateInvocation() (*UpdateTransaction, string, map[string]string, error) {
2287 tx, err := ReadPendingUpdate()
2288 if err != nil {
2289 return nil, "", nil, err
2290 }
2291 stateID, states := pendingUpdateBoundPreview(tx)
2292 return tx, stateID, states, nil
2293 }
2294
2295 // MarkUpdateHealthy commits a probationary update and removes its backup. A
2296 // version mismatch is ignored so an older process cannot bless a newer update.
2297 func MarkUpdateHealthy(runningVersion string) error {
2298 return markUpdateHealthyInvocation(runningVersion, "", "")
2299 }
2300
2301 // MarkUpdateHealthyMatching commits only the exact pending transaction observed
2302 // when this desktop process started. The creation identity prevents an older
2303 // process from blessing a later same-version retry.
2304 func MarkUpdateHealthyMatching(runningVersion, expectedCreatedAt string) error {
2305 expectedCreatedAt = strings.TrimSpace(expectedCreatedAt)
2306 if expectedCreatedAt == "" {
2307 return nil
2308 }
2309 return markUpdateHealthyInvocation(runningVersion, expectedCreatedAt, "")
2310 }
2311
2312 // MarkUpdateHealthyExact commits only the complete transaction captured before
2313 // the replacement process started.
2314 func MarkUpdateHealthyExact(runningVersion, expectedCreatedAt, expectedTransactionID string) error {
2315 expectedCreatedAt = strings.TrimSpace(expectedCreatedAt)
2316 expectedTransactionID = strings.TrimSpace(expectedTransactionID)
2317 if expectedCreatedAt == "" || expectedTransactionID == "" {
2318 return nil
2319 }
2320 return markUpdateHealthyInvocation(runningVersion, expectedCreatedAt, expectedTransactionID)
2321 }
2322
2323 func markUpdateHealthyInvocation(runningVersion, expectedCreatedAt, expectedTransactionID string) error {
2324 tx, stateID, _, err := readPendingUpdateInvocation()
2325 if err != nil {
2326 if os.IsNotExist(err) {
2327 return nil
2328 }
2329 return err
2330 }
2331 if !UpdateVersionsEqual(runningVersion, tx.ToVersion) {
2332 return nil
2333 }
2334 if expected := strings.TrimSpace(expectedCreatedAt); expected != "" && expected != strings.TrimSpace(tx.CreatedAt) {
2335 return nil
2336 }
2337 if expected := strings.TrimSpace(expectedTransactionID); expected != "" && expected != UpdateTransactionID(tx) {
2338 return fmt.Errorf("mark update healthy: pending transaction changed")
2339 }
2340 return markUpdateHealthyMatching(
2341 runningVersion,
2342 tx.CreatedAt,
2343 UpdateTransactionID(tx),
2344 stateID,
2345 )
2346 }
2347
2348 func markUpdateHealthyMatching(runningVersion, expectedCreatedAt, expectedTransactionID, expectedStateID string) error {
2349 unlock, err := acquirePendingUpdateLock()
2350 if err != nil {
2351 return fmt.Errorf("mark update healthy: lock pending transaction: %w", err)
2352 }
2353 defer unlock()
2354 tx, err := ReadPendingUpdate()
2355 if err != nil {
2356 if os.IsNotExist(err) {
2357 return nil
2358 }
2359 return err
2360 }
2361 if !UpdateVersionsEqual(runningVersion, tx.ToVersion) {
2362 return nil
2363 }
2364 if expected := strings.TrimSpace(expectedCreatedAt); expected != "" && expected != strings.TrimSpace(tx.CreatedAt) {
2365 return nil
2366 }
2367 if expected := strings.TrimSpace(expectedTransactionID); expected != "" && expected != UpdateTransactionID(tx) {
2368 return fmt.Errorf("mark update healthy: pending transaction changed")
2369 }
2370 unlockTargets, lockErr := lockRepairMutations(pendingUpdateTargetPaths(tx)...)
2371 if lockErr != nil {
2372 return fmt.Errorf("mark update healthy: lock targets: %w", lockErr)
2373 }
2374 defer unlockTargets()
2375 current, err := ReadPendingUpdate()
2376 if err != nil {
2377 return fmt.Errorf("mark update healthy: re-read pending transaction: %w", err)
2378 }
2379 if !reflect.DeepEqual(tx, current) {
2380 return fmt.Errorf("mark update healthy: pending transaction changed while waiting")
2381 }
2382 tx = current
2383 verifyInvocationState := func() error {
2384 actual, _ := pendingUpdateBoundPreview(tx)
2385 if strings.TrimSpace(expectedStateID) != actual {
2386 return fmt.Errorf("mark update healthy: pending update state changed while waiting")
2387 }
2388 return nil
2389 }
2390 verifyHealthyState := func() error {
2391 if err := verifyInvocationState(); err != nil {
2392 return err
2393 }
2394 switch tx.TargetKind {
2395 case "app-bundle":
2396 if strings.TrimSpace(tx.HandoffAppTreeID) == "" {
2397 return fmt.Errorf("mark update healthy: installed bundle state is missing")
2398 }
2399 if err := VerifyAppBundleUpdateHandoffTarget(tx); err != nil {
2400 return fmt.Errorf("mark update healthy: %w", err)
2401 }
2402 if strings.TrimSpace(tx.BackupTreeID) == "" {
2403 return fmt.Errorf("mark update healthy: rollback backup state is missing")
2404 }
2405 if err := VerifyAppBundleUpdateHandoffBackup(tx); err != nil {
2406 return fmt.Errorf("mark update healthy: %w", err)
2407 }
2408 case "file":
2409 if err := verifyPreparedFileUpdateBackups(tx); err != nil {
2410 return fmt.Errorf("mark update healthy: %w", err)
2411 }
2412 if _, _, err := installedFileUpdateTargets(tx, true); err != nil {
2413 return fmt.Errorf("mark update healthy: %w", err)
2414 }
2415 }
2416 return nil
2417 }
2418 if err := verifyHealthyState(); err != nil {
2419 return err
2420 }
2421 if err := removePendingUpdateExactVerified(tx, verifyHealthyState); err != nil {
2422 return err
2423 }
2424 removeUpdateBackups(tx)
2425 _ = removeInstalledFileUpdateState(tx)
2426 return nil
2427 }
2428
2429 // CancelPendingUpdate removes a transaction that failed before control was
2430 // handed to the replacement build. A version mismatch is intentionally inert.
2431 func CancelPendingUpdate(toVersion string) error {
2432 return cancelPendingUpdateInvocation(toVersion, "", "")
2433 }
2434
2435 // CancelPendingUpdateMatching removes only the exact transaction prepared by
2436 // the caller. It is used by updater failure paths where a same-version retry can
2437 // replace pending-update.json before cleanup runs.
2438 func CancelPendingUpdateMatching(toVersion, expectedCreatedAt string) error {
2439 expectedCreatedAt = strings.TrimSpace(expectedCreatedAt)
2440 if expectedCreatedAt == "" {
2441 return nil
2442 }
2443 return cancelPendingUpdateInvocation(toVersion, expectedCreatedAt, "")
2444 }
2445
2446 // CancelPendingUpdateExact removes only the complete transaction returned by
2447 // prepare. This is the updater failure path: copied creation timestamps are not
2448 // sufficient authorization if pending-update.json itself was rewritten.
2449 func CancelPendingUpdateExact(expected *UpdateTransaction) error {
2450 if expected == nil {
2451 return fmt.Errorf("cancel pending update: transaction identity is incomplete")
2452 }
2453 return cancelPendingUpdateInvocation(
2454 expected.ToVersion,
2455 expected.CreatedAt,
2456 repairPlanStateID(expected),
2457 )
2458 }
2459
2460 func cancelPendingUpdateInvocation(toVersion, expectedCreatedAt, expectedTransactionID string) error {
2461 tx, stateID, _, err := readPendingUpdateInvocation()
2462 if err != nil {
2463 if os.IsNotExist(err) {
2464 return nil
2465 }
2466 return err
2467 }
2468 if strings.TrimSpace(toVersion) != strings.TrimSpace(tx.ToVersion) {
2469 return nil
2470 }
2471 if expected := strings.TrimSpace(expectedCreatedAt); expected != "" && expected != strings.TrimSpace(tx.CreatedAt) {
2472 return nil
2473 }
2474 if expected := strings.TrimSpace(expectedTransactionID); expected != "" && expected != UpdateTransactionID(tx) {
2475 return fmt.Errorf("cancel pending update: pending transaction changed")
2476 }
2477 return cancelPendingUpdateMatching(
2478 tx.ToVersion,
2479 tx.CreatedAt,
2480 UpdateTransactionID(tx),
2481 stateID,
2482 )
2483 }
2484
2485 func cancelPendingUpdateMatching(toVersion, expectedCreatedAt, expectedTransactionID, expectedStateID string) error {
2486 unlock, err := acquirePendingUpdateLock()
2487 if err != nil {
2488 return fmt.Errorf("cancel pending update: lock pending transaction: %w", err)
2489 }
2490 defer unlock()
2491 tx, err := ReadPendingUpdate()
2492 if err != nil {
2493 if os.IsNotExist(err) {
2494 return nil
2495 }
2496 return err
2497 }
2498 if strings.TrimSpace(toVersion) != strings.TrimSpace(tx.ToVersion) {
2499 return nil
2500 }
2501 if expected := strings.TrimSpace(expectedCreatedAt); expected != "" && expected != strings.TrimSpace(tx.CreatedAt) {
2502 return nil
2503 }
2504 if expected := strings.TrimSpace(expectedTransactionID); expected != "" && expected != repairPlanStateID(tx) {
2505 return fmt.Errorf("cancel pending update: pending transaction changed")
2506 }
2507 unlockTargets, lockErr := lockRepairMutations(pendingUpdateTargetPaths(tx)...)
2508 if lockErr != nil {
2509 return fmt.Errorf("cancel pending update: lock targets: %w", lockErr)
2510 }
2511 defer unlockTargets()
2512 current, err := ReadPendingUpdate()
2513 if err != nil {
2514 return fmt.Errorf("cancel pending update: re-read pending transaction: %w", err)
2515 }
2516 if !reflect.DeepEqual(tx, current) {
2517 return fmt.Errorf("cancel pending update: pending transaction changed while waiting")
2518 }
2519 tx = current
2520 verifyCancellationState := func() error {
2521 actual, _ := pendingUpdateBoundPreview(tx)
2522 if strings.TrimSpace(expectedStateID) != actual {
2523 return fmt.Errorf("cancel pending update: pending update state changed while waiting")
2524 }
2525 switch tx.TargetKind {
2526 case "app-bundle":
2527 if err := VerifyAppBundleUpdateHandoffOriginal(tx); err != nil {
2528 return fmt.Errorf("cancel pending update: %w", err)
2529 }
2530 if err := verifyAppBundleUpdateHandoffBackupAbsent(tx); err != nil {
2531 return fmt.Errorf("cancel pending update: %w", err)
2532 }
2533 case "file":
2534 if _, bound, err := installedFileUpdateTargets(tx, false); err != nil {
2535 return fmt.Errorf("cancel pending update: %w", err)
2536 } else if bound {
2537 return fmt.Errorf("cancel pending update: installed release-unit state is already recorded")
2538 }
2539 if err := verifyPreparedFileUpdateTargets(tx); err != nil {
2540 return fmt.Errorf("cancel pending update: %w", err)
2541 }
2542 default:
2543 return fmt.Errorf("cancel pending update: unsupported target kind %q", tx.TargetKind)
2544 }
2545 return nil
2546 }
2547 if err := verifyCancellationState(); err != nil {
2548 return err
2549 }
2550 if err := removePendingUpdateExactVerified(tx, verifyCancellationState); err != nil {
2551 return err
2552 }
2553 if tx.TargetKind == "file" {
2554 removeUpdateBackups(tx)
2555 }
2556 return nil
2557 }
2558
2559 func removeUpdateBackups(tx *UpdateTransaction) {
2560 if tx == nil {
2561 return
2562 }
2563 if tx.TargetKind == "app-bundle" {
2564 _ = removeUpdateBackupTreeMatching(tx.BackupPath, tx.BackupTreeID)
2565 return
2566 }
2567 seen := map[string]struct{}{}
2568 for _, f := range pendingUpdateFiles(tx) {
2569 if f.MissingBefore || strings.TrimSpace(f.BackupPath) == "" {
2570 continue
2571 }
2572 key := canonicalRepairPath(f.BackupPath)
2573 if _, ok := seen[key]; ok {
2574 continue
2575 }
2576 seen[key] = struct{}{}
2577 _ = removeUpdateBackupFileMatching(f.BackupPath, f.SHA256)
2578 }
2579 }
2580
2581 func removeUpdateBackupFileMatching(path, expectedSHA256 string) error {
2582 expectedSHA256 = strings.TrimSpace(expectedSHA256)
2583 if path == "" || expectedSHA256 == "" {
2584 return nil
2585 }
2586 return removeUpdateNodeMatching(path, func(moved string) error {
2587 info, err := os.Lstat(moved)
2588 if err != nil {
2589 return err
2590 }
2591 if !info.Mode().IsRegular() {
2592 return fmt.Errorf("update backup changed type")
2593 }
2594 actual, err := hashFile(moved)
2595 if err != nil {
2596 return err
2597 }
2598 if !strings.EqualFold(actual, expectedSHA256) {
2599 return fmt.Errorf("update backup hash changed")
2600 }
2601 return nil
2602 }, false)
2603 }
2604
2605 func removeUpdateBackupTreeMatching(path, expectedTreeID string) error {
2606 expectedTreeID = strings.TrimSpace(expectedTreeID)
2607 if path == "" || expectedTreeID == "" {
2608 return nil
2609 }
2610 return removeUpdateNodeMatching(path, func(moved string) error {
2611 actual, err := repairPlanTreeContentStateID(moved)
2612 if err != nil {
2613 return err
2614 }
2615 if actual != expectedTreeID {
2616 return fmt.Errorf("update backup tree changed")
2617 }
2618 return nil
2619 }, true)
2620 }
2621
2622 func removeUpdateNodeMatching(path string, verify func(string) error, directory bool) error {
2623 cleanup, err := moveRepairNodeToUniqueCleanup(path)
2624 if err != nil || cleanup == "" {
2625 return err
2626 }
2627 updateCleanupAfterRename(path, cleanup)
2628 restore := func(cause error) error {
2629 if restoreErr := renameRepairNodeNoReplace(cleanup, path); restoreErr != nil {
2630 return fmt.Errorf("%w; changed update node retained at %s: %w", cause, cleanup, restoreErr)
2631 }
2632 return cause
2633 }
2634 if err := verify(cleanup); err != nil {
2635 return restore(err)
2636 }
2637 if directory {
2638 return os.RemoveAll(cleanup)
2639 }
2640 if err := os.Remove(cleanup); err != nil {
2641 return restore(err)
2642 }
2643 return nil
2644 }
2645
2646 func RollbackPendingUpdate() (UpdateRollbackResult, error) {
2647 return rollbackPendingUpdateInvocation("", "", "")
2648 }
2649
2650 // RollbackPendingUpdateMatching rolls back only the exact transaction prepared
2651 // by the caller. This is used when an apply attempt fails after another process
2652 // may already have replaced pending-update.json with a same-version retry.
2653 func RollbackPendingUpdateMatching(expectedToVersion, expectedCreatedAt string) (UpdateRollbackResult, error) {
2654 expectedToVersion = strings.TrimSpace(expectedToVersion)
2655 expectedCreatedAt = strings.TrimSpace(expectedCreatedAt)
2656 if expectedToVersion == "" || expectedCreatedAt == "" {
2657 return UpdateRollbackResult{}, fmt.Errorf("rollback update: transaction identity is incomplete")
2658 }
2659 return rollbackPendingUpdateInvocation(expectedToVersion, expectedCreatedAt, "")
2660 }
2661
2662 func rollbackPendingUpdateState(expectedStateID string, expectedStates map[string]string) (UpdateRollbackResult, error) {
2663 return rollbackPendingUpdateMatching("", "", expectedStateID, expectedStates, "", true)
2664 }
2665
2666 // RollbackPendingUpdateExact restores only the complete transaction returned by
2667 // prepare. It is used after a platform apply failure where a later same-version
2668 // transaction must remain untouched.
2669 func RollbackPendingUpdateExact(expected *UpdateTransaction) (UpdateRollbackResult, error) {
2670 if expected == nil {
2671 return UpdateRollbackResult{}, fmt.Errorf("rollback update: transaction identity is incomplete")
2672 }
2673 return rollbackPendingUpdateInvocation(
2674 expected.ToVersion,
2675 expected.CreatedAt,
2676 repairPlanStateID(expected),
2677 )
2678 }
2679
2680 func rollbackPendingUpdateInvocation(
2681 expectedToVersion, expectedCreatedAt, expectedTransactionID string,
2682 ) (UpdateRollbackResult, error) {
2683 tx, stateID, states, err := readPendingUpdateInvocation()
2684 if err != nil {
2685 if os.IsNotExist(err) {
2686 return UpdateRollbackResult{}, nil
2687 }
2688 return UpdateRollbackResult{}, err
2689 }
2690 if expected := strings.TrimSpace(expectedToVersion); expected != "" && expected != strings.TrimSpace(tx.ToVersion) {
2691 return UpdateRollbackResult{}, nil
2692 }
2693 if expected := strings.TrimSpace(expectedCreatedAt); expected != "" && expected != strings.TrimSpace(tx.CreatedAt) {
2694 return UpdateRollbackResult{}, nil
2695 }
2696 if expected := strings.TrimSpace(expectedTransactionID); expected != "" && expected != UpdateTransactionID(tx) {
2697 return UpdateRollbackResult{}, fmt.Errorf("rollback update: pending transaction changed")
2698 }
2699 return rollbackPendingUpdateMatching(
2700 tx.ToVersion,
2701 tx.CreatedAt,
2702 stateID,
2703 states,
2704 UpdateTransactionID(tx),
2705 false,
2706 )
2707 }
2708
2709 func rollbackPendingUpdateMatching(
2710 expectedToVersion, expectedCreatedAt, expectedStateID string,
2711 expectedStates map[string]string,
2712 expectedTransactionID string,
2713 callerConfirmedState bool,
2714 ) (UpdateRollbackResult, error) {
2715 // The expected-match checks below re-run under the strict lock, so a
2716 // transaction committed, cancelled, or replaced while waiting here is never
2717 // acted upon.
2718 unlock, err := acquirePendingUpdateLock()
2719 if err != nil {
2720 return UpdateRollbackResult{}, fmt.Errorf("rollback update: lock pending transaction: %w", err)
2721 }
2722 defer unlock()
2723 return rollbackPendingUpdateMatchingLocked(
2724 expectedToVersion,
2725 expectedCreatedAt,
2726 expectedStateID,
2727 expectedStates,
2728 expectedTransactionID,
2729 callerConfirmedState,
2730 )
2731 }
2732
2733 // rollbackPendingUpdateMatchingLocked performs the transition while the caller
2734 // holds the pending-update lock. RecoverFailedInstall uses this form so failure
2735 // marker correlation, rollback, and marker cleanup are one serialized state
2736 // transition.
2737 func rollbackPendingUpdateMatchingLocked(
2738 expectedToVersion, expectedCreatedAt, expectedStateID string,
2739 expectedStates map[string]string,
2740 expectedTransactionID string,
2741 callerConfirmedState bool,
2742 ) (UpdateRollbackResult, error) {
2743 tx, err := ReadPendingUpdate()
2744 if err != nil {
2745 if os.IsNotExist(err) {
2746 return UpdateRollbackResult{}, nil
2747 }
2748 return UpdateRollbackResult{}, err
2749 }
2750 if expected := strings.TrimSpace(expectedToVersion); expected != "" && expected != strings.TrimSpace(tx.ToVersion) {
2751 return UpdateRollbackResult{}, nil
2752 }
2753 if expected := strings.TrimSpace(expectedCreatedAt); expected != "" && expected != strings.TrimSpace(tx.CreatedAt) {
2754 return UpdateRollbackResult{}, nil
2755 }
2756 if expected := strings.TrimSpace(expectedTransactionID); expected != "" && expected != repairPlanStateID(tx) {
2757 return UpdateRollbackResult{}, fmt.Errorf("rollback update: pending transaction changed")
2758 }
2759 hasBoundState := strings.TrimSpace(expectedStateID) != ""
2760 if !hasBoundState {
2761 expectedStateID, expectedStates = pendingUpdateBoundPreview(tx)
2762 }
2763 // Share release-unit target locks with other repair mutations so two
2764 // REASONIX_HOME profiles cannot quarantine or restore the same binaries
2765 // through different pending-update locks.
2766 unlockTargets, lockErr := lockRepairMutations(pendingUpdateTargetPaths(tx)...)
2767 if lockErr != nil {
2768 return UpdateRollbackResult{}, fmt.Errorf("rollback update: lock targets: %w", lockErr)
2769 }
2770 defer unlockTargets()
2771 current, err := ReadPendingUpdate()
2772 if err != nil {
2773 return UpdateRollbackResult{}, fmt.Errorf("rollback update: re-read pending transaction: %w", err)
2774 }
2775 if !reflect.DeepEqual(tx, current) {
2776 return UpdateRollbackResult{}, fmt.Errorf("rollback update: pending transaction changed while waiting")
2777 }
2778 tx = current
2779 verifyBoundState := func() error {
2780 expected := strings.TrimSpace(expectedStateID)
2781 if expected == "" {
2782 return nil
2783 }
2784 actual, _ := pendingUpdateBoundPreview(tx)
2785 if expected != actual {
2786 return fmt.Errorf("repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)", expected, actual)
2787 }
2788 return nil
2789 }
2790 if expected := strings.TrimSpace(expectedStateID); expected != "" {
2791 if err := verifyBoundState(); err != nil {
2792 if callerConfirmedState {
2793 return UpdateRollbackResult{}, nil
2794 }
2795 return UpdateRollbackResult{}, err
2796 }
2797 }
2798 confirmedStates := expectedStates
2799 if !callerConfirmedState {
2800 // Invocation-local binding proves that the live unit did not drift while
2801 // this rollback waited for locks. It does not prove that an unbound live
2802 // node belongs to the pending transaction and therefore cannot authorize
2803 // deleting the retained aside after restore.
2804 confirmedStates = nil
2805 }
2806 result := UpdateRollbackResult{FromVersion: tx.ToVersion, ToVersion: tx.FromVersion, TargetPath: tx.TargetPath}
2807 var verifyCommitState func() error
2808 switch tx.TargetKind {
2809 case "file":
2810 files, _, installedErr := installedFileUpdateTargets(tx, false)
2811 if installedErr != nil {
2812 return result, fmt.Errorf("rollback update: %w", installedErr)
2813 }
2814 // Verify every backup before touching any binary: a partial restore
2815 // would recreate exactly the mixed-version install rollback exists to
2816 // prevent. A missing hash is a validation failure, not a bypass —
2817 // ReadPendingUpdate already rejects hashless file transactions, so
2818 // this guards hand-crafted callers.
2819 for _, f := range files {
2820 if f.MissingBefore {
2821 continue
2822 }
2823 if strings.TrimSpace(f.SHA256) == "" {
2824 return result, fmt.Errorf("rollback update: backup hash missing for %s", filepath.Base(f.TargetPath))
2825 }
2826 got, hashErr := hashFile(f.BackupPath)
2827 if hashErr != nil || !strings.EqualFold(got, f.SHA256) {
2828 return result, fmt.Errorf("rollback update: backup hash mismatch for %s", filepath.Base(f.TargetPath))
2829 }
2830 }
2831 mixed, restoreErr := restoreReleaseUnit(files, verifyBoundState, confirmedStates)
2832 if restoreErr != nil {
2833 result.MixedInstall = mixed
2834 return result, fmt.Errorf("rollback update: %w", restoreErr)
2835 }
2836 verifyCommitState = func() error {
2837 return verifyRestoredFileUpdateTargets(files)
2838 }
2839 case "app-bundle":
2840 confirmedBackupState := strings.TrimSpace(confirmedStates[tx.BackupPath])
2841 if strings.TrimSpace(tx.BackupTreeID) == "" &&
2842 (!callerConfirmedState || confirmedBackupState == "") {
2843 return result, fmt.Errorf("rollback update: backup bundle identity is missing; explicit preview confirmation is required")
2844 }
2845 backupInfo, err := os.Lstat(tx.BackupPath)
2846 if err != nil {
2847 if os.IsNotExist(err) && strings.TrimSpace(tx.BackupTreeID) != "" {
2848 actual, digestErr := repairPlanTreeContentStateID(tx.TargetPath)
2849 if digestErr == nil && actual == tx.BackupTreeID {
2850 result.RolledBack = true
2851 if removeErr := removePendingUpdateExactVerified(tx, func() error {
2852 current, currentErr := repairPlanTreeContentStateID(tx.TargetPath)
2853 if currentErr != nil || current != tx.BackupTreeID {
2854 return fmt.Errorf("rollback update: restored bundle changed before commit")
2855 }
2856 return nil
2857 }); removeErr != nil {
2858 return result, fmt.Errorf("rollback update: clear pending transaction: %w", removeErr)
2859 }
2860 return result, nil
2861 }
2862 }
2863 return result, fmt.Errorf("rollback update: backup bundle: %w", err)
2864 }
2865 if !backupInfo.IsDir() {
2866 return result, fmt.Errorf("rollback update: backup bundle is not a directory")
2867 }
2868 if tx.BackupTreeID != "" {
2869 actual, digestErr := repairPlanTreeContentStateID(tx.BackupPath)
2870 if digestErr != nil || actual != tx.BackupTreeID {
2871 return result, fmt.Errorf("rollback update: backup bundle digest mismatch")
2872 }
2873 } else if err := verifyRepairPlanReleaseNodeStateFor(
2874 tx.BackupPath,
2875 tx.BackupPath,
2876 confirmedBackupState,
2877 ); err != nil {
2878 return result, fmt.Errorf("rollback update: confirmed backup bundle changed: %w", err)
2879 }
2880 if err := verifyBoundState(); err != nil {
2881 return result, err
2882 }
2883 failed := ""
2884 retainedFailed := false
2885 retainedFailedOwned := false
2886 retainedFailedState := ""
2887 if _, statErr := os.Lstat(tx.TargetPath); statErr == nil {
2888 retainedFailedState = repairPlanReleaseNodeState(tx.TargetPath)
2889 var retainErr error
2890 failed, retainErr = retainUpdateRollbackNode(tx.TargetPath, "reasonix-failed")
2891 if retainErr != nil {
2892 return result, fmt.Errorf("rollback update: move failed bundle: %w", retainErr)
2893 }
2894 retainedFailed = true
2895 if verifyErr := verifyRepairPlanReleaseNodeStateFor(failed, tx.TargetPath, retainedFailedState); verifyErr != nil {
2896 if restoreErr := rollbackSwapRename(failed, tx.TargetPath); restoreErr != nil {
2897 result.MixedInstall = true
2898 return result, fmt.Errorf("%w; preserve moved live bundle at %s: %w", verifyErr, failed, restoreErr)
2899 }
2900 return result, verifyErr
2901 }
2902 if strings.TrimSpace(tx.HandoffAppTreeID) != "" {
2903 retainedFailedOwned = VerifyAppBundleUpdateHandoffReplacement(tx, failed) == nil
2904 }
2905 if expected := confirmedStates[tx.TargetPath]; expected != "" {
2906 if verifyErr := verifyRepairPlanReleaseNodeStateFor(failed, tx.TargetPath, expected); verifyErr != nil {
2907 if restoreErr := rollbackSwapRename(failed, tx.TargetPath); restoreErr != nil {
2908 result.MixedInstall = true
2909 return result, fmt.Errorf("%w; preserve moved live bundle at %s: %w", verifyErr, failed, restoreErr)
2910 }
2911 return result, verifyErr
2912 }
2913 retainedFailedOwned = true
2914 }
2915 } else if !os.IsNotExist(statErr) {
2916 return result, fmt.Errorf("rollback update: inspect live bundle: %w", statErr)
2917 }
2918 if _, statErr := os.Lstat(tx.TargetPath); statErr == nil {
2919 result.MixedInstall = retainedFailed
2920 return result, fmt.Errorf("rollback update: target bundle was recreated before restore")
2921 } else if !os.IsNotExist(statErr) {
2922 result.MixedInstall = retainedFailed
2923 return result, fmt.Errorf("rollback update: inspect restore target: %w", statErr)
2924 }
2925 if err := rollbackSwapRename(tx.BackupPath, tx.TargetPath); err != nil {
2926 if retainedFailed {
2927 if verifyErr := verifyRepairPlanReleaseNodeStateFor(failed, tx.TargetPath, retainedFailedState); verifyErr != nil {
2928 result.MixedInstall = true
2929 return result, fmt.Errorf("rollback update: restore bundle: %w (retained live bundle changed at %s: %w)", err, failed, verifyErr)
2930 }
2931 if restoreErr := rollbackSwapRename(failed, tx.TargetPath); restoreErr != nil {
2932 result.MixedInstall = true
2933 return result, fmt.Errorf("rollback update: restore bundle: %w (preserve replacement at %s: %w)", err, failed, restoreErr)
2934 }
2935 }
2936 return result, fmt.Errorf("rollback update: restore bundle: %w", err)
2937 }
2938 restoredTreeID, digestErr := repairPlanTreeContentStateID(tx.TargetPath)
2939 restoredMatches := digestErr == nil
2940 if strings.TrimSpace(tx.BackupTreeID) != "" {
2941 restoredMatches = restoredMatches && restoredTreeID == tx.BackupTreeID
2942 } else if restoredMatches {
2943 restoredMatches = verifyRepairPlanReleaseNodeStateFor(
2944 tx.TargetPath,
2945 tx.BackupPath,
2946 confirmedBackupState,
2947 ) == nil
2948 }
2949 if !restoredMatches {
2950 mismatchErr := fmt.Errorf("rollback update: restored bundle digest mismatch")
2951 rejected, moveErr := moveRepairNodeToUniqueCleanup(tx.TargetPath)
2952 if moveErr != nil || rejected == "" {
2953 result.MixedInstall = true
2954 if moveErr != nil {
2955 return result, fmt.Errorf("%w; retain rejected bundle: %w", mismatchErr, moveErr)
2956 }
2957 return result, fmt.Errorf("%w; rejected bundle disappeared before compensation", mismatchErr)
2958 }
2959 if !retainedFailed {
2960 result.MixedInstall = true
2961 return result, fmt.Errorf("%w; rejected bundle retained at %s and no prior live bundle is available", mismatchErr, rejected)
2962 }
2963 if verifyErr := verifyRepairPlanReleaseNodeStateFor(failed, tx.TargetPath, retainedFailedState); verifyErr != nil {
2964 result.MixedInstall = true
2965 return result, fmt.Errorf("%w; rejected bundle retained at %s; prior live bundle changed at %s: %w", mismatchErr, rejected, failed, verifyErr)
2966 }
2967 if restoreErr := rollbackSwapRename(failed, tx.TargetPath); restoreErr != nil {
2968 result.MixedInstall = true
2969 return result, fmt.Errorf("%w; rejected bundle retained at %s; restore prior live bundle: %w", mismatchErr, rejected, restoreErr)
2970 }
2971 if verifyErr := verifyRepairPlanReleaseNodeStateFor(tx.TargetPath, tx.TargetPath, retainedFailedState); verifyErr != nil {
2972 result.MixedInstall = true
2973 return result, fmt.Errorf("%w; rejected bundle retained at %s; restored prior live bundle changed: %w", mismatchErr, rejected, verifyErr)
2974 }
2975 return result, fmt.Errorf("%w; rejected bundle retained at %s and prior live bundle restored", mismatchErr, rejected)
2976 }
2977 verifyCommitState = func() error {
2978 current, currentErr := repairPlanTreeContentStateID(tx.TargetPath)
2979 if currentErr != nil {
2980 return fmt.Errorf("rollback update: read restored bundle before commit: %w", currentErr)
2981 }
2982 if current != restoredTreeID {
2983 return fmt.Errorf("rollback update: restored bundle changed before commit")
2984 }
2985 return nil
2986 }
2987 if retainedFailed && retainedFailedOwned {
2988 _ = removeUpdateNodeMatching(failed, func(moved string) error {
2989 return verifyRepairPlanReleaseNodeStateFor(moved, tx.TargetPath, retainedFailedState)
2990 }, true)
2991 }
2992 default:
2993 return result, fmt.Errorf("rollback update: unsupported target kind %q", tx.TargetKind)
2994 }
2995 if err := verifyCommitState(); err != nil {
2996 return result, err
2997 }
2998 result.RolledBack = true
2999 if err := removePendingUpdateExactVerified(tx, verifyCommitState); err != nil {
3000 return result, fmt.Errorf("rollback update: clear pending transaction: %w", err)
3001 }
3002 if tx.TargetKind == "file" {
3003 _ = removeInstalledFileUpdateState(tx)
3004 }
3005 return result, nil
3006 }
3007
3008 func retainUpdateRollbackNode(path, suffix string) (string, error) {
3009 for attempt := range 16 {
3010 retained := fmt.Sprintf(
3011 "%s.%s-%d-%d",
3012 path,
3013 suffix,
3014 time.Now().UTC().UnixNano(),
3015 attempt,
3016 )
3017 if err := rollbackSwapRename(path, retained); err != nil {
3018 if os.IsExist(err) {
3019 continue
3020 }
3021 return "", err
3022 }
3023 return retained, nil
3024 }
3025 return "", fmt.Errorf("cannot allocate retained update path")
3026 }
3027
3028 func verifyRestoredFileUpdateTargets(files []UpdateTransactionFile) error {
3029 for _, f := range files {
3030 info, err := os.Lstat(f.TargetPath)
3031 if f.MissingBefore {
3032 if os.IsNotExist(err) {
3033 continue
3034 }
3035 if err != nil {
3036 return fmt.Errorf("verify restored release unit %s: %w", filepath.Base(f.TargetPath), err)
3037 }
3038 return fmt.Errorf("verify restored release unit %s: unexpected file appeared", filepath.Base(f.TargetPath))
3039 }
3040 if err != nil {
3041 return fmt.Errorf("verify restored release unit %s: %w", filepath.Base(f.TargetPath), err)
3042 }
3043 if !info.Mode().IsRegular() {
3044 return fmt.Errorf("verify restored release unit %s: file changed type", filepath.Base(f.TargetPath))
3045 }
3046 got, hashErr := hashFile(f.TargetPath)
3047 if hashErr != nil {
3048 return fmt.Errorf("verify restored release unit %s: %w", filepath.Base(f.TargetPath), hashErr)
3049 }
3050 if !strings.EqualFold(got, f.SHA256) {
3051 return fmt.Errorf("verify restored release unit %s: hash mismatch", filepath.Base(f.TargetPath))
3052 }
3053 }
3054 return nil
3055 }
3056
3057 // Rename/copy indirection so tests can inject mid-unit failures.
3058 var (
3059 rollbackStageCopy = copyFileWithHashCreate
3060 rollbackPublishStage = renameRepairNodeNoReplace
3061 rollbackSwapRename = renameRepairNodeNoReplace
3062 removePendingUpdateFile = os.Remove
3063 pendingUpdateBeforeCleanup = func(string) {}
3064 updateCleanupAfterRename = func(string, string) {}
3065 fileUpdateAfterRetain = func(string, string) {}
3066 installedUpdateAfterCreate = func(string) {}
3067 )
3068
3069 // restoreReleaseUnit swaps every backup into place with compensation, so a
3070 // failed rollback never leaves a mixed old/new install. Phase 1 stages each
3071 // backup next to its target — a copy can fail halfway (disk full, unreadable
3072 // backup) and staging keeps the live binaries untouched until every byte is
3073 // on the target filesystem. Phase 2 swaps via renames only: each target moves
3074 // aside first (renaming works even for the running executable, where
3075 // overwriting does not), so a failure renames the asides back and the unit
3076 // stays coherent on the new version for a retried rollback. Only when that
3077 // unwinding itself fails is the install reported as mixed.
3078 func restoreReleaseUnit(
3079 files []UpdateTransactionFile,
3080 verifyBeforeSwap func() error,
3081 expectedStates map[string]string,
3082 ) (mixed bool, err error) {
3083 stages := make([]string, len(files))
3084 defer func() {
3085 for i, stage := range stages {
3086 if stage != "" {
3087 _ = removeUpdateBackupFileMatching(stage, files[i].SHA256)
3088 }
3089 }
3090 }()
3091 for i, f := range files {
3092 if f.MissingBefore {
3093 continue
3094 }
3095 mode := os.FileMode(0o700)
3096 if st, statErr := os.Stat(f.TargetPath); statErr == nil {
3097 mode = st.Mode().Perm()
3098 }
3099 stage, stagedSHA256, copyErr := stageUpdateRollbackBackup(f, mode)
3100 if copyErr != nil {
3101 return false, fmt.Errorf("stage %s: %w", filepath.Base(f.TargetPath), copyErr)
3102 }
3103 stages[i] = stage
3104 // The backup can change after the preflight hash but before or during
3105 // this copy. Bind the bytes that will actually be installed, not only
3106 // the source path observed before staging.
3107 if !strings.EqualFold(stagedSHA256, f.SHA256) {
3108 return false, fmt.Errorf("stage %s: backup hash mismatch", filepath.Base(f.TargetPath))
3109 }
3110 }
3111 if verifyBeforeSwap != nil {
3112 if err := verifyBeforeSwap(); err != nil {
3113 return false, err
3114 }
3115 }
3116 // A crash can leave an old-version target beside the retained new-version
3117 // aside after the stage-to-target rename. Recognize that exact state before
3118 // touching any entry so a retry preserves the aside for compensation instead
3119 // of overwriting it with the already-restored target.
3120 alreadyRestored := make([]bool, len(files))
3121 preexistingAside := make([]bool, len(files))
3122 for i, f := range files {
3123 aside := f.TargetPath + ".reasonix-rollback-aside"
3124 asideInfo, err := os.Lstat(aside)
3125 if err != nil {
3126 if os.IsNotExist(err) {
3127 continue
3128 }
3129 return false, fmt.Errorf("inspect retained %s: %w", filepath.Base(f.TargetPath), err)
3130 }
3131 if !asideInfo.Mode().IsRegular() {
3132 return false, fmt.Errorf("ambiguous rollback state for %s", filepath.Base(f.TargetPath))
3133 }
3134 preexistingAside[i] = true
3135 if _, err := os.Lstat(f.TargetPath); err != nil {
3136 if os.IsNotExist(err) {
3137 continue
3138 }
3139 return false, fmt.Errorf("inspect restored %s: %w", filepath.Base(f.TargetPath), err)
3140 }
3141 if f.MissingBefore {
3142 return false, fmt.Errorf("ambiguous rollback state for %s", filepath.Base(f.TargetPath))
3143 }
3144 got, err := hashFile(f.TargetPath)
3145 if err != nil || !strings.EqualFold(got, f.SHA256) {
3146 return false, fmt.Errorf("ambiguous rollback state for %s", filepath.Base(f.TargetPath))
3147 }
3148 alreadyRestored[i] = true
3149 }
3150 asides := make([]string, len(files))
3151 retainedStates := make([]string, len(files))
3152 processed := make([]bool, len(files))
3153 restoreAttempted := make([]bool, len(files))
3154 preserveAside := make([]bool, len(files))
3155 ownedRetained := make([]bool, len(files))
3156 publishedStates := make([]string, len(files))
3157 failedIndex := -1
3158 var swapErr error
3159 for i, f := range files {
3160 aside := f.TargetPath + ".reasonix-rollback-aside"
3161 if alreadyRestored[i] {
3162 asides[i] = aside
3163 processed[i] = true
3164 continue
3165 }
3166 retainedState := repairPlanReleaseNodeState(f.TargetPath)
3167 if renameErr := rollbackSwapRename(f.TargetPath, aside); renameErr != nil {
3168 if os.IsNotExist(renameErr) {
3169 // A rollback interrupted between renames may have consumed this
3170 // target while retaining the new binary at the fixed aside path.
3171 // Preserve that copy for compensation until the retry succeeds.
3172 if f.MissingBefore {
3173 aside = ""
3174 } else if _, statErr := os.Lstat(aside); statErr != nil {
3175 aside = ""
3176 }
3177 } else {
3178 failedIndex = i
3179 swapErr = fmt.Errorf("retain %s: %w", filepath.Base(f.TargetPath), renameErr)
3180 break
3181 }
3182 }
3183 asides[i] = aside
3184 if aside != "" && !preexistingAside[i] {
3185 if verifyErr := verifyRepairPlanReleaseNodeStateFor(aside, f.TargetPath, retainedState); verifyErr != nil {
3186 failedIndex = i
3187 swapErr = verifyErr
3188 if restoreErr := restoreRepairNodeIfAbsent(aside, f.TargetPath); restoreErr != nil {
3189 preserveAside[i] = true
3190 swapErr = fmt.Errorf("%w; preserve moved live target at %s: %w", verifyErr, aside, restoreErr)
3191 } else {
3192 asides[i] = ""
3193 }
3194 break
3195 }
3196 retainedStates[i] = retainedState
3197 if installedState := strings.TrimSpace(f.InstalledStateID); installedState != "" {
3198 if verifyErr := verifyRepairPlanReleaseNodeStateFor(aside, f.TargetPath, installedState); verifyErr != nil {
3199 failedIndex = i
3200 swapErr = fmt.Errorf("installed release file %s changed before rollback: %w", filepath.Base(f.TargetPath), verifyErr)
3201 if restoreErr := restoreRepairNodeIfAbsent(aside, f.TargetPath); restoreErr != nil {
3202 preserveAside[i] = true
3203 swapErr = fmt.Errorf("%w; preserve moved live target at %s: %w", swapErr, aside, restoreErr)
3204 } else {
3205 asides[i] = ""
3206 }
3207 break
3208 }
3209 ownedRetained[i] = true
3210 }
3211 }
3212 if expected := expectedStates[f.TargetPath]; expected != "" && aside != "" {
3213 if verifyErr := verifyRepairPlanStateIDFor(aside, f.TargetPath, expected); verifyErr != nil {
3214 failedIndex = i
3215 swapErr = verifyErr
3216 if restoreErr := restoreRepairNodeIfAbsent(aside, f.TargetPath); restoreErr != nil {
3217 preserveAside[i] = true
3218 swapErr = fmt.Errorf("%w; preserve moved live target at %s: %w", verifyErr, aside, restoreErr)
3219 } else {
3220 asides[i] = ""
3221 }
3222 break
3223 }
3224 ownedRetained[i] = true
3225 }
3226 if f.MissingBefore {
3227 // The old release did not contain this path. Retaining the new file
3228 // at the aside path removes it from the live release atomically; it
3229 // is deleted only after the whole rollback succeeds.
3230 processed[i] = true
3231 continue
3232 }
3233 restoreAttempted[i] = true
3234 // Stage and target share a filesystem. A no-replace rename publishes the
3235 // fully verified bytes atomically, consumes the writable staging alias,
3236 // and refuses to overwrite a target recreated after the confirmed node
3237 // moved aside.
3238 if publishErr := rollbackPublishStage(stages[i], f.TargetPath); publishErr != nil {
3239 failedIndex = i
3240 swapErr = fmt.Errorf("restore %s: %w", filepath.Base(f.TargetPath), publishErr)
3241 break
3242 }
3243 stages[i] = ""
3244 publishedStates[i] = repairPlanReleaseNodeState(f.TargetPath)
3245 processed[i] = true
3246 publishedHash, hashErr := hashFile(f.TargetPath)
3247 if hashErr != nil || !strings.EqualFold(publishedHash, f.SHA256) {
3248 failedIndex = i
3249 if hashErr != nil {
3250 swapErr = fmt.Errorf("verify restored %s: %w", filepath.Base(f.TargetPath), hashErr)
3251 } else {
3252 swapErr = fmt.Errorf("verify restored %s: hash mismatch", filepath.Base(f.TargetPath))
3253 }
3254 break
3255 }
3256 }
3257 if swapErr == nil {
3258 for _, f := range files {
3259 info, verifyErr := os.Lstat(f.TargetPath)
3260 if f.MissingBefore {
3261 if os.IsNotExist(verifyErr) {
3262 continue
3263 }
3264 if verifyErr != nil {
3265 swapErr = fmt.Errorf("verify restored release unit %s: %w", filepath.Base(f.TargetPath), verifyErr)
3266 } else {
3267 swapErr = fmt.Errorf("verify restored release unit %s: unexpected file appeared", filepath.Base(f.TargetPath))
3268 }
3269 break
3270 }
3271 if verifyErr != nil {
3272 swapErr = fmt.Errorf("verify restored release unit %s: %w", filepath.Base(f.TargetPath), verifyErr)
3273 break
3274 }
3275 if !info.Mode().IsRegular() {
3276 swapErr = fmt.Errorf("verify restored release unit %s: file changed type", filepath.Base(f.TargetPath))
3277 break
3278 }
3279 got, hashErr := hashFile(f.TargetPath)
3280 if hashErr != nil || !strings.EqualFold(got, f.SHA256) {
3281 if hashErr != nil {
3282 swapErr = fmt.Errorf("verify restored release unit %s: %w", filepath.Base(f.TargetPath), hashErr)
3283 } else {
3284 swapErr = fmt.Errorf("verify restored release unit %s: hash mismatch", filepath.Base(f.TargetPath))
3285 }
3286 break
3287 }
3288 }
3289 }
3290 if swapErr == nil {
3291 for i, f := range files {
3292 // Best-effort: on Windows the running executable's aside may linger
3293 // until the process exits, but it is no longer a live entry point.
3294 aside := f.TargetPath + ".reasonix-rollback-aside"
3295 if !preexistingAside[i] && retainedStates[i] != "" && ownedRetained[i] {
3296 _ = removeUpdateNodeMatching(aside, func(moved string) error {
3297 return verifyRepairPlanReleaseNodeStateFor(moved, f.TargetPath, retainedStates[i])
3298 }, false)
3299 }
3300 }
3301 return false, nil
3302 }
3303 // Compensate: rename the new-version binaries back over the restored old
3304 // ones. A missing-before entry is compensated the same way: move the
3305 // retained new file back to its original path.
3306 for j, f := range files {
3307 if !processed[j] && j != failedIndex {
3308 continue
3309 }
3310 if preserveAside[j] {
3311 mixed = true
3312 continue
3313 }
3314 if preexistingAside[j] {
3315 // An aside inherited from a crashed process has no durable content
3316 // binding. Never move it back into an executable path during
3317 // compensation; leave recovery material in place and fail closed.
3318 mixed = true
3319 continue
3320 }
3321 if asides[j] != "" {
3322 if retainedStates[j] != "" {
3323 if verifyErr := verifyRepairPlanReleaseNodeStateFor(asides[j], f.TargetPath, retainedStates[j]); verifyErr != nil {
3324 mixed = true
3325 continue
3326 }
3327 }
3328 if _, statErr := os.Lstat(f.TargetPath); statErr == nil {
3329 // Atomically displace and verify only bytes this rollback
3330 // published. Anything else is restored or retained.
3331 if f.MissingBefore || publishedStates[j] == "" {
3332 mixed = true
3333 continue
3334 }
3335 if removeErr := removeUpdateNodeMatching(f.TargetPath, func(moved string) error {
3336 return verifyRepairPlanReleaseNodeStateFor(moved, f.TargetPath, publishedStates[j])
3337 }, false); removeErr != nil {
3338 mixed = true
3339 continue
3340 }
3341 } else if !os.IsNotExist(statErr) {
3342 mixed = true
3343 continue
3344 }
3345 if restoreErr := restoreRepairNodeIfAbsent(asides[j], f.TargetPath); restoreErr != nil {
3346 mixed = true
3347 }
3348 continue
3349 }
3350 if !f.MissingBefore && restoreAttempted[j] {
3351 // No retained new-version copy exists to put back after the old
3352 // backup was (or may have been) placed.
3353 mixed = true
3354 }
3355 }
3356 return mixed, swapErr
3357 }
3358
3359 func stageUpdateRollbackBackup(
3360 file UpdateTransactionFile,
3361 mode os.FileMode,
3362 ) (string, string, error) {
3363 for attempt := range 16 {
3364 stage := fmt.Sprintf(
3365 "%s.reasonix-rollback-stage-%d-%d",
3366 file.TargetPath,
3367 time.Now().UTC().UnixNano(),
3368 attempt,
3369 )
3370 stagedSHA256, err := rollbackStageCopy(file.BackupPath, stage, mode)
3371 if err == nil {
3372 return stage, stagedSHA256, nil
3373 }
3374 if os.IsExist(err) {
3375 continue
3376 }
3377 return "", "", err
3378 }
3379 return "", "", fmt.Errorf("cannot allocate rollback staging path")
3380 }
3381
3382 // allowedUpdateTargetBase whitelists the packaged binaries an update
3383 // transaction may name. The main executable names are only valid as the
3384 // primary target; Guard/launcher artifacts only as release-unit siblings.
3385 func allowedUpdateTargetBase(base string, primary bool) bool {
3386 switch strings.ToLower(base) {
3387 case "reasonix-desktop", "reasonix-desktop.exe":
3388 return primary
3389 case "reasonix.exe":
3390 return !primary
3391 case "reasonix", "reasonix-guard", "reasonix-guard.exe", "reasonix-launcher.exe", "reasonix-update-helper.exe", "reasonix-cli.exe":
3392 return !primary
3393 default:
3394 return false
3395 }
3396 }
3397
3398 func validateUpdateTransaction(tx *UpdateTransaction) error {
3399 if tx == nil || tx.SchemaVersion != updateTransactionVersion || strings.TrimSpace(tx.ToVersion) == "" {
3400 return fmt.Errorf("pending update metadata is incomplete")
3401 }
3402 launcher, err := repairExecutable()
3403 if err != nil {
3404 return fmt.Errorf("pending update launcher path is unavailable")
3405 }
3406 return validateUpdateTransactionForLauncher(tx, launcher)
3407 }
3408
3409 func validateUpdateTransactionForLauncher(tx *UpdateTransaction, launcher string) error {
3410 if tx == nil || tx.SchemaVersion != updateTransactionVersion || strings.TrimSpace(tx.ToVersion) == "" {
3411 return fmt.Errorf("pending update metadata is incomplete")
3412 }
3413 if strings.TrimSpace(tx.Platform) == "" || strings.TrimSpace(tx.CreatedAt) == "" {
3414 return fmt.Errorf("pending update transaction identity is incomplete")
3415 }
3416 if _, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(tx.CreatedAt)); err != nil {
3417 return fmt.Errorf("pending update creation identity is invalid")
3418 }
3419 tx.TargetPath = filepath.Clean(tx.TargetPath)
3420 tx.BackupPath = filepath.Clean(tx.BackupPath)
3421 launcher = filepath.Clean(strings.TrimSpace(launcher))
3422 if launcher == "" || launcher == "." {
3423 return fmt.Errorf("pending update launcher path is unavailable")
3424 }
3425 if resolved, resolveErr := filepath.EvalSymlinks(launcher); resolveErr == nil {
3426 launcher = resolved
3427 }
3428 launcher = filepath.Clean(launcher)
3429 switch tx.TargetKind {
3430 case "file":
3431 if !allowedUpdateTargetBase(filepath.Base(tx.TargetPath), true) {
3432 return fmt.Errorf("pending update target is not a Reasonix executable")
3433 }
3434 launcherKey := canonicalRepairPath(launcher)
3435 targetKey := canonicalRepairPath(tx.TargetPath)
3436 if launcherKey == "" || targetKey == "" || filepath.Dir(launcherKey) != filepath.Dir(targetKey) {
3437 return fmt.Errorf("%w: pending update target is outside the current Guard installation", errPendingUpdateForeignInstall)
3438 }
3439 root := filepath.Clean(filepath.Join(config.MemoryUserDir(), "repair"))
3440 insideRepairDir := func(path string) bool {
3441 return pathInsideResolvedRoot(root, path)
3442 }
3443 if !insideRepairDir(tx.BackupPath) {
3444 return fmt.Errorf("pending update backup is outside the repair directory")
3445 }
3446 // Every restorable file must carry a hash — rollback promises to
3447 // verify all backups before touching any binary, so an unhashed entry
3448 // would silently weaken that gate.
3449 if strings.TrimSpace(tx.BackupSHA256) == "" {
3450 return fmt.Errorf("pending update backup hash is missing")
3451 }
3452 primaryListed := len(tx.Files) == 0
3453 seenTargets := make(map[string]struct{}, len(tx.Files))
3454 seenBackups := make(map[string]struct{}, len(tx.Files))
3455 for i := range tx.Files {
3456 f := &tx.Files[i]
3457 f.TargetPath = filepath.Clean(f.TargetPath)
3458 targetIdentity := canonicalRepairPath(f.TargetPath)
3459 if targetIdentity == "" {
3460 return fmt.Errorf("pending update release file path is invalid")
3461 }
3462 if _, duplicate := seenTargets[targetIdentity]; duplicate {
3463 return fmt.Errorf("pending update lists a duplicate release file")
3464 }
3465 seenTargets[targetIdentity] = struct{}{}
3466 primary := f.TargetPath == tx.TargetPath
3467 primaryListed = primaryListed || primary
3468 if !allowedUpdateTargetBase(filepath.Base(f.TargetPath), primary) {
3469 return fmt.Errorf("pending update lists an unexpected release file")
3470 }
3471 if filepath.Dir(f.TargetPath) != filepath.Dir(tx.TargetPath) {
3472 return fmt.Errorf("%w: pending update release file is outside the current Guard installation", errPendingUpdateForeignInstall)
3473 }
3474 if f.MissingBefore {
3475 if primary || strings.TrimSpace(f.BackupPath) != "" || strings.TrimSpace(f.SHA256) != "" {
3476 return fmt.Errorf("pending update missing release file metadata is invalid")
3477 }
3478 continue
3479 }
3480 f.BackupPath = filepath.Clean(f.BackupPath)
3481 if !insideRepairDir(f.BackupPath) {
3482 return fmt.Errorf("pending update backup is outside the repair directory")
3483 }
3484 if strings.TrimSpace(f.SHA256) == "" {
3485 return fmt.Errorf("pending update release file hash is missing")
3486 }
3487 backupIdentity := canonicalRepairPath(f.BackupPath)
3488 if backupIdentity == "" {
3489 return fmt.Errorf("pending update backup path is invalid")
3490 }
3491 if _, duplicate := seenBackups[backupIdentity]; duplicate {
3492 return fmt.Errorf("pending update lists a duplicate release backup")
3493 }
3494 seenBackups[backupIdentity] = struct{}{}
3495 if primary &&
3496 (f.BackupPath != tx.BackupPath || !strings.EqualFold(f.SHA256, tx.BackupSHA256)) {
3497 return fmt.Errorf("pending update primary backup metadata is inconsistent")
3498 }
3499 }
3500 installedStates := 0
3501 for _, f := range tx.Files {
3502 stateID := strings.TrimSpace(f.InstalledStateID)
3503 if stateID == "" {
3504 continue
3505 }
3506 if len(stateID) != sha256.Size*2 {
3507 return fmt.Errorf("pending update installed release-unit state is invalid")
3508 }
3509 if _, err := hex.DecodeString(stateID); err != nil {
3510 return fmt.Errorf("pending update installed release-unit state is invalid")
3511 }
3512 installedStates++
3513 }
3514 if installedStates != 0 && installedStates != len(tx.Files) {
3515 return fmt.Errorf("pending update installed release-unit state is incomplete")
3516 }
3517 if !primaryListed {
3518 return fmt.Errorf("pending update release unit omits the primary executable")
3519 }
3520 case "app-bundle":
3521 if !strings.HasSuffix(strings.ToLower(tx.TargetPath), ".app") || tx.BackupPath != tx.TargetPath+".reasonix-update-backup" {
3522 return fmt.Errorf("pending update bundle paths are invalid")
3523 }
3524 inside := tx.TargetPath + string(filepath.Separator)
3525 if !strings.HasPrefix(launcher, inside) {
3526 return fmt.Errorf("%w: pending update bundle is not the current Guard installation", errPendingUpdateForeignInstall)
3527 }
3528 if err := validateAppBundleHandoffMetadata(tx); err != nil {
3529 return fmt.Errorf("pending update %w", err)
3530 }
3531 if err := validateOrphanedAppBundleBackupMetadata(tx); err != nil {
3532 return fmt.Errorf("pending update %w", err)
3533 }
3534 default:
3535 return fmt.Errorf("pending update target kind is invalid")
3536 }
3537 return nil
3538 }
3539
3540 func pathInsideResolvedRoot(root, path string) bool {
3541 root = filepath.Clean(strings.TrimSpace(root))
3542 path = filepath.Clean(strings.TrimSpace(path))
3543 if root == "" || path == "" {
3544 return false
3545 }
3546 lexicalRel, err := filepath.Rel(root, path)
3547 if err != nil || lexicalRel == ".." || strings.HasPrefix(lexicalRel, ".."+string(filepath.Separator)) {
3548 return false
3549 }
3550 resolvedRoot, err := filepath.EvalSymlinks(root)
3551 if err != nil {
3552 return false
3553 }
3554 resolvedPath, err := filepath.EvalSymlinks(path)
3555 if err != nil {
3556 return false
3557 }
3558 resolvedRel, err := filepath.Rel(resolvedRoot, resolvedPath)
3559 return err == nil && resolvedRel != ".." &&
3560 !strings.HasPrefix(resolvedRel, ".."+string(filepath.Separator))
3561 }
3562
3563 func validateAppBundleHandoffMetadata(tx *UpdateTransaction) error {
3564 if tx == nil {
3565 return fmt.Errorf("handoff metadata is incomplete")
3566 }
3567 hasAny := strings.TrimSpace(tx.HandoffAppPath) != "" ||
3568 strings.TrimSpace(tx.HandoffStagingPath) != "" ||
3569 strings.TrimSpace(tx.HandoffAppTreeID) != "" ||
3570 strings.TrimSpace(tx.HandoffStagingTreeID) != "" ||
3571 tx.HandoffOwnerPID != 0
3572 if !hasAny {
3573 return nil
3574 }
3575 tx.HandoffAppPath = filepath.Clean(strings.TrimSpace(tx.HandoffAppPath))
3576 tx.HandoffStagingPath = filepath.Clean(strings.TrimSpace(tx.HandoffStagingPath))
3577 if tx.HandoffOwnerPID <= 0 ||
3578 !filepath.IsAbs(tx.HandoffAppPath) ||
3579 !filepath.IsAbs(tx.HandoffStagingPath) ||
3580 !strings.HasSuffix(strings.ToLower(tx.HandoffAppPath), ".app") {
3581 return fmt.Errorf("handoff metadata is incomplete")
3582 }
3583 if tx.HandoffAppPath == tx.TargetPath || tx.HandoffAppPath == tx.BackupPath {
3584 return fmt.Errorf("handoff app overlaps the installed bundle")
3585 }
3586 rel, err := filepath.Rel(tx.HandoffStagingPath, tx.HandoffAppPath)
3587 if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
3588 return fmt.Errorf("handoff app is outside its staging directory")
3589 }
3590 tempRoot := filepath.Clean(os.TempDir())
3591 stagingRel, err := filepath.Rel(tempRoot, tx.HandoffStagingPath)
3592 if err != nil || stagingRel == "." || stagingRel == ".." || strings.HasPrefix(stagingRel, ".."+string(filepath.Separator)) {
3593 return fmt.Errorf("handoff staging directory is outside the system temporary directory")
3594 }
3595 stagingBase := strings.Split(stagingRel, string(filepath.Separator))[0]
3596 if !strings.HasPrefix(stagingBase, "reasonix-mac-update-") {
3597 return fmt.Errorf("handoff staging directory has an unexpected name")
3598 }
3599 return nil
3600 }
3601
3602 func validateOrphanedAppBundleBackupMetadata(tx *UpdateTransaction) error {
3603 if tx == nil {
3604 return fmt.Errorf("orphaned backup metadata is incomplete")
3605 }
3606 path := strings.TrimSpace(tx.OrphanedBackupPath)
3607 treeID := strings.TrimSpace(tx.OrphanedBackupTreeID)
3608 if path == "" && treeID == "" {
3609 return nil
3610 }
3611 if tx.TargetKind != "app-bundle" || path == "" || treeID == "" {
3612 return fmt.Errorf("orphaned backup metadata is incomplete")
3613 }
3614 path = filepath.Clean(path)
3615 if !filepath.IsAbs(path) || filepath.Dir(path) != filepath.Dir(tx.BackupPath) {
3616 return fmt.Errorf("orphaned backup path is outside the app installation directory")
3617 }
3618 prefix := filepath.Base(tx.BackupPath) + ".reasonix-orphaned-"
3619 suffix, ok := strings.CutPrefix(filepath.Base(path), prefix)
3620 if !ok {
3621 return fmt.Errorf("orphaned backup path has an unexpected name")
3622 }
3623 parts := strings.Split(suffix, "-")
3624 if len(parts) != 2 {
3625 return fmt.Errorf("orphaned backup path has an unexpected name")
3626 }
3627 for _, part := range parts {
3628 if part == "" || strings.Trim(part, "0123456789") != "" {
3629 return fmt.Errorf("orphaned backup path has an unexpected name")
3630 }
3631 }
3632 if len(treeID) != sha256.Size*2 {
3633 return fmt.Errorf("orphaned backup digest is invalid")
3634 }
3635 if _, err := hex.DecodeString(treeID); err != nil {
3636 return fmt.Errorf("orphaned backup digest is invalid")
3637 }
3638 tx.OrphanedBackupPath = path
3639 tx.OrphanedBackupTreeID = treeID
3640 return nil
3641 }
3642
3643 func copyFileWithHash(src, dst string, mode os.FileMode) (string, error) {
3644 return copyFileWithHashMode(src, dst, mode, false)
3645 }
3646
3647 func copyFileWithHashCreate(src, dst string, mode os.FileMode) (string, error) {
3648 return copyFileWithHashMode(src, dst, mode, true)
3649 }
3650
3651 func copyFileWithHashMode(src, dst string, mode os.FileMode, createOnly bool) (string, error) {
3652 in, err := openRepairRegularRead(src)
3653 if err != nil {
3654 return "", err
3655 }
3656 defer in.Close()
3657 info, err := in.Stat()
3658 if err != nil {
3659 return "", err
3660 }
3661 if !info.Mode().IsRegular() {
3662 return "", fmt.Errorf("source %s is not a regular file", filepath.Base(src))
3663 }
3664 if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
3665 return "", err
3666 }
3667 tmp, err := os.CreateTemp(filepath.Dir(dst), ".repair-copy-*")
3668 if err != nil {
3669 return "", err
3670 }
3671 tmpPath := tmp.Name()
3672 defer os.Remove(tmpPath)
3673 h := sha256.New()
3674 if _, err := io.Copy(io.MultiWriter(tmp, h), in); err != nil {
3675 tmp.Close()
3676 return "", err
3677 }
3678 if err := tmp.Sync(); err != nil {
3679 tmp.Close()
3680 return "", err
3681 }
3682 if err := tmp.Chmod(mode); err != nil {
3683 tmp.Close()
3684 return "", err
3685 }
3686 if err := tmp.Close(); err != nil {
3687 return "", err
3688 }
3689 if createOnly {
3690 if err := renameRepairNodeNoReplace(tmpPath, dst); err != nil {
3691 return "", err
3692 }
3693 } else {
3694 if err := fileutil.ReplaceFile(tmpPath, dst); err != nil {
3695 return "", err
3696 }
3697 }
3698 return hex.EncodeToString(h.Sum(nil)), nil
3699 }
3700
3701 func hashFile(path string) (string, error) {
3702 f, err := os.Open(path)
3703 if err != nil {
3704 return "", err
3705 }
3706 defer f.Close()
3707 h := sha256.New()
3708 if _, err := io.Copy(h, f); err != nil {
3709 return "", err
3710 }
3711 return hex.EncodeToString(h.Sum(nil)), nil
3712 }
3713
3713 lines GO