返回 DeepSeek-Reasonix
transaction.go
根目录 / internal / repair / transaction.go
1 package repair
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "encoding/json"
7 "fmt"
8 "os"
9 "path/filepath"
10 "reflect"
11 "slices"
12 "strings"
13 "time"
14
15 "reasonix/internal/config"
16 "reasonix/internal/fileutil"
17 )
18
19 type RepairChange struct {
20 Scope string `json:"scope,omitempty"`
21 TargetPath string `json:"targetPath"`
22 PreviousPath string `json:"previousPath,omitempty"`
23 PreviousStateID string `json:"previousStateId,omitempty"`
24 RemoveOnUndo bool `json:"removeOnUndo,omitempty"`
25 // CreatedStateID binds a remove-on-undo entry to the exact node this repair
26 // intended to create. If type, mode, or bytes later differ, undo preserves
27 // it as an unowned concurrent write.
28 CreatedStateID string `json:"createdStateId,omitempty"`
29 // Prepared is a write-ahead filesystem-mutation intent. The exact previous
30 // state may still be at TargetPath (rename not run) or at PreviousPath
31 // (rename committed). Undo resolves either state without guessing.
32 Prepared bool `json:"prepared,omitempty"`
33 // Undone marks a change already reverted by an interrupted undo, so a
34 // retry can resume with the remaining changes instead of failing the
35 // preflight on the consumed backup of a change that is already restored.
36 Undone bool `json:"undone,omitempty"`
37 }
38
39 type RepairTransaction struct {
40 SchemaVersion int `json:"schemaVersion"`
41 ID string `json:"id"`
42 CreatedAt string `json:"createdAt"`
43 Changes []RepairChange `json:"changes"`
44 PreparedLastRepairStateID string `json:"preparedLastRepairStateId,omitempty"`
45 Undone bool `json:"undone,omitempty"`
46 UndoneAt string `json:"undoneAt,omitempty"`
47 }
48
49 func newRepairTransaction(now time.Time) *RepairTransaction {
50 now = now.UTC()
51 return &RepairTransaction{
52 SchemaVersion: 1,
53 ID: fmt.Sprintf("repair-%d", now.UnixNano()),
54 CreatedAt: now.Format(time.RFC3339Nano),
55 Changes: []RepairChange{},
56 }
57 }
58
59 func repairChangeForPrevious(scope, target, previous string) RepairChange {
60 return RepairChange{
61 Scope: scope,
62 TargetPath: target,
63 PreviousPath: previous,
64 PreviousStateID: repairPlanReleaseNodeStateFor(previous, target),
65 }
66 }
67
68 func preparedRepairChangeForPrevious(scope, target, previous string) RepairChange {
69 return RepairChange{
70 Scope: scope,
71 TargetPath: target,
72 PreviousPath: previous,
73 PreviousStateID: repairPlanReleaseNodeStateFor(target, target),
74 Prepared: true,
75 }
76 }
77
78 func preparedRepairChangeForCreate(scope, target, createdStateID string) RepairChange {
79 return RepairChange{
80 Scope: scope,
81 TargetPath: target,
82 RemoveOnUndo: true,
83 CreatedStateID: createdStateID,
84 Prepared: true,
85 }
86 }
87
88 func repairTransactionPath() string {
89 if root := config.MemoryUserDir(); root != "" {
90 return filepath.Join(root, "repair", "last-repair.json")
91 }
92 return ""
93 }
94
95 func pendingRepairTransactionPath() string {
96 if root := config.MemoryUserDir(); root != "" {
97 return filepath.Join(root, "repair", "pending-repair.json")
98 }
99 return ""
100 }
101
102 func repairLogPath() string {
103 if root := config.MemoryUserDir(); root != "" {
104 return filepath.Join(root, "repair", "repair-log.jsonl")
105 }
106 return ""
107 }
108
109 func saveRepairTransaction(tx *RepairTransaction) error {
110 if tx == nil || len(tx.Changes) == 0 {
111 return nil
112 }
113 if err := persistRepairTransaction(tx); err != nil {
114 return err
115 }
116 appendRepairLogBestEffort(tx)
117 return nil
118 }
119
120 // The append-only audit log is best-effort. last-repair.json is the durable
121 // undo state, so an audit failure must not turn an already committed filesystem
122 // change into a reported failure or trigger cleanup that consumes its backup.
123 func appendRepairLogBestEffort(tx *RepairTransaction) {
124 _ = appendRepairLog(tx)
125 }
126
127 func persistRepairTransaction(tx *RepairTransaction) error {
128 if tx != nil {
129 if strings.TrimSpace(tx.PreparedLastRepairStateID) != "" {
130 return fmt.Errorf("last repair transaction contains pending-only state")
131 }
132 for _, change := range tx.Changes {
133 if change.Prepared {
134 return fmt.Errorf("last repair transaction contains a prepared change")
135 }
136 }
137 }
138 path := repairTransactionPath()
139 if path == "" {
140 return nil
141 }
142 b, err := json.MarshalIndent(tx, "", " ")
143 if err != nil {
144 return err
145 }
146 if err := fileutil.AtomicWriteFile(path, append(b, '\n'), 0o600); err != nil {
147 return err
148 }
149 repairTransactionAfterPersist(tx)
150 return nil
151 }
152
153 var repairTransactionAfterPersist = func(*RepairTransaction) {}
154 var repairPendingAfterMove = func(string, string) {}
155
156 func persistPreparedRepairTransaction(tx *RepairTransaction) error {
157 if tx == nil || len(tx.Changes) == 0 || !tx.Changes[len(tx.Changes)-1].Prepared {
158 return fmt.Errorf("pending repair transaction is incomplete")
159 }
160 path := pendingRepairTransactionPath()
161 if path == "" {
162 return fmt.Errorf("pending repair state path is unavailable")
163 }
164 if _, err := os.Lstat(repairTransactionPath()); err == nil {
165 if _, err := ReadLastRepair(); err != nil {
166 return fmt.Errorf("prepare repair: current undo state is invalid: %w", err)
167 }
168 } else if !os.IsNotExist(err) {
169 return fmt.Errorf("prepare repair: inspect current undo state: %w", err)
170 }
171 // Bind the journal to the undo state it is allowed to replace. A stale
172 // pending file must never promote itself over a newer completed repair.
173 tx.PreparedLastRepairStateID = repairPlanReleaseNodeState(repairTransactionPath())
174 b, err := json.MarshalIndent(tx, "", " ")
175 if err != nil {
176 return err
177 }
178 if err := fileutil.AtomicCreateFile(path, append(b, '\n'), 0o600); err != nil {
179 return fmt.Errorf("publish pending repair transaction: %w", err)
180 }
181 return nil
182 }
183
184 // verifyPreparedCreateOwnership proves that AtomicCreateFile published the
185 // state recorded before the mutation. It must never rebind to whatever happens
186 // to be at path afterward: an uncooperative writer could replace the file in
187 // that window and would then be incorrectly claimed and removed by undo.
188 func verifyPreparedCreateOwnership(tx *RepairTransaction, changeIndex int, path string) error {
189 if tx == nil || changeIndex < 0 || changeIndex >= len(tx.Changes) {
190 return fmt.Errorf("prepared create ownership is incomplete")
191 }
192 change := tx.Changes[changeIndex]
193 if !change.Prepared || !change.RemoveOnUndo {
194 return fmt.Errorf("prepared create ownership is incomplete")
195 }
196 expected := strings.TrimSpace(change.CreatedStateID)
197 if !validRepairStateID(expected) {
198 return fmt.Errorf("prepared create ownership is incomplete")
199 }
200 if err := verifyRepairPlanReleaseNodeStateFor(path, path, expected); err != nil {
201 return fmt.Errorf("published create ownership changed: %w", err)
202 }
203 return nil
204 }
205
206 func clearPreparedRepairTransaction(expected *RepairTransaction) error {
207 if expected == nil {
208 return fmt.Errorf("pending repair transaction identity is incomplete")
209 }
210 path := pendingRepairTransactionPath()
211 if path == "" {
212 return nil
213 }
214 cleanup, err := moveRepairNodeToUniqueCleanup(path)
215 if err != nil || cleanup == "" {
216 return err
217 }
218 repairPendingAfterMove(path, cleanup)
219 restoreUnknown := func(cause error) error {
220 if restoreErr := renameRepairNodeNoReplace(cleanup, path); restoreErr != nil {
221 return fmt.Errorf("%w; displaced pending repair retained at %s: %w", cause, cleanup, restoreErr)
222 }
223 return cause
224 }
225 b, err := os.ReadFile(cleanup)
226 if err != nil {
227 return restoreUnknown(fmt.Errorf("read displaced pending repair: %w", err))
228 }
229 var actual RepairTransaction
230 if err := json.Unmarshal(b, &actual); err != nil {
231 return restoreUnknown(fmt.Errorf("pending repair transaction changed before cleanup: %w", err))
232 }
233 if !reflect.DeepEqual(&actual, expected) {
234 return restoreUnknown(fmt.Errorf("pending repair transaction changed before cleanup"))
235 }
236 // Do not unlink the moved journal. A path-based remove has an unavoidable
237 // check/remove race: an uncooperative writer could replace cleanup after the
238 // equality check and have its node deleted. The uniquely named, inactive
239 // journal is small and doubles as crash-recovery evidence.
240 if _, err := os.Lstat(path); err == nil {
241 return fmt.Errorf("a new pending repair transaction appeared during cleanup")
242 } else if !os.IsNotExist(err) {
243 return err
244 }
245 return nil
246 }
247
248 // reconcilePreparedRepairTransaction runs while the repair transaction lock is
249 // held. It promotes a crashed post-rename intent into last-repair.json, or
250 // discards an intent whose exact source is still live and therefore never
251 // committed. Ambiguous state remains fail closed for manual recovery.
252 func reconcilePreparedRepairTransaction() error {
253 path := pendingRepairTransactionPath()
254 if path == "" {
255 return nil
256 }
257 b, err := os.ReadFile(path)
258 if err != nil {
259 if os.IsNotExist(err) {
260 return nil
261 }
262 return err
263 }
264 var tx RepairTransaction
265 if err := json.Unmarshal(b, &tx); err != nil {
266 return fmt.Errorf("pending repair transaction is invalid: %w", err)
267 }
268 if tx.SchemaVersion != 1 || tx.ID == "" || len(tx.Changes) == 0 ||
269 !validRepairStateID(tx.PreparedLastRepairStateID) {
270 return fmt.Errorf("pending repair transaction is incomplete")
271 }
272 for i, change := range tx.Changes {
273 if err := validateRepairChange(change); err != nil {
274 return err
275 }
276 if change.Prepared != (i == len(tx.Changes)-1) {
277 return fmt.Errorf("pending repair transaction has an invalid prepared prefix")
278 }
279 }
280 last := tx.Changes[len(tx.Changes)-1]
281 unlockTargets, err := lockRepairMutations(last.TargetPath, last.PreviousPath)
282 if err != nil {
283 return err
284 }
285 defer unlockTargets()
286
287 // The target lock may have blocked behind another cooperative process.
288 // Re-read the journal so that process cannot substitute a different intent
289 // between validation and promotion.
290 currentPendingBytes, err := os.ReadFile(path)
291 if err != nil {
292 return err
293 }
294 var currentPending RepairTransaction
295 if err := json.Unmarshal(currentPendingBytes, &currentPending); err != nil {
296 return fmt.Errorf("pending repair transaction changed while waiting: %w", err)
297 }
298 if !reflect.DeepEqual(&currentPending, &tx) {
299 return fmt.Errorf("pending repair transaction changed while waiting")
300 }
301
302 committed := tx
303 committed.Changes = append([]RepairChange(nil), tx.Changes...)
304 committed.Changes[len(committed.Changes)-1].Prepared = false
305 committed.PreparedLastRepairStateID = ""
306 currentLast, readLastErr := ReadLastRepair()
307 if readLastErr == nil && reflect.DeepEqual(currentLast, &committed) {
308 // last-repair.json was already published and only journal cleanup was
309 // interrupted. Its backup must not be compensated or reclassified.
310 return clearPreparedRepairTransaction(&tx)
311 }
312 if readLastErr != nil && !os.IsNotExist(readLastErr) {
313 return fmt.Errorf("pending repair transaction cannot replace invalid undo state: %w", readLastErr)
314 }
315 if actual := repairPlanReleaseNodeState(repairTransactionPath()); actual != tx.PreparedLastRepairStateID {
316 return fmt.Errorf("pending repair transaction no longer matches the previous undo state")
317 }
318 applied, err := preparedRepairChangeApplied(last)
319 if err != nil {
320 return err
321 }
322 if applied {
323 if err := persistRepairTransaction(&committed); err != nil {
324 return err
325 }
326 }
327 return clearPreparedRepairTransaction(&tx)
328 }
329
330 // commitPreparedRepairTransaction reports whether last-repair.json became
331 // durable separately from journal-cleanup errors. Once durable is true callers
332 // must retain the backup referenced by the undo record and never compensate the
333 // already committed rename.
334 func commitPreparedRepairTransaction(tx *RepairTransaction, changeIndex int) (durable bool, err error) {
335 if tx == nil || changeIndex < 0 || changeIndex >= len(tx.Changes) || !tx.Changes[changeIndex].Prepared {
336 return false, fmt.Errorf("prepared repair transaction is incomplete")
337 }
338 pending := *tx
339 pending.Changes = append([]RepairChange(nil), tx.Changes...)
340 tx.Changes[changeIndex].Prepared = false
341 tx.PreparedLastRepairStateID = ""
342 if err := persistRepairTransaction(tx); err != nil {
343 tx.Changes[changeIndex].Prepared = true
344 tx.PreparedLastRepairStateID = pending.PreparedLastRepairStateID
345 return false, err
346 }
347 if err := clearPreparedRepairTransaction(&pending); err != nil {
348 return true, err
349 }
350 return true, nil
351 }
352
353 func validRepairStateID(value string) bool {
354 value = strings.TrimSpace(value)
355 if len(value) != sha256.Size*2 || value != strings.ToLower(value) {
356 return false
357 }
358 _, err := hex.DecodeString(value)
359 return err == nil
360 }
361
362 func appendRepairLog(tx *RepairTransaction) error {
363 path := repairLogPath()
364 if path == "" {
365 return nil
366 }
367 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
368 return err
369 }
370 b, err := json.Marshal(tx)
371 if err != nil {
372 return err
373 }
374 f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
375 if err != nil {
376 return err
377 }
378 defer f.Close()
379 _, err = f.Write(append(b, '\n'))
380 return err
381 }
382
383 func ReadLastRepair() (*RepairTransaction, error) {
384 path := repairTransactionPath()
385 if path == "" {
386 return nil, os.ErrNotExist
387 }
388 b, err := os.ReadFile(path)
389 if err != nil {
390 return nil, err
391 }
392 var tx RepairTransaction
393 if err := json.Unmarshal(b, &tx); err != nil {
394 return nil, err
395 }
396 if tx.SchemaVersion != 1 || tx.ID == "" || len(tx.Changes) == 0 {
397 return nil, fmt.Errorf("last repair transaction is incomplete")
398 }
399 if strings.TrimSpace(tx.PreparedLastRepairStateID) != "" {
400 return nil, fmt.Errorf("last repair transaction contains pending-only state")
401 }
402 for _, change := range tx.Changes {
403 if change.Prepared {
404 return nil, fmt.Errorf("last repair transaction contains a prepared change")
405 }
406 if err := validateRepairChange(change); err != nil {
407 return nil, err
408 }
409 }
410 return &tx, nil
411 }
412
413 func validateRepairChange(change RepairChange) error {
414 target := filepath.Clean(change.TargetPath)
415 switch {
416 case change.Scope == "global":
417 if target != filepath.Clean(config.UserConfigPath()) {
418 return fmt.Errorf("repair transaction global target is invalid")
419 }
420 case change.Scope == "project":
421 if filepath.Base(target) != "reasonix.toml" {
422 return fmt.Errorf("repair transaction project target is invalid")
423 }
424 case strings.HasPrefix(change.Scope, "derived:"):
425 name := change.Scope[len("derived:"):]
426 want, ok := derivedStatePaths()[name]
427 if !ok || target != filepath.Clean(want) {
428 return fmt.Errorf("repair transaction derived-state target is invalid")
429 }
430 default:
431 return fmt.Errorf("repair transaction scope is invalid")
432 }
433 if change.RemoveOnUndo {
434 if change.Scope != "global" || change.PreviousPath != "" {
435 return fmt.Errorf("repair transaction remove-on-undo action is invalid")
436 }
437 value := strings.TrimSpace(change.CreatedStateID)
438 if change.Prepared && value == "" {
439 return fmt.Errorf("repair transaction prepared create state is invalid")
440 }
441 if value != "" && !validRepairStateID(value) {
442 return fmt.Errorf("repair transaction created state is invalid")
443 }
444 return nil
445 }
446 if change.Prepared && strings.TrimSpace(change.PreviousStateID) == "" {
447 return fmt.Errorf("repair transaction prepared state is invalid")
448 }
449 previous := filepath.Clean(change.PreviousPath)
450 if filepath.Dir(previous) == filepath.Dir(target) && strings.HasPrefix(filepath.Base(previous), filepath.Base(target)+".reasonix-") {
451 return nil
452 }
453 if config.MemoryUserDir() == "" {
454 return fmt.Errorf("repair transaction state directory is unavailable")
455 }
456 restoreRoot := filepath.Join(config.MemoryUserDir(), "repair", "restore-backups")
457 if repairNodeInsideResolvedRoot(restoreRoot, previous) {
458 return nil
459 }
460 return fmt.Errorf("repair transaction previous path is invalid")
461 }
462
463 // repairNodeInsideResolvedRoot follows directory symlinks but deliberately
464 // leaves the leaf unresolved. A backed-up config may itself be a symlink whose
465 // referent is outside the repair directory; cleanup owns that link node, not
466 // its target. A symlinked parent, however, would let a forged transaction move
467 // and delete an unrelated node outside the repair root.
468 func repairNodeInsideResolvedRoot(root, path string) bool {
469 root = filepath.Clean(strings.TrimSpace(root))
470 path = filepath.Clean(strings.TrimSpace(path))
471 if root == "" || path == "" || root == "." || path == "." {
472 return false
473 }
474 rootInfo, err := os.Lstat(root)
475 if err != nil || !rootInfo.IsDir() {
476 return false
477 }
478 resolvedRoot, err := filepath.EvalSymlinks(root)
479 if err != nil {
480 return false
481 }
482 resolvedParent, err := filepath.EvalSymlinks(filepath.Dir(path))
483 if err != nil {
484 return false
485 }
486 resolvedPath := filepath.Join(resolvedParent, filepath.Base(path))
487 rel, err := filepath.Rel(resolvedRoot, resolvedPath)
488 return err == nil && rel != "." && rel != ".." &&
489 !strings.HasPrefix(rel, ".."+string(filepath.Separator))
490 }
491
492 var (
493 readRepairPreviousFile = os.ReadFile
494 readRepairPreviousLink = os.Readlink
495 )
496
497 // UndoLastRepair restores the exact files moved aside by the latest repair. Any
498 // currently repaired file is retained as a timestamped redo candidate.
499 func UndoLastRepair() (*RepairTransaction, error) {
500 invocationLastState := repairPlanReleaseNodeState(repairTransactionPath())
501 invocationPendingState := repairPlanReleaseNodeState(pendingRepairTransactionPath())
502 unlockTransaction, err := lockRepairTransaction()
503 if err != nil {
504 return nil, err
505 }
506 defer unlockTransaction()
507 if repairPlanReleaseNodeState(repairTransactionPath()) != invocationLastState ||
508 repairPlanReleaseNodeState(pendingRepairTransactionPath()) != invocationPendingState {
509 return nil, fmt.Errorf("undo repair: repair transaction changed while waiting")
510 }
511 if err := reconcilePreparedRepairTransaction(); err != nil {
512 return nil, fmt.Errorf("undo repair: reconcile pending mutation: %w", err)
513 }
514 tx, err := ReadLastRepair()
515 if err != nil {
516 return nil, err
517 }
518 if tx.Undone {
519 return nil, fmt.Errorf("repair %s was already undone", tx.ID)
520 }
521 if err := verifyUndoRepairBackups(tx); err != nil {
522 return nil, err
523 }
524 invocationID := repairPlanStateID(tx)
525 targets := make([]string, 0, len(tx.Changes))
526 for _, change := range tx.Changes {
527 targets = append(targets, change.TargetPath)
528 }
529 unlockTargets, err := lockRepairMutations(targets...)
530 if err != nil {
531 return nil, err
532 }
533 defer unlockTargets()
534 current, err := ReadLastRepair()
535 if err != nil {
536 return nil, fmt.Errorf("undo repair: re-read last repair transaction: %w", err)
537 }
538 if repairPlanStateID(current) != invocationID {
539 return nil, fmt.Errorf("undo repair: last repair transaction changed while waiting")
540 }
541 tx = current
542 if err := verifyUndoRepairBackups(tx); err != nil {
543 return nil, err
544 }
545 now := time.Now().UTC()
546 // markUndone persists per-change progress so a failure partway through a
547 // multi-change undo leaves a transaction the next undo can resume.
548 markUndone := func(i int) error {
549 tx.Changes[i].Undone = true
550 return persistRepairTransaction(tx)
551 }
552 for i, v := range slices.Backward(tx.Changes) {
553 change := v
554 if change.Undone {
555 // Progress was persisted but the backup removal may have been cut
556 // short by a crash; finish the cleanup the completed step owed.
557 if !change.RemoveOnUndo && change.PreviousPath != "" {
558 _ = removeRepairNodeIfMatching(change.PreviousPath, change.TargetPath, change.PreviousStateID)
559 }
560 continue
561 }
562 if change.Prepared {
563 applied, resolveErr := preparedRepairChangeApplied(change)
564 if resolveErr != nil {
565 return nil, fmt.Errorf("undo repair: resolve prepared change: %w", resolveErr)
566 }
567 if !applied {
568 if err := markUndone(i); err != nil {
569 return nil, err
570 }
571 continue
572 }
573 }
574 previousStateID := strings.TrimSpace(change.PreviousStateID)
575 redo := ""
576 if change.RemoveOnUndo && strings.TrimSpace(change.CreatedStateID) != "" {
577 info, statErr := os.Lstat(change.TargetPath)
578 if os.IsNotExist(statErr) {
579 if err := markUndone(i); err != nil {
580 return nil, err
581 }
582 continue
583 }
584 if statErr != nil {
585 return nil, fmt.Errorf("undo repair: inspect created file: %w", statErr)
586 }
587 owned := info.Mode().IsRegular() &&
588 verifyRepairPlanReleaseNodeStateFor(
589 change.TargetPath,
590 change.TargetPath,
591 change.CreatedStateID,
592 ) == nil
593 if !owned {
594 // A failed/crashed create intent may be followed by an
595 // uncooperative writer. It is not this repair's file, so leave
596 // it live while allowing earlier plan changes to be undone.
597 if err := markUndone(i); err != nil {
598 return nil, err
599 }
600 continue
601 }
602 }
603 // Lstat so a dangling symlink at the target is still moved aside
604 // instead of being clobbered by the restore below.
605 if _, err := os.Lstat(change.TargetPath); err == nil {
606 // Index suffix keeps redo names unique when one undo touches the
607 // same target twice (e.g. quarantine + snapshot restore): a shared
608 // name would silently overwrite the earlier redo copy.
609 redo = fmt.Sprintf("%s.reasonix-redo-%s-%d", change.TargetPath, now.Format("20060102T150405.000000000Z"), i)
610 if err := renameRepairNodeNoReplace(change.TargetPath, redo); err != nil {
611 return nil, fmt.Errorf("undo repair: retain current file: %w", err)
612 }
613 repairMutationAfterRename(change.TargetPath)
614 }
615 if change.RemoveOnUndo {
616 if err := markUndone(i); err != nil {
617 return nil, err
618 }
619 continue
620 }
621 // Restore by copy and keep the backup until the progress record is on
622 // disk: consuming the backup first (rename or delete) would leave an
623 // unresumable transaction if the process died before markUndone, since
624 // the retry's preflight requires the backup of every un-undone change.
625 restoreErr := func() error {
626 if expected := previousStateID; expected != "" {
627 if err := verifyRepairPlanReleaseNodeStateFor(change.PreviousPath, change.TargetPath, expected); err != nil {
628 return fmt.Errorf("previous state changed: %w", err)
629 }
630 }
631 info, err := os.Lstat(change.PreviousPath)
632 if err != nil {
633 return err
634 }
635 if info.Mode()&os.ModeSymlink != 0 {
636 // A quarantined symlink (e.g. a dotfiles-managed config.toml)
637 // must come back as a symlink: ReadFile would follow it and
638 // materialize a regular file, permanently severing the link.
639 // Recreating a symlink is a single atomic syscall, so the
640 // crash-safety of the copy path is preserved.
641 linkTarget, err := readRepairPreviousLink(change.PreviousPath)
642 if err != nil {
643 return err
644 }
645 linkContent, linkContentErr := readRepairPreviousFile(change.PreviousPath)
646 exactStateID := repairPlanReadStateIDFor(
647 change.TargetPath,
648 info.Mode(),
649 "symlink",
650 linkTarget,
651 linkContent,
652 linkContentErr == nil,
653 )
654 if previousStateID != "" && exactStateID != previousStateID {
655 return fmt.Errorf("previous link read changed since it was verified")
656 }
657 if expected := previousStateID; expected != "" {
658 if err := verifyRepairPlanReleaseNodeStateFor(change.PreviousPath, change.TargetPath, expected); err != nil {
659 return fmt.Errorf("previous state changed while reading link: %w", err)
660 }
661 }
662 return os.Symlink(linkTarget, change.TargetPath)
663 }
664 b, err := readRepairPreviousFile(change.PreviousPath)
665 if err != nil {
666 return err
667 }
668 exactStateID := repairPlanReadStateIDFor(
669 change.TargetPath,
670 info.Mode(),
671 "file",
672 "",
673 b,
674 true,
675 )
676 if previousStateID != "" && exactStateID != previousStateID {
677 return fmt.Errorf("previous file bytes changed since they were verified")
678 }
679 if expected := previousStateID; expected != "" {
680 if err := verifyRepairPlanReleaseNodeStateFor(change.PreviousPath, change.TargetPath, expected); err != nil {
681 return fmt.Errorf("previous state changed while reading file: %w", err)
682 }
683 }
684 return fileutil.AtomicCreateFile(change.TargetPath, b, info.Mode().Perm())
685 }()
686 if restoreErr != nil {
687 if redo != "" {
688 if compensateErr := restoreRepairNodeIfAbsent(redo, change.TargetPath); compensateErr != nil {
689 return nil, fmt.Errorf("undo repair: restore %s: %w (current state retained at %s: %w)", change.TargetPath, restoreErr, redo, compensateErr)
690 }
691 }
692 return nil, fmt.Errorf("undo repair: restore %s: %w", change.TargetPath, restoreErr)
693 }
694 if err := markUndone(i); err != nil {
695 return nil, err
696 }
697 _ = removeRepairNodeIfMatching(change.PreviousPath, change.TargetPath, previousStateID)
698 }
699 tx.Undone = true
700 tx.UndoneAt = now.Format(time.RFC3339Nano)
701 if err := saveRepairTransaction(tx); err != nil {
702 return nil, err
703 }
704 return tx, nil
705 }
706
707 func verifyUndoRepairBackups(tx *RepairTransaction) error {
708 if tx == nil {
709 return fmt.Errorf("undo repair: transaction identity is incomplete")
710 }
711 for _, change := range tx.Changes {
712 if change.RemoveOnUndo {
713 continue
714 }
715 expected := strings.TrimSpace(change.PreviousStateID)
716 if expected == "" {
717 return fmt.Errorf("undo repair: previous state identity is missing; legacy transaction cannot be undone safely")
718 }
719 if change.Undone {
720 continue
721 }
722 if change.Prepared {
723 applied, err := preparedRepairChangeApplied(change)
724 if err != nil {
725 return fmt.Errorf("undo repair: resolve prepared change: %w", err)
726 }
727 if !applied {
728 continue
729 }
730 }
731 // Lstat: a quarantined symlink counts as present even when its link
732 // target is gone. Undo restores the link node, not its referent.
733 if _, err := os.Lstat(change.PreviousPath); err != nil {
734 return fmt.Errorf("undo repair: previous file %s: %w", change.PreviousPath, err)
735 }
736 if err := verifyRepairPlanReleaseNodeStateFor(change.PreviousPath, change.TargetPath, expected); err != nil {
737 return fmt.Errorf("undo repair: previous state changed: %w", err)
738 }
739 }
740 return nil
741 }
742
743 func preparedRepairChangeApplied(change RepairChange) (bool, error) {
744 if !change.Prepared {
745 return true, nil
746 }
747 if change.RemoveOnUndo {
748 if _, err := os.Lstat(change.TargetPath); err == nil {
749 // The target was absent when the intent was prepared. Any node now
750 // present makes this the newest repair transaction, but undo removes
751 // it only when CreatedStateID proves the repair owns the exact node.
752 return true, nil
753 } else if os.IsNotExist(err) {
754 return false, nil
755 } else {
756 return false, err
757 }
758 }
759 expected := strings.TrimSpace(change.PreviousStateID)
760 if expected == "" {
761 return false, fmt.Errorf("prepared previous state identity is missing")
762 }
763 targetExists := true
764 if _, err := os.Lstat(change.TargetPath); err != nil {
765 if !os.IsNotExist(err) {
766 return false, err
767 }
768 targetExists = false
769 }
770 if targetExists && verifyRepairPlanReleaseNodeStateFor(change.TargetPath, change.TargetPath, expected) == nil {
771 // The exact source is still live, so the no-replace rename did not
772 // commit. Ignore an unrelated/colliding node at PreviousPath.
773 return false, nil
774 }
775 if _, err := os.Lstat(change.PreviousPath); err == nil {
776 if err := verifyRepairPlanReleaseNodeStateFor(change.PreviousPath, change.TargetPath, expected); err != nil {
777 return false, fmt.Errorf("prepared previous state changed: %w", err)
778 }
779 return true, nil
780 } else if !os.IsNotExist(err) {
781 return false, err
782 }
783 return false, fmt.Errorf("prepared state is not present at its target or previous path")
784 }
785
785 lines GO