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