| 1 | package repair |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "runtime" |
| 9 | "strings" |
| 10 | "time" |
| 11 | |
| 12 | "golang.org/x/mod/semver" |
| 13 | |
| 14 | "reasonix/internal/config" |
| 15 | "reasonix/internal/installlayout" |
| 16 | ) |
| 17 | |
| 18 | var supersededUpdateBeforeArchive = func(string) {} |
| 19 | var supersededAppUpdateAfterBackupArchive = func(string) {} |
| 20 | |
| 21 | // ArchiveSupersededPendingAppBundleUpdate retires a legacy macOS transaction |
| 22 | // only after a healthy desktop is already running from the transaction's exact |
| 23 | // target bundle. It is intentionally limited to the two unrecoverable legacy |
| 24 | // shapes: the rollback backup identity was never recorded, or the recorded |
| 25 | // backup no longer exists. A surviving backup is content-bound and moved aside; |
| 26 | // the original transaction is archived under Reasonix repair state. Neither is |
| 27 | // deleted, so support can still inspect or manually recover the old bundle. |
| 28 | func ArchiveSupersededPendingAppBundleUpdate(runningVersion string) (bool, error) { |
| 29 | tx, err := ReadPendingUpdate() |
| 30 | if os.IsNotExist(err) { |
| 31 | return false, nil |
| 32 | } |
| 33 | if err != nil { |
| 34 | return false, nil |
| 35 | } |
| 36 | eligible, err := validateSupersededPendingAppBundleUpdate(tx, runningVersion) |
| 37 | if err != nil || !eligible { |
| 38 | return false, err |
| 39 | } |
| 40 | expectedID := UpdateTransactionID(tx) |
| 41 | |
| 42 | unlock, err := acquirePendingUpdateLock() |
| 43 | if err != nil { |
| 44 | return false, fmt.Errorf("archive superseded app update: lock transaction: %w", err) |
| 45 | } |
| 46 | defer unlock() |
| 47 | unlocks, err := lockRepairMutations(pendingUpdateTargetPaths(tx)...) |
| 48 | if err != nil { |
| 49 | return false, fmt.Errorf("archive superseded app update: lock bundle paths: %w", err) |
| 50 | } |
| 51 | defer unlocks() |
| 52 | |
| 53 | current, err := ReadPendingUpdate() |
| 54 | if os.IsNotExist(err) { |
| 55 | return false, nil |
| 56 | } |
| 57 | if err != nil { |
| 58 | return false, fmt.Errorf("archive superseded app update: re-read transaction: %w", err) |
| 59 | } |
| 60 | eligible, err = validateSupersededPendingAppBundleUpdate(current, runningVersion) |
| 61 | if err != nil || !eligible { |
| 62 | return false, err |
| 63 | } |
| 64 | if UpdateTransactionID(current) != expectedID { |
| 65 | return false, fmt.Errorf("archive superseded app update: transaction changed while waiting") |
| 66 | } |
| 67 | tx = current |
| 68 | |
| 69 | targetTreeID, err := repairPlanTreeContentStateID(tx.TargetPath) |
| 70 | if err != nil { |
| 71 | return false, fmt.Errorf("archive superseded app update: read running bundle: %w", err) |
| 72 | } |
| 73 | backupArchive, backupTreeID, err := archiveSupersededAppBundleBackup(tx, expectedID) |
| 74 | if err != nil { |
| 75 | return false, err |
| 76 | } |
| 77 | restoreBackup := func(cause error) error { |
| 78 | if backupArchive == "" { |
| 79 | return cause |
| 80 | } |
| 81 | actual, digestErr := repairPlanTreeContentStateID(backupArchive) |
| 82 | if digestErr != nil || actual != backupTreeID { |
| 83 | return fmt.Errorf("%w; preserved changed backup at %s", cause, backupArchive) |
| 84 | } |
| 85 | if _, statErr := os.Lstat(tx.BackupPath); statErr == nil { |
| 86 | return fmt.Errorf("%w; preserved archived backup at %s because the public path was recreated", cause, backupArchive) |
| 87 | } else if !os.IsNotExist(statErr) { |
| 88 | return fmt.Errorf("%w; inspect backup restore path: %v", cause, statErr) |
| 89 | } |
| 90 | if restoreErr := renameRepairNodeNoReplace(backupArchive, tx.BackupPath); restoreErr != nil { |
| 91 | return fmt.Errorf("%w; preserved archived backup at %s: %v", cause, backupArchive, restoreErr) |
| 92 | } |
| 93 | return cause |
| 94 | } |
| 95 | if backupArchive != "" { |
| 96 | if _, statErr := os.Lstat(tx.BackupPath); statErr == nil { |
| 97 | return false, restoreBackup(fmt.Errorf("archive superseded app update: rollback backup path was recreated during recovery")) |
| 98 | } else if !os.IsNotExist(statErr) { |
| 99 | return false, restoreBackup(fmt.Errorf("archive superseded app update: inspect rollback backup after archival: %w", statErr)) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | supersededAppUpdateAfterBackupArchive(backupArchive) |
| 104 | archivePath, err := archiveSupersededPendingMarker(tx, expectedID, "app-bundle") |
| 105 | if err != nil { |
| 106 | return false, restoreBackup(err) |
| 107 | } |
| 108 | restoreMarker := func(cause error) error { |
| 109 | if restoreErr := renameRepairNodeNoReplace(archivePath, PendingUpdatePath()); restoreErr != nil { |
| 110 | cause = fmt.Errorf("%w; preserved moved transaction at %s: %v", cause, archivePath, restoreErr) |
| 111 | } |
| 112 | return restoreBackup(cause) |
| 113 | } |
| 114 | actualTarget, err := repairPlanTreeContentStateID(tx.TargetPath) |
| 115 | if err != nil || actualTarget != targetTreeID { |
| 116 | if err == nil { |
| 117 | err = fmt.Errorf("running bundle changed during recovery") |
| 118 | } |
| 119 | return false, restoreMarker(fmt.Errorf("archive superseded app update: %w", err)) |
| 120 | } |
| 121 | if backupArchive != "" { |
| 122 | if _, statErr := os.Lstat(tx.BackupPath); statErr == nil { |
| 123 | return false, restoreMarker(fmt.Errorf("archive superseded app update: rollback backup path was recreated before commit")) |
| 124 | } else if !os.IsNotExist(statErr) { |
| 125 | return false, restoreMarker(fmt.Errorf("archive superseded app update: inspect rollback backup before commit: %w", statErr)) |
| 126 | } |
| 127 | } |
| 128 | return true, nil |
| 129 | } |
| 130 | |
| 131 | func validateSupersededPendingAppBundleUpdate(tx *UpdateTransaction, runningVersion string) (bool, error) { |
| 132 | if tx == nil || tx.TargetKind != "app-bundle" { |
| 133 | return false, nil |
| 134 | } |
| 135 | platform := strings.TrimSpace(tx.Platform) |
| 136 | if slash := strings.IndexByte(platform, '/'); slash >= 0 { |
| 137 | platform = platform[:slash] |
| 138 | } |
| 139 | if platform != runtime.GOOS { |
| 140 | return false, fmt.Errorf("archive superseded app update: transaction platform %q does not match %q", tx.Platform, runtime.GOOS) |
| 141 | } |
| 142 | if err := validateUpdateTransaction(tx); err != nil { |
| 143 | return false, fmt.Errorf("archive superseded app update: invalid transaction: %w", err) |
| 144 | } |
| 145 | // A normal update prepares the transaction before this process shuts down; |
| 146 | // its backup is intentionally absent until the detached helper performs the |
| 147 | // swap. Never mistake that fresh handoff for a stale missing-backup record. |
| 148 | if tx.HandoffOwnerPID == os.Getpid() { |
| 149 | return false, nil |
| 150 | } |
| 151 | running := canonicalSemver(runningVersion) |
| 152 | from := canonicalSemver(tx.FromVersion) |
| 153 | to := canonicalSemver(tx.ToVersion) |
| 154 | if !semver.IsValid(running) || !semver.IsValid(to) { |
| 155 | return false, fmt.Errorf("archive superseded app update: invalid running or target version") |
| 156 | } |
| 157 | if running != from && semver.Compare(running, to) < 0 { |
| 158 | return false, fmt.Errorf("archive superseded app update: running version %q is neither the prior release nor at least %q", running, to) |
| 159 | } |
| 160 | if strings.TrimSpace(tx.BackupTreeID) == "" { |
| 161 | return true, nil |
| 162 | } |
| 163 | if _, err := os.Lstat(tx.BackupPath); os.IsNotExist(err) { |
| 164 | return true, nil |
| 165 | } else if err != nil { |
| 166 | return false, fmt.Errorf("archive superseded app update: inspect rollback backup: %w", err) |
| 167 | } |
| 168 | return false, nil |
| 169 | } |
| 170 | |
| 171 | func archiveSupersededAppBundleBackup(tx *UpdateTransaction, transactionID string) (string, string, error) { |
| 172 | info, err := os.Lstat(tx.BackupPath) |
| 173 | if os.IsNotExist(err) { |
| 174 | return "", "", nil |
| 175 | } |
| 176 | if err != nil { |
| 177 | return "", "", fmt.Errorf("archive superseded app update: inspect rollback backup: %w", err) |
| 178 | } |
| 179 | if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { |
| 180 | return "", "", fmt.Errorf("archive superseded app update: rollback backup is not a real directory") |
| 181 | } |
| 182 | treeID, err := repairPlanTreeContentStateID(tx.BackupPath) |
| 183 | if err != nil { |
| 184 | return "", "", fmt.Errorf("archive superseded app update: read rollback backup: %w", err) |
| 185 | } |
| 186 | shortID := transactionID |
| 187 | if len(shortID) > 16 { |
| 188 | shortID = shortID[:16] |
| 189 | } |
| 190 | base := fmt.Sprintf("%s.reasonix-retired-%s-%s", tx.BackupPath, shortID, time.Now().UTC().Format("20060102T150405.000000000Z")) |
| 191 | for attempt := 0; attempt < 16; attempt++ { |
| 192 | archive := fmt.Sprintf("%s-%d", base, attempt) |
| 193 | if err := renameRepairNodeNoReplace(tx.BackupPath, archive); err != nil { |
| 194 | if os.IsExist(err) { |
| 195 | continue |
| 196 | } |
| 197 | return "", "", fmt.Errorf("archive superseded app update: move rollback backup: %w", err) |
| 198 | } |
| 199 | actual, digestErr := repairPlanTreeContentStateID(archive) |
| 200 | if digestErr == nil && actual == treeID { |
| 201 | return archive, treeID, nil |
| 202 | } |
| 203 | cause := fmt.Errorf("archive superseded app update: rollback backup changed during archival") |
| 204 | if restoreErr := renameRepairNodeNoReplace(archive, tx.BackupPath); restoreErr != nil { |
| 205 | return "", "", fmt.Errorf("%w; preserved moved backup at %s: %v", cause, archive, restoreErr) |
| 206 | } |
| 207 | return "", "", cause |
| 208 | } |
| 209 | return "", "", fmt.Errorf("archive superseded app update: cannot allocate rollback backup archive path") |
| 210 | } |
| 211 | |
| 212 | func archiveSupersededPendingMarker(tx *UpdateTransaction, transactionID, kind string) (string, error) { |
| 213 | pendingPath := PendingUpdatePath() |
| 214 | archiveDir := filepath.Join(filepath.Dir(pendingPath), "legacy-updates") |
| 215 | if err := os.MkdirAll(archiveDir, 0o700); err != nil { |
| 216 | return "", fmt.Errorf("archive superseded update: create archive: %w", err) |
| 217 | } |
| 218 | if !pathInsideResolvedRoot(filepath.Join(config.MemoryUserDir(), "repair"), archiveDir) { |
| 219 | return "", fmt.Errorf("archive superseded update: archive directory resolves outside the repair directory") |
| 220 | } |
| 221 | shortID := transactionID |
| 222 | if len(shortID) > 16 { |
| 223 | shortID = shortID[:16] |
| 224 | } |
| 225 | base := filepath.Join(archiveDir, fmt.Sprintf("%s-%s-%s", time.Now().UTC().Format("20060102T150405.000000000Z"), shortID, kind)) |
| 226 | for attempt := 0; attempt < 16; attempt++ { |
| 227 | archivePath := fmt.Sprintf("%s-%d.json", base, attempt) |
| 228 | if err := renameRepairNodeNoReplace(pendingPath, archivePath); err != nil { |
| 229 | if os.IsExist(err) { |
| 230 | continue |
| 231 | } |
| 232 | return "", fmt.Errorf("archive superseded update: move transaction: %w", err) |
| 233 | } |
| 234 | restore := func(cause error) error { |
| 235 | if restoreErr := renameRepairNodeNoReplace(archivePath, pendingPath); restoreErr != nil { |
| 236 | return fmt.Errorf("%w; preserved moved transaction at %s: %v", cause, archivePath, restoreErr) |
| 237 | } |
| 238 | return cause |
| 239 | } |
| 240 | body, readErr := os.ReadFile(archivePath) |
| 241 | if readErr != nil { |
| 242 | return "", restore(fmt.Errorf("archive superseded update: verify moved transaction: %w", readErr)) |
| 243 | } |
| 244 | var archived UpdateTransaction |
| 245 | if unmarshalErr := json.Unmarshal(body, &archived); unmarshalErr != nil { |
| 246 | return "", restore(fmt.Errorf("archive superseded update: verify moved transaction: %w", unmarshalErr)) |
| 247 | } |
| 248 | if validateErr := validateUpdateTransaction(&archived); validateErr != nil { |
| 249 | return "", restore(fmt.Errorf("archive superseded update: verify moved transaction: %w", validateErr)) |
| 250 | } |
| 251 | if UpdateTransactionID(&archived) != transactionID || UpdateTransactionID(tx) != transactionID { |
| 252 | return "", restore(fmt.Errorf("archive superseded update: transaction changed before archival")) |
| 253 | } |
| 254 | return archivePath, nil |
| 255 | } |
| 256 | return "", fmt.Errorf("archive superseded update: cannot allocate archive path") |
| 257 | } |
| 258 | |
| 259 | // ArchiveSupersededPendingFileUpdate retires a superseded file-update transaction |
| 260 | // after the same or a newer versioned installation has started successfully. |
| 261 | // It never deletes the transaction or trusts version text alone: the current |
| 262 | // process must be the active desktop named by a valid current.json, every |
| 263 | // recorded target must belong to the superseded flat installRoot, and the exact |
| 264 | // transaction is revalidated under the pending-update lock. |
| 265 | // |
| 266 | // This is the recovery path for users whose v1.18-v1.19 update completed but |
| 267 | // whose old Guard never committed startup health. The original JSON is moved to |
| 268 | // repair/legacy-updates for diagnostics; rollback backups are left untouched. |
| 269 | func ArchiveSupersededPendingFileUpdate(runningVersion, installRoot string) (bool, error) { |
| 270 | tx, err := readSupersededPendingFileUpdate(runningVersion, installRoot) |
| 271 | if os.IsNotExist(err) { |
| 272 | return false, nil |
| 273 | } |
| 274 | if err != nil { |
| 275 | return false, fmt.Errorf("archive superseded pending update: %w", err) |
| 276 | } |
| 277 | expectedID := UpdateTransactionID(tx) |
| 278 | |
| 279 | unlock, err := acquirePendingUpdateLock() |
| 280 | if err != nil { |
| 281 | return false, fmt.Errorf("archive superseded pending update: lock transaction: %w", err) |
| 282 | } |
| 283 | defer unlock() |
| 284 | current, err := readSupersededPendingFileUpdate(runningVersion, installRoot) |
| 285 | if os.IsNotExist(err) { |
| 286 | return false, nil |
| 287 | } |
| 288 | if err != nil { |
| 289 | return false, fmt.Errorf("archive superseded pending update: re-read transaction: %w", err) |
| 290 | } |
| 291 | if UpdateTransactionID(current) != expectedID { |
| 292 | return false, fmt.Errorf("archive superseded pending update: transaction changed while waiting") |
| 293 | } |
| 294 | |
| 295 | pendingPath := PendingUpdatePath() |
| 296 | archiveDir := filepath.Join(filepath.Dir(pendingPath), "legacy-updates") |
| 297 | if err := os.MkdirAll(archiveDir, 0o700); err != nil { |
| 298 | return false, fmt.Errorf("archive superseded pending update: create archive: %w", err) |
| 299 | } |
| 300 | if !pathInsideResolvedRoot(filepath.Join(config.MemoryUserDir(), "repair"), archiveDir) { |
| 301 | return false, fmt.Errorf("archive superseded pending update: archive directory resolves outside the repair directory") |
| 302 | } |
| 303 | shortID := expectedID |
| 304 | if len(shortID) > 16 { |
| 305 | shortID = shortID[:16] |
| 306 | } |
| 307 | archiveBase := filepath.Join(archiveDir, fmt.Sprintf("%s-%s", time.Now().UTC().Format("20060102T150405.000000000Z"), shortID)) |
| 308 | supersededUpdateBeforeArchive(pendingPath) |
| 309 | for attempt := 0; attempt < 16; attempt++ { |
| 310 | archivePath := fmt.Sprintf("%s-%d.json", archiveBase, attempt) |
| 311 | if err := renameRepairNodeNoReplace(pendingPath, archivePath); err != nil { |
| 312 | if os.IsExist(err) { |
| 313 | continue |
| 314 | } |
| 315 | return false, fmt.Errorf("archive superseded pending update: move transaction: %w", err) |
| 316 | } |
| 317 | restore := func(cause error) error { |
| 318 | if restoreErr := renameRepairNodeNoReplace(archivePath, pendingPath); restoreErr != nil { |
| 319 | return fmt.Errorf("%w; preserved moved transaction at %s: %v", cause, archivePath, restoreErr) |
| 320 | } |
| 321 | return cause |
| 322 | } |
| 323 | body, readErr := os.ReadFile(archivePath) |
| 324 | if readErr != nil { |
| 325 | return false, restore(fmt.Errorf("archive superseded pending update: verify moved transaction: %w", readErr)) |
| 326 | } |
| 327 | var archived UpdateTransaction |
| 328 | if unmarshalErr := json.Unmarshal(body, &archived); unmarshalErr != nil { |
| 329 | return false, restore(fmt.Errorf("archive superseded pending update: verify moved transaction: %w", unmarshalErr)) |
| 330 | } |
| 331 | if UpdateTransactionID(&archived) != expectedID { |
| 332 | return false, restore(fmt.Errorf("archive superseded pending update: transaction changed before archival")) |
| 333 | } |
| 334 | return true, nil |
| 335 | } |
| 336 | return false, fmt.Errorf("archive superseded pending update: cannot allocate archive path") |
| 337 | } |
| 338 | |
| 339 | // readSupersededPendingFileUpdate deliberately bypasses the ordinary |
| 340 | // current-Guard directory check. That check is correct for rollback, but a |
| 341 | // versioned install runs from versions/<version>/ while the superseded |
| 342 | // transaction names flat binaries at InstallRoot. Requiring the ordinary read |
| 343 | // here made this recovery path reject the only state it was designed to heal. |
| 344 | func readSupersededPendingFileUpdate(runningVersion, installRoot string) (*UpdateTransaction, error) { |
| 345 | tx, err := readPendingUpdateUnchecked() |
| 346 | if err != nil { |
| 347 | return nil, err |
| 348 | } |
| 349 | if err := validateSupersededPendingFileUpdate(tx, runningVersion, installRoot); err != nil { |
| 350 | return nil, err |
| 351 | } |
| 352 | return tx, nil |
| 353 | } |
| 354 | |
| 355 | func validateSupersededPendingFileUpdate(tx *UpdateTransaction, runningVersion, installRoot string) error { |
| 356 | if tx == nil || tx.TargetKind != "file" { |
| 357 | return fmt.Errorf("archive superseded pending update: only file transactions are eligible") |
| 358 | } |
| 359 | runningVersion = canonicalSemver(runningVersion) |
| 360 | pendingVersion := canonicalSemver(tx.ToVersion) |
| 361 | if !semver.IsValid(runningVersion) || !semver.IsValid(pendingVersion) || semver.Compare(runningVersion, pendingVersion) < 0 { |
| 362 | return fmt.Errorf("archive superseded pending update: running version %q is older than %q", runningVersion, pendingVersion) |
| 363 | } |
| 364 | installRoot = canonicalLegacyInstallPath(installRoot) |
| 365 | if installRoot == "" || !filepath.IsAbs(installRoot) { |
| 366 | return fmt.Errorf("archive superseded pending update: install root is invalid") |
| 367 | } |
| 368 | launcher, err := repairExecutable() |
| 369 | if err != nil { |
| 370 | return fmt.Errorf("archive superseded pending update: current executable is unavailable") |
| 371 | } |
| 372 | resolvedRoot, err := installlayout.ResolveInstallRoot(launcher) |
| 373 | if err != nil || canonicalLegacyInstallPath(resolvedRoot) != installRoot { |
| 374 | return fmt.Errorf("archive superseded pending update: current executable is outside the install root") |
| 375 | } |
| 376 | ptr, err := installlayout.ReadCurrent(installRoot) |
| 377 | if err != nil { |
| 378 | return fmt.Errorf("archive superseded pending update: current installation is not versioned: %w", err) |
| 379 | } |
| 380 | if canonicalSemver(ptr.ActiveVersion) != runningVersion { |
| 381 | return fmt.Errorf("archive superseded pending update: active install version %q does not match running version %q", ptr.ActiveVersion, runningVersion) |
| 382 | } |
| 383 | activeDesktop, err := installlayout.ActiveDesktopPath(installRoot) |
| 384 | if err != nil { |
| 385 | return fmt.Errorf("archive superseded pending update: active desktop is unavailable: %w", err) |
| 386 | } |
| 387 | if canonicalRepairPath(activeDesktop) != canonicalRepairPath(launcher) { |
| 388 | return fmt.Errorf("archive superseded pending update: current executable is not the active desktop") |
| 389 | } |
| 390 | platform := strings.TrimSpace(tx.Platform) |
| 391 | if slash := strings.IndexByte(platform, '/'); slash >= 0 { |
| 392 | platform = platform[:slash] |
| 393 | } |
| 394 | if platform != runtime.GOOS { |
| 395 | return fmt.Errorf("archive superseded pending update: transaction platform %q does not match %q", tx.Platform, runtime.GOOS) |
| 396 | } |
| 397 | // Validate every transaction field and backup path while substituting the |
| 398 | // old primary target as the legacy launcher's location. The only ordinary |
| 399 | // invariant intentionally relaxed is that this target must sit beside the |
| 400 | // current versioned desktop. |
| 401 | if err := validateUpdateTransactionForLauncher(tx, tx.TargetPath); err != nil { |
| 402 | return fmt.Errorf("archive superseded pending update: invalid transaction: %w", err) |
| 403 | } |
| 404 | if canonicalLegacyInstallPath(filepath.Dir(tx.TargetPath)) != installRoot { |
| 405 | return fmt.Errorf("archive superseded pending update: target is not a flat install member") |
| 406 | } |
| 407 | targets := []string{tx.TargetPath} |
| 408 | for _, file := range tx.Files { |
| 409 | targets = append(targets, file.TargetPath) |
| 410 | } |
| 411 | for _, target := range targets { |
| 412 | if canonicalLegacyInstallPath(filepath.Dir(target)) != installRoot { |
| 413 | return fmt.Errorf("archive superseded pending update: release member is not in the flat install root") |
| 414 | } |
| 415 | } |
| 416 | return nil |
| 417 | } |
| 418 | |
| 419 | func canonicalSemver(value string) string { |
| 420 | value = strings.TrimSpace(value) |
| 421 | if value != "" && !strings.HasPrefix(value, "v") { |
| 422 | value = "v" + value |
| 423 | } |
| 424 | return value |
| 425 | } |
| 426 | |
| 427 | func canonicalLegacyInstallPath(path string) string { |
| 428 | path = filepath.Clean(strings.TrimSpace(path)) |
| 429 | if runtime.GOOS == "windows" { |
| 430 | path = strings.ToLower(path) |
| 431 | } |
| 432 | return path |
| 433 | } |
| 434 |