返回 DeepSeek-Reasonix
snapshot.go
根目录 / internal / repair / snapshot.go
1 package repair
2
3 import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "path/filepath"
12 "sort"
13 "strings"
14 "time"
15
16 "reasonix/internal/config"
17 "reasonix/internal/fileutil"
18 )
19
20 const configSnapshotRetention = 5
21
22 type ConfigSnapshot struct {
23 SchemaVersion int `json:"schemaVersion"`
24 ID string `json:"id"`
25 Path string `json:"path"`
26 SHA256 string `json:"sha256"`
27 SourcePath string `json:"sourcePath"`
28 RecordedAt string `json:"recordedAt"`
29 Version string `json:"version,omitempty"`
30 }
31
32 func snapshotDir() string {
33 if root := config.MemoryUserDir(); root != "" {
34 return filepath.Join(root, "repair", "snapshots")
35 }
36 return ""
37 }
38
39 func recordConfigSnapshot(source string, b []byte, version string, now time.Time) error {
40 dir := snapshotDir()
41 if dir == "" {
42 return nil
43 }
44 sum := sha256.Sum256(b)
45 hash := hex.EncodeToString(sum[:])
46 existing, err := ListConfigSnapshots()
47 if err != nil {
48 return err
49 }
50 if len(existing) > 0 && strings.EqualFold(existing[0].SHA256, hash) {
51 current, err := readVerifiedConfigSnapshot(existing[0])
52 if err != nil {
53 return fmt.Errorf("verify existing config snapshot %s: %w", existing[0].ID, err)
54 }
55 if !bytes.Equal(current, b) {
56 return fmt.Errorf("config snapshot %s SHA-256 collision", existing[0].ID)
57 }
58 return nil
59 }
60 stamp := now.UTC().Format("20060102T150405.000000000Z")
61 id := stamp + "-" + hash[:12]
62 path := filepath.Join(dir, id+".toml")
63 if err := createOrVerifyConfigSnapshotFile(path, b); err != nil {
64 return err
65 }
66 meta := ConfigSnapshot{SchemaVersion: 1, ID: id, Path: path, SHA256: hash, SourcePath: source, RecordedAt: now.UTC().Format(time.RFC3339Nano), Version: version}
67 encoded, err := json.MarshalIndent(meta, "", " ")
68 if err != nil {
69 return err
70 }
71 metadata := append(encoded, '\n')
72 if err := createOrVerifyConfigSnapshotFile(path+".json", metadata); err != nil {
73 return err
74 }
75 // Re-read both immutable files before pruning. This catches a conflicting
76 // pre-existing ID as well as an uncooperative replacement during publish.
77 if err := verifyConfigSnapshotFile(path, b); err != nil {
78 return err
79 }
80 if err := verifyConfigSnapshotFile(path+".json", metadata); err != nil {
81 return err
82 }
83 return pruneConfigSnapshots(configSnapshotRetention)
84 }
85
86 func createOrVerifyConfigSnapshotFile(path string, content []byte) error {
87 if err := fileutil.AtomicCreateFile(path, content, 0o600); err != nil {
88 if !errors.Is(err, os.ErrExist) {
89 return err
90 }
91 if err := verifyConfigSnapshotFile(path, content); err != nil {
92 return fmt.Errorf("config snapshot path already exists with different state: %w", err)
93 }
94 }
95 return nil
96 }
97
98 func verifyConfigSnapshotFile(path string, expected []byte) error {
99 info, err := os.Lstat(path)
100 if err != nil {
101 return err
102 }
103 if !info.Mode().IsRegular() {
104 return fmt.Errorf("%s is not a regular file", path)
105 }
106 actual, err := os.ReadFile(path)
107 if err != nil {
108 return err
109 }
110 if !bytes.Equal(actual, expected) {
111 return fmt.Errorf("%s content changed", path)
112 }
113 return nil
114 }
115
116 func ListConfigSnapshots() ([]ConfigSnapshot, error) {
117 dir := snapshotDir()
118 if dir == "" {
119 return []ConfigSnapshot{}, nil
120 }
121 entries, err := os.ReadDir(dir)
122 if err != nil {
123 if os.IsNotExist(err) {
124 return []ConfigSnapshot{}, nil
125 }
126 return nil, err
127 }
128 out := make([]ConfigSnapshot, 0, len(entries))
129 for _, entry := range entries {
130 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".toml.json") {
131 continue
132 }
133 b, err := os.ReadFile(filepath.Join(dir, entry.Name()))
134 if err != nil {
135 continue
136 }
137 var snap ConfigSnapshot
138 if json.Unmarshal(b, &snap) != nil || validateConfigSnapshot(dir, &snap) != nil {
139 continue
140 }
141 if entry.Name() != snap.ID+".toml.json" {
142 continue
143 }
144 out = append(out, snap)
145 }
146 sort.Slice(out, func(i, j int) bool {
147 left, leftErr := time.Parse(time.RFC3339Nano, out[i].RecordedAt)
148 right, rightErr := time.Parse(time.RFC3339Nano, out[j].RecordedAt)
149 if leftErr == nil && rightErr == nil && !left.Equal(right) {
150 return left.After(right)
151 }
152 if out[i].RecordedAt != out[j].RecordedAt {
153 return out[i].RecordedAt > out[j].RecordedAt
154 }
155 return out[i].ID > out[j].ID
156 })
157 return out, nil
158 }
159
160 func RestoreConfigSnapshot(id string) (*RepairTransaction, error) {
161 dest := config.UserConfigPath()
162 if dest == "" {
163 return nil, fmt.Errorf("global config path is unavailable")
164 }
165 dir, contentPath, metadataPath, err := configSnapshotPaths(id)
166 if err != nil {
167 return nil, err
168 }
169 plan := RepairPlan{
170 SchemaVersion: RepairPlanSchemaVersion,
171 Summary: "restore config snapshot",
172 Actions: []RepairPlanAction{{
173 Type: "restore_snapshot",
174 SnapshotID: id,
175 Reason: "direct restore",
176 }},
177 }
178 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
179 if err != nil {
180 return nil, err
181 }
182 unlockTransaction, err := lockRepairTransaction()
183 if err != nil {
184 return nil, err
185 }
186 defer unlockTransaction()
187 if err := reconcilePreparedRepairTransaction(); err != nil {
188 return nil, fmt.Errorf("restore config snapshot: reconcile pending mutation: %w", err)
189 }
190 unlock, err := lockRepairMutations(dest, dir, contentPath, metadataPath)
191 if err != nil {
192 return nil, err
193 }
194 defer unlock()
195 if err := verifyRepairPlanFileStates(preview[0].fileStates); err != nil {
196 return nil, err
197 }
198 return restoreConfigSnapshotBoundUnlocked(id, preview[0].fileStates, preview[0].afterContent, nil)
199 }
200
201 func restoreConfigSnapshotBoundUnlocked(
202 id string,
203 expectedStates map[string]string,
204 confirmedSnapshot []byte,
205 planTx *RepairTransaction,
206 ) (*RepairTransaction, error) {
207 if err := verifyRepairPlanFileStates(expectedStates); err != nil {
208 return nil, err
209 }
210 snapshots, err := ListConfigSnapshots()
211 if err != nil {
212 return nil, err
213 }
214 var selected *ConfigSnapshot
215 for i := range snapshots {
216 if snapshots[i].ID == id {
217 selected = &snapshots[i]
218 break
219 }
220 }
221 if selected == nil {
222 return nil, fmt.Errorf("config snapshot %q not found", id)
223 }
224 var verifiedSnapshot []byte
225 if expectedStates == nil {
226 verifiedSnapshot, err = readVerifiedConfigSnapshot(*selected)
227 if err != nil {
228 return nil, err
229 }
230 } else {
231 if err := verifyConfirmedConfigSnapshot(*selected, confirmedSnapshot); err != nil {
232 return nil, err
233 }
234 }
235 dest := config.UserConfigPath()
236 if dest == "" {
237 return nil, fmt.Errorf("global config path is unavailable")
238 }
239 if err := verifyRepairPlanFileStates(expectedStates); err != nil {
240 return nil, err
241 }
242 b := confirmedSnapshot
243 if expectedStates == nil {
244 b = verifiedSnapshot
245 }
246 repairMutationBeforeRename(dest)
247 if err := verifyRepairPlanFileStates(expectedStates); err != nil {
248 return nil, err
249 }
250 tx := planTx
251 if tx == nil {
252 tx = newRepairTransaction(time.Now())
253 }
254 backup := filepath.Join(config.MemoryUserDir(), "repair", "restore-backups", tx.ID+".toml")
255 if expectedStates != nil {
256 backup = dest + ".reasonix-restore-" + tx.ID
257 }
258 moved := false
259 if _, err := os.Lstat(dest); err == nil {
260 // Move the live file aside instead of copying its bytes: dest may be a
261 // symlink (a dotfiles-managed config), and a byte copy would record a
262 // plain file, so undo could never restore the link. Rename preserves
263 // the exact node; undo recreates symlinks from the quarantined link.
264 if err := os.MkdirAll(filepath.Dir(backup), 0o700); err != nil {
265 return nil, err
266 }
267 changeIndex := len(tx.Changes)
268 tx.Changes = append(tx.Changes, preparedRepairChangeForPrevious("global", dest, backup))
269 if err := persistPreparedRepairTransaction(tx); err != nil {
270 return nil, fmt.Errorf("prepare snapshot restore: %w", err)
271 }
272 repairMutationAfterPrepare(dest)
273 if renameErr := snapshotRename(dest, backup); renameErr == nil {
274 moved = true
275 repairMutationAfterRename(dest)
276 } else if expectedStates != nil {
277 return nil, fmt.Errorf("restore config snapshot: move confirmed config: %w", renameErr)
278 } else {
279 // The repair state directory can live on another filesystem. Fall
280 // back to a unique sibling so displacement remains one atomic rename;
281 // copy-then-remove could delete a concurrent recreation of dest.
282 prepared := *tx
283 prepared.Changes = append([]RepairChange(nil), tx.Changes...)
284 if err := clearPreparedRepairTransaction(&prepared); err != nil {
285 return nil, fmt.Errorf("clear cross-device snapshot restore intent: %w", err)
286 }
287 backup = dest + ".reasonix-restore-" + tx.ID
288 tx.Changes[changeIndex].PreviousPath = backup
289 if err := persistPreparedRepairTransaction(tx); err != nil {
290 return nil, fmt.Errorf("prepare sibling snapshot restore: %w", err)
291 }
292 repairMutationAfterPrepare(dest)
293 if fallbackErr := snapshotRename(dest, backup); fallbackErr != nil {
294 return nil, errors.Join(
295 fmt.Errorf("restore config snapshot: move config to repair state: %w", renameErr),
296 fmt.Errorf("move config to sibling backup: %w", fallbackErr),
297 )
298 }
299 moved = true
300 repairMutationAfterRename(dest)
301 }
302 if moved {
303 expected := tx.Changes[changeIndex].PreviousStateID
304 if err := verifyRepairPlanReleaseNodeStateFor(backup, dest, expected); err != nil {
305 return nil, joinRestoreCleanupError(err, backup, restoreRepairNodeIfAbsent(backup, dest))
306 }
307 if durable, err := commitPreparedRepairTransaction(tx, changeIndex); err != nil {
308 if durable {
309 return nil, fmt.Errorf("commit snapshot restore undo state: cleanup pending journal: %w", err)
310 }
311 return nil, joinRestoreCleanupError(err, backup, restoreRepairNodeIfAbsent(backup, dest))
312 }
313 if _, err := os.Lstat(dest); err == nil {
314 return nil, fmt.Errorf("target was recreated during snapshot restore; original state remains at %s", backup)
315 } else if !os.IsNotExist(err) {
316 return nil, err
317 }
318 }
319 } else if os.IsNotExist(err) {
320 createdStateID := repairPlanPreparedCreateStateID(dest, b, 0o600)
321 tx.Changes = append(tx.Changes, preparedRepairChangeForCreate("global", dest, createdStateID))
322 } else {
323 return nil, err
324 }
325 if !moved {
326 changeIndex := len(tx.Changes) - 1
327 if err := persistPreparedRepairTransaction(tx); err != nil {
328 return nil, fmt.Errorf("prepare snapshot create: %w", err)
329 }
330 repairMutationAfterPrepare(dest)
331 if err := fileutil.AtomicCreateFile(dest, b, 0o600); err != nil {
332 prepared := *tx
333 prepared.Changes = append([]RepairChange(nil), tx.Changes...)
334 clearErr := clearPreparedRepairTransaction(&prepared)
335 return nil, errors.Join(err, clearErr)
336 }
337 repairSnapshotAfterCreate(dest)
338 if err := verifyPreparedCreateOwnership(tx, changeIndex, dest); err != nil {
339 return nil, fmt.Errorf("verify snapshot create ownership: %w", err)
340 }
341 if err := verifyConfigSnapshotFile(dest, b); err != nil {
342 return nil, fmt.Errorf("restored config changed during publish: %w", err)
343 }
344 if durable, err := commitPreparedRepairTransaction(tx, changeIndex); err != nil {
345 if durable {
346 return nil, fmt.Errorf("commit snapshot create undo state: cleanup pending journal: %w", err)
347 }
348 return nil, fmt.Errorf("commit snapshot create undo state: %w", err)
349 }
350 return tx, nil
351 }
352 if err := fileutil.AtomicCreateFile(dest, b, 0o600); err != nil {
353 return nil, err
354 }
355 repairSnapshotAfterCreate(dest)
356 if err := verifyConfigSnapshotFile(dest, b); err != nil {
357 return nil, fmt.Errorf("restored config changed during publish: %w", err)
358 }
359 return tx, nil
360 }
361
362 var repairSnapshotAfterCreate = func(string) {}
363
364 func verifyConfirmedConfigSnapshot(snap ConfigSnapshot, content []byte) error {
365 if err := config.ValidateBytes(content); err != nil {
366 return fmt.Errorf("config snapshot %q is invalid: %w", snap.ID, err)
367 }
368 sum := sha256.Sum256(content)
369 if !strings.EqualFold(hex.EncodeToString(sum[:]), snap.SHA256) {
370 return fmt.Errorf("config snapshot %q hash mismatch", snap.ID)
371 }
372 return nil
373 }
374
375 // snapshotRename is an indirection over the no-replace move so tests can force
376 // cross-device fallback paths.
377 var snapshotRename = renameRepairNodeNoReplace
378
379 func joinRestoreCleanupError(err error, backup string, cleanupErr error) error {
380 if cleanupErr == nil {
381 return err
382 }
383 return errors.Join(err, fmt.Errorf("restore original config from %s: %w", backup, cleanupErr))
384 }
385
386 func validateConfigSnapshot(dir string, snap *ConfigSnapshot) error {
387 if snap == nil || snap.SchemaVersion != 1 || snap.ID == "" || snap.Path == "" || snap.SHA256 == "" {
388 return fmt.Errorf("snapshot metadata is incomplete")
389 }
390 if len(snap.SHA256) != sha256.Size*2 {
391 return fmt.Errorf("snapshot SHA-256 is invalid")
392 }
393 if _, err := hex.DecodeString(snap.SHA256); err != nil {
394 return fmt.Errorf("snapshot SHA-256 is invalid")
395 }
396 rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(snap.Path))
397 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
398 return fmt.Errorf("snapshot path is outside snapshot directory")
399 }
400 expectedPath := filepath.Join(filepath.Clean(dir), snap.ID+".toml")
401 if filepath.Clean(snap.Path) != expectedPath {
402 return fmt.Errorf("snapshot id does not match path")
403 }
404 return nil
405 }
406
407 func configSnapshotPaths(id string) (dir, contentPath, metadataPath string, err error) {
408 id = strings.TrimSpace(id)
409 if id == "" || id == "." || id == ".." || filepath.Base(id) != id || strings.ContainsAny(id, `/\`) {
410 return "", "", "", fmt.Errorf("invalid config snapshot id %q", id)
411 }
412 dir = snapshotDir()
413 if dir == "" {
414 return "", "", "", fmt.Errorf("config snapshot directory is unavailable")
415 }
416 contentPath = filepath.Join(dir, id+".toml")
417 metadataPath = contentPath + ".json"
418 return dir, contentPath, metadataPath, nil
419 }
420
421 // readVerifiedConfigSnapshot validates the exact bytes it returns. Keeping
422 // validation and consumption in one read closes the verify-then-read window in
423 // direct (non-preview) snapshot restores.
424 func readVerifiedConfigSnapshot(snap ConfigSnapshot) ([]byte, error) {
425 b, err := os.ReadFile(snap.Path)
426 if err != nil {
427 return nil, err
428 }
429 if err := config.ValidateBytes(b); err != nil {
430 return nil, err
431 }
432 sum := sha256.Sum256(b)
433 got := hex.EncodeToString(sum[:])
434 if !strings.EqualFold(got, snap.SHA256) {
435 return nil, fmt.Errorf("snapshot %s failed SHA-256 verification", snap.ID)
436 }
437 return b, nil
438 }
439
440 func pruneConfigSnapshots(keep int) error {
441 snapshots, err := ListConfigSnapshots()
442 if err != nil {
443 return err
444 }
445 for _, snap := range snapshots[minimum(keep, len(snapshots)):] {
446 if err := pruneConfigSnapshot(snap); err != nil {
447 return err
448 }
449 }
450 return nil
451 }
452
453 // configSnapshotPruneAfterMove is a test seam for an uncooperative writer that
454 // recreates or changes a snapshot path after prune atomically displaces it.
455 var configSnapshotPruneAfterMove = func(string, string, string) {}
456
457 func pruneConfigSnapshot(snap ConfigSnapshot) error {
458 dir := snapshotDir()
459 metaPath := snap.Path + ".json"
460 expectedMeta, err := os.ReadFile(metaPath)
461 if err != nil {
462 if os.IsNotExist(err) {
463 return nil
464 }
465 return err
466 }
467 var current ConfigSnapshot
468 if err := json.Unmarshal(expectedMeta, &current); err != nil || validateConfigSnapshot(dir, &current) != nil || current != snap {
469 return fmt.Errorf("config snapshot %s metadata changed before prune", snap.ID)
470 }
471
472 metaCleanup, err := moveRepairNodeToUniqueCleanup(metaPath)
473 if err != nil {
474 return fmt.Errorf("prune config snapshot %s metadata: %w", snap.ID, err)
475 }
476 if metaCleanup == "" {
477 return nil
478 }
479 configSnapshotPruneAfterMove("metadata", metaPath, metaCleanup)
480 if err := verifyConfigSnapshotFile(metaCleanup, expectedMeta); err != nil {
481 restoreErr := renameRepairNodeNoReplace(metaCleanup, metaPath)
482 if restoreErr != nil {
483 return errors.Join(err, fmt.Errorf("preserve changed snapshot metadata at %s: %w", metaCleanup, restoreErr))
484 }
485 return fmt.Errorf("config snapshot %s metadata changed during prune: %w", snap.ID, err)
486 }
487 if _, err := os.Lstat(metaPath); err == nil {
488 return fmt.Errorf("config snapshot %s metadata was recreated during prune; displaced metadata remains at %s", snap.ID, metaCleanup)
489 } else if !os.IsNotExist(err) {
490 return errors.Join(err, fmt.Errorf("displaced snapshot metadata remains at %s", metaCleanup))
491 }
492
493 contentCleanup, err := moveRepairNodeToUniqueCleanup(snap.Path)
494 if err != nil {
495 restoreErr := renameRepairNodeNoReplace(metaCleanup, metaPath)
496 if restoreErr != nil {
497 return errors.Join(err, fmt.Errorf("restore snapshot metadata from %s: %w", metaCleanup, restoreErr))
498 }
499 return fmt.Errorf("prune config snapshot %s content: %w", snap.ID, err)
500 }
501 if contentCleanup == "" {
502 if err := os.Remove(metaCleanup); err != nil {
503 return fmt.Errorf("remove metadata for missing config snapshot %s: %w", snap.ID, err)
504 }
505 return nil
506 }
507 configSnapshotPruneAfterMove("content", snap.Path, contentCleanup)
508 moved := snap
509 moved.Path = contentCleanup
510 if _, err := readVerifiedConfigSnapshot(moved); err != nil {
511 contentRestoreErr := renameRepairNodeNoReplace(contentCleanup, snap.Path)
512 if contentRestoreErr != nil {
513 return errors.Join(err, fmt.Errorf("preserve changed snapshot content at %s: %w", contentCleanup, contentRestoreErr))
514 }
515 metaRestoreErr := renameRepairNodeNoReplace(metaCleanup, metaPath)
516 if metaRestoreErr != nil {
517 return errors.Join(err, fmt.Errorf("restore snapshot metadata from %s: %w", metaCleanup, metaRestoreErr))
518 }
519 return fmt.Errorf("config snapshot %s content changed during prune: %w", snap.ID, err)
520 }
521 if _, err := os.Lstat(metaPath); err == nil {
522 return fmt.Errorf(
523 "config snapshot %s metadata was recreated during content cleanup; displaced files remain at %s and %s",
524 snap.ID,
525 metaCleanup,
526 contentCleanup,
527 )
528 } else if !os.IsNotExist(err) {
529 return errors.Join(
530 err,
531 fmt.Errorf("displaced snapshot files remain at %s and %s", metaCleanup, contentCleanup),
532 )
533 }
534
535 // Metadata is removed first. A crash or content-cleanup failure therefore
536 // leaves only an ignored orphan content file, never a visible partial pair.
537 if err := os.Remove(metaCleanup); err != nil {
538 contentRestoreErr := renameRepairNodeNoReplace(contentCleanup, snap.Path)
539 if contentRestoreErr != nil {
540 return errors.Join(err, fmt.Errorf("restore snapshot content from %s: %w", contentCleanup, contentRestoreErr))
541 }
542 metaRestoreErr := renameRepairNodeNoReplace(metaCleanup, metaPath)
543 if metaRestoreErr != nil {
544 return errors.Join(err, fmt.Errorf("restore snapshot metadata from %s: %w", metaCleanup, metaRestoreErr))
545 }
546 return fmt.Errorf("remove config snapshot %s metadata: %w", snap.ID, err)
547 }
548 if err := os.Remove(contentCleanup); err != nil {
549 return fmt.Errorf("remove config snapshot %s content: %w", snap.ID, err)
550 }
551 return nil
552 }
553
554 func minimum(a, b int) int {
555 if a < b {
556 return a
557 }
558 return b
559 }
560
560 lines GO