返回 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, and only for records the health commit cannot settle: no
24 // recorded backup identity, a recorded backup that is gone, or a target already
25 // reached whose record no longer verifies. A surviving backup is 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: %w", cause, statErr)
89 }
90 if restoreErr := renameRepairNodeNoReplace(backupArchive, tx.BackupPath); restoreErr != nil {
91 return fmt.Errorf("%w; preserved archived backup at %s: %w", 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: %w", 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 fresh handoff has no backup until the detached helper swaps the bundle.
146 // Its owner is this process under Wails and the Electron parent in host mode
147 // (desktop updateHandoffOwnerPID); either one means the record is live.
148 if tx.HandoffOwnerPID > 0 && (tx.HandoffOwnerPID == os.Getpid() || tx.HandoffOwnerPID == os.Getppid()) {
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 if semver.Compare(running, to) < 0 {
169 return false, nil
170 }
171 // The health commit owns a record it can still prove. One it cannot, such as
172 // a backup Finder wrote .DS_Store into, would pin every later update.
173 if semver.Compare(running, to) == 0 && pendingUpdateInstalledForHealth(tx) {
174 return false, nil
175 }
176 return true, nil
177 }
178
179 func archiveSupersededAppBundleBackup(tx *UpdateTransaction, transactionID string) (string, string, error) {
180 info, err := os.Lstat(tx.BackupPath)
181 if os.IsNotExist(err) {
182 return "", "", nil
183 }
184 if err != nil {
185 return "", "", fmt.Errorf("archive superseded app update: inspect rollback backup: %w", err)
186 }
187 if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
188 return "", "", fmt.Errorf("archive superseded app update: rollback backup is not a real directory")
189 }
190 treeID, err := repairPlanTreeContentStateID(tx.BackupPath)
191 if err != nil {
192 return "", "", fmt.Errorf("archive superseded app update: read rollback backup: %w", err)
193 }
194 shortID := transactionID
195 if len(shortID) > 16 {
196 shortID = shortID[:16]
197 }
198 base := fmt.Sprintf("%s.reasonix-retired-%s-%s", tx.BackupPath, shortID, time.Now().UTC().Format("20060102T150405.000000000Z"))
199 for attempt := range 16 {
200 archive := fmt.Sprintf("%s-%d", base, attempt)
201 if err := renameRepairNodeNoReplace(tx.BackupPath, archive); err != nil {
202 if os.IsExist(err) {
203 continue
204 }
205 return "", "", fmt.Errorf("archive superseded app update: move rollback backup: %w", err)
206 }
207 actual, digestErr := repairPlanTreeContentStateID(archive)
208 if digestErr == nil && actual == treeID {
209 return archive, treeID, nil
210 }
211 cause := fmt.Errorf("archive superseded app update: rollback backup changed during archival")
212 if restoreErr := renameRepairNodeNoReplace(archive, tx.BackupPath); restoreErr != nil {
213 return "", "", fmt.Errorf("%w; preserved moved backup at %s: %w", cause, archive, restoreErr)
214 }
215 return "", "", cause
216 }
217 return "", "", fmt.Errorf("archive superseded app update: cannot allocate rollback backup archive path")
218 }
219
220 func archiveSupersededPendingMarker(tx *UpdateTransaction, transactionID, kind string) (string, error) {
221 pendingPath := PendingUpdatePath()
222 archiveDir := filepath.Join(filepath.Dir(pendingPath), "legacy-updates")
223 if err := os.MkdirAll(archiveDir, 0o700); err != nil {
224 return "", fmt.Errorf("archive superseded update: create archive: %w", err)
225 }
226 if !pathInsideResolvedRoot(filepath.Join(config.MemoryUserDir(), "repair"), archiveDir) {
227 return "", fmt.Errorf("archive superseded update: archive directory resolves outside the repair directory")
228 }
229 shortID := transactionID
230 if len(shortID) > 16 {
231 shortID = shortID[:16]
232 }
233 base := filepath.Join(archiveDir, fmt.Sprintf("%s-%s-%s", time.Now().UTC().Format("20060102T150405.000000000Z"), shortID, kind))
234 for attempt := range 16 {
235 archivePath := fmt.Sprintf("%s-%d.json", base, attempt)
236 if err := renameRepairNodeNoReplace(pendingPath, archivePath); err != nil {
237 if os.IsExist(err) {
238 continue
239 }
240 return "", fmt.Errorf("archive superseded update: move transaction: %w", err)
241 }
242 restore := func(cause error) error {
243 if restoreErr := renameRepairNodeNoReplace(archivePath, pendingPath); restoreErr != nil {
244 return fmt.Errorf("%w; preserved moved transaction at %s: %w", cause, archivePath, restoreErr)
245 }
246 return cause
247 }
248 body, readErr := os.ReadFile(archivePath)
249 if readErr != nil {
250 return "", restore(fmt.Errorf("archive superseded update: verify moved transaction: %w", readErr))
251 }
252 var archived UpdateTransaction
253 if unmarshalErr := json.Unmarshal(body, &archived); unmarshalErr != nil {
254 return "", restore(fmt.Errorf("archive superseded update: verify moved transaction: %w", unmarshalErr))
255 }
256 if validateErr := validateUpdateTransaction(&archived); validateErr != nil {
257 return "", restore(fmt.Errorf("archive superseded update: verify moved transaction: %w", validateErr))
258 }
259 if UpdateTransactionID(&archived) != transactionID || UpdateTransactionID(tx) != transactionID {
260 return "", restore(fmt.Errorf("archive superseded update: transaction changed before archival"))
261 }
262 return archivePath, nil
263 }
264 return "", fmt.Errorf("archive superseded update: cannot allocate archive path")
265 }
266
267 // ArchiveSupersededPendingFileUpdate retires a superseded file-update transaction
268 // after the same or a newer versioned installation has started successfully.
269 // It never deletes the transaction or trusts version text alone: the current
270 // process must be the active desktop named by a valid current.json, every
271 // recorded target must belong to the superseded flat installRoot, and the exact
272 // transaction is revalidated under the pending-update lock.
273 //
274 // This is the recovery path for users whose v1.18-v1.19 update completed but
275 // whose old Guard never committed startup health. The original JSON is moved to
276 // repair/legacy-updates for diagnostics; rollback backups are left untouched.
277 func ArchiveSupersededPendingFileUpdate(runningVersion, installRoot string) (bool, error) {
278 tx, err := readSupersededPendingFileUpdate(runningVersion, installRoot)
279 if os.IsNotExist(err) {
280 return false, nil
281 }
282 if err != nil {
283 return false, fmt.Errorf("archive superseded pending update: %w", err)
284 }
285 expectedID := UpdateTransactionID(tx)
286
287 unlock, err := acquirePendingUpdateLock()
288 if err != nil {
289 return false, fmt.Errorf("archive superseded pending update: lock transaction: %w", err)
290 }
291 defer unlock()
292 current, err := readSupersededPendingFileUpdate(runningVersion, installRoot)
293 if os.IsNotExist(err) {
294 return false, nil
295 }
296 if err != nil {
297 return false, fmt.Errorf("archive superseded pending update: re-read transaction: %w", err)
298 }
299 if UpdateTransactionID(current) != expectedID {
300 return false, fmt.Errorf("archive superseded pending update: transaction changed while waiting")
301 }
302
303 pendingPath := PendingUpdatePath()
304 archiveDir := filepath.Join(filepath.Dir(pendingPath), "legacy-updates")
305 if err := os.MkdirAll(archiveDir, 0o700); err != nil {
306 return false, fmt.Errorf("archive superseded pending update: create archive: %w", err)
307 }
308 if !pathInsideResolvedRoot(filepath.Join(config.MemoryUserDir(), "repair"), archiveDir) {
309 return false, fmt.Errorf("archive superseded pending update: archive directory resolves outside the repair directory")
310 }
311 shortID := expectedID
312 if len(shortID) > 16 {
313 shortID = shortID[:16]
314 }
315 archiveBase := filepath.Join(archiveDir, fmt.Sprintf("%s-%s", time.Now().UTC().Format("20060102T150405.000000000Z"), shortID))
316 supersededUpdateBeforeArchive(pendingPath)
317 for attempt := range 16 {
318 archivePath := fmt.Sprintf("%s-%d.json", archiveBase, attempt)
319 if err := renameRepairNodeNoReplace(pendingPath, archivePath); err != nil {
320 if os.IsExist(err) {
321 continue
322 }
323 return false, fmt.Errorf("archive superseded pending update: move transaction: %w", err)
324 }
325 restore := func(cause error) error {
326 if restoreErr := renameRepairNodeNoReplace(archivePath, pendingPath); restoreErr != nil {
327 return fmt.Errorf("%w; preserved moved transaction at %s: %w", cause, archivePath, restoreErr)
328 }
329 return cause
330 }
331 body, readErr := os.ReadFile(archivePath)
332 if readErr != nil {
333 return false, restore(fmt.Errorf("archive superseded pending update: verify moved transaction: %w", readErr))
334 }
335 var archived UpdateTransaction
336 if unmarshalErr := json.Unmarshal(body, &archived); unmarshalErr != nil {
337 return false, restore(fmt.Errorf("archive superseded pending update: verify moved transaction: %w", unmarshalErr))
338 }
339 if UpdateTransactionID(&archived) != expectedID {
340 return false, restore(fmt.Errorf("archive superseded pending update: transaction changed before archival"))
341 }
342 return true, nil
343 }
344 return false, fmt.Errorf("archive superseded pending update: cannot allocate archive path")
345 }
346
347 // readSupersededPendingFileUpdate deliberately bypasses the ordinary
348 // current-Guard directory check. That check is correct for rollback, but a
349 // versioned install runs from versions/<version>/ while the superseded
350 // transaction names flat binaries at InstallRoot. Requiring the ordinary read
351 // here made this recovery path reject the only state it was designed to heal.
352 func readSupersededPendingFileUpdate(runningVersion, installRoot string) (*UpdateTransaction, error) {
353 tx, err := readPendingUpdateUnchecked()
354 if err != nil {
355 return nil, err
356 }
357 if err := validateSupersededPendingFileUpdate(tx, runningVersion, installRoot); err != nil {
358 return nil, err
359 }
360 return tx, nil
361 }
362
363 func validateSupersededPendingFileUpdate(tx *UpdateTransaction, runningVersion, installRoot string) error {
364 if tx == nil || tx.TargetKind != "file" {
365 return fmt.Errorf("archive superseded pending update: only file transactions are eligible")
366 }
367 runningVersion = canonicalSemver(runningVersion)
368 pendingVersion := canonicalSemver(tx.ToVersion)
369 if !semver.IsValid(runningVersion) || !semver.IsValid(pendingVersion) || semver.Compare(runningVersion, pendingVersion) < 0 {
370 return fmt.Errorf("archive superseded pending update: running version %q is older than %q", runningVersion, pendingVersion)
371 }
372 installRoot = canonicalLegacyInstallPath(installRoot)
373 if installRoot == "" || !filepath.IsAbs(installRoot) {
374 return fmt.Errorf("archive superseded pending update: install root is invalid")
375 }
376 launcher, err := repairExecutable()
377 if err != nil {
378 return fmt.Errorf("archive superseded pending update: current executable is unavailable")
379 }
380 resolvedRoot, err := installlayout.ResolveInstallRoot(launcher)
381 if err != nil || canonicalLegacyInstallPath(resolvedRoot) != installRoot {
382 return fmt.Errorf("archive superseded pending update: current executable is outside the install root")
383 }
384 ptr, err := installlayout.ReadCurrent(installRoot)
385 if err != nil {
386 return fmt.Errorf("archive superseded pending update: current installation is not versioned: %w", err)
387 }
388 if canonicalSemver(ptr.ActiveVersion) != runningVersion {
389 return fmt.Errorf("archive superseded pending update: active install version %q does not match running version %q", ptr.ActiveVersion, runningVersion)
390 }
391 activeDesktop, err := installlayout.ActiveDesktopPath(installRoot)
392 if err != nil {
393 return fmt.Errorf("archive superseded pending update: active desktop is unavailable: %w", err)
394 }
395 if canonicalRepairPath(activeDesktop) != canonicalRepairPath(launcher) {
396 return fmt.Errorf("archive superseded pending update: current executable is not the active desktop")
397 }
398 platform := strings.TrimSpace(tx.Platform)
399 if slash := strings.IndexByte(platform, '/'); slash >= 0 {
400 platform = platform[:slash]
401 }
402 if platform != runtime.GOOS {
403 return fmt.Errorf("archive superseded pending update: transaction platform %q does not match %q", tx.Platform, runtime.GOOS)
404 }
405 // Validate every transaction field and backup path while substituting the
406 // old primary target as the legacy launcher's location. The only ordinary
407 // invariant intentionally relaxed is that this target must sit beside the
408 // current versioned desktop.
409 if err := validateUpdateTransactionForLauncher(tx, tx.TargetPath); err != nil {
410 return fmt.Errorf("archive superseded pending update: invalid transaction: %w", err)
411 }
412 if canonicalLegacyInstallPath(filepath.Dir(tx.TargetPath)) != installRoot {
413 return fmt.Errorf("archive superseded pending update: target is not a flat install member")
414 }
415 targets := []string{tx.TargetPath}
416 for _, file := range tx.Files {
417 targets = append(targets, file.TargetPath)
418 }
419 for _, target := range targets {
420 if canonicalLegacyInstallPath(filepath.Dir(target)) != installRoot {
421 return fmt.Errorf("archive superseded pending update: release member is not in the flat install root")
422 }
423 }
424 return nil
425 }
426
427 func canonicalSemver(value string) string {
428 value = strings.TrimSpace(value)
429 if value != "" && !strings.HasPrefix(value, "v") {
430 value = "v" + value
431 }
432 return value
433 }
434
435 func canonicalLegacyInstallPath(path string) string {
436 path = filepath.Clean(strings.TrimSpace(path))
437 if runtime.GOOS == "windows" {
438 path = strings.ToLower(path)
439 }
440 return path
441 }
442
442 lines GO