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