返回 DeepSeek-Reasonix
update_legacy.go
根目录 / internal / repair / update_legacy.go
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
434 lines GO