返回 DeepSeek-Reasonix
plan.go
根目录 / internal / repair / plan.go
1 package repair
2
3 import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "io"
10 "io/fs"
11 "os"
12 "path/filepath"
13 "runtime"
14 "sort"
15 "strings"
16 "time"
17
18 "reasonix/internal/config"
19 textdiff "reasonix/internal/diff"
20 )
21
22 const RepairPlanSchemaVersion = 1
23
24 type RepairPlan struct {
25 SchemaVersion int `json:"schemaVersion"`
26 Summary string `json:"summary"`
27 Actions []RepairPlanAction `json:"actions"`
28 }
29
30 type RepairPlanAction struct {
31 Type string `json:"type"`
32 Scope string `json:"scope,omitempty"`
33 SnapshotID string `json:"snapshotId,omitempty"`
34 Target string `json:"target,omitempty"`
35 Reason string `json:"reason"`
36 }
37
38 type RepairPlanPreview struct {
39 Index int `json:"index"`
40 Type string `json:"type"`
41 Description string `json:"description"`
42 Diff string `json:"diff,omitempty"`
43 // StateID binds non-display inputs without exposing their paths or content.
44 StateID string `json:"stateId,omitempty"`
45
46 fileStates map[string]string
47 afterContent []byte
48 afterReadable bool
49 }
50
51 type repairPlanFileSnapshot struct {
52 StateID string
53 Content []byte
54 Readable bool
55 }
56
57 type repairPlanFileStateDescriptor struct {
58 Target string `json:"target"`
59 Kind string `json:"kind"`
60 Mode uint32 `json:"mode,omitempty"`
61 LinkTarget string `json:"linkTarget,omitempty"`
62 Content string `json:"content,omitempty"`
63 }
64
65 // RepairPlanID identifies the canonical plan content without trusting an ID
66 // supplied by a caller. It changes when the summary, action list, or any
67 // action field changes.
68 func RepairPlanID(plan RepairPlan) string {
69 canonical := struct {
70 SchemaVersion int `json:"schemaVersion"`
71 Summary string `json:"summary"`
72 Actions []RepairPlanAction `json:"actions"`
73 }{plan.SchemaVersion, plan.Summary, plan.Actions}
74 b, _ := json.Marshal(canonical)
75 sum := sha256.Sum256(b)
76 return hex.EncodeToString(sum[:])
77 }
78
79 // RepairPlanPreviewID binds a plan to the exact preview shown to the user.
80 // This includes the current filesystem-derived descriptions and diffs, so a
81 // changed file or action set cannot reuse an earlier confirmation.
82 func RepairPlanPreviewID(plan RepairPlan, previews []RepairPlanPreview) string {
83 canonical := struct {
84 PlanID string `json:"planId"`
85 Preview []RepairPlanPreview `json:"preview"`
86 }{RepairPlanID(plan), previews}
87 b, _ := json.Marshal(canonical)
88 sum := sha256.Sum256(b)
89 return hex.EncodeToString(sum[:])
90 }
91
92 func repairPlanStateID(value any) string {
93 b, _ := json.Marshal(value)
94 sum := sha256.Sum256(b)
95 return hex.EncodeToString(sum[:])
96 }
97
98 func repairPlanActionPreviewID(action RepairPlanAction, preview RepairPlanPreview) string {
99 preview.Index = 1
100 return repairPlanStateID(struct {
101 Action RepairPlanAction `json:"action"`
102 Preview RepairPlanPreview `json:"preview"`
103 }{action, preview})
104 }
105
106 func repairPlanFileSnapshotAt(path string) repairPlanFileSnapshot {
107 return repairPlanFileSnapshotFor(path, path)
108 }
109
110 // repairPlanFileSnapshotFor reads the node at readPath but binds identityPath
111 // into StateID. After a confirmed rename the quarantine path still proves the
112 // original destination's content without treating the quarantine suffix as a
113 // different confirmed target.
114 func repairPlanFileSnapshotFor(readPath, identityPath string) repairPlanFileSnapshot {
115 // Target binds the real destination into StateID without exposing the path
116 // in the exported preview: confirmation for project A cannot be replayed
117 // against project B even when both files have identical content.
118 state := repairPlanFileStateDescriptor{Target: repairPlanTargetIdentity(identityPath), Kind: "missing"}
119 info, err := os.Lstat(readPath)
120 if err != nil {
121 if !os.IsNotExist(err) {
122 state.Kind = "unreadable"
123 }
124 return repairPlanFileSnapshot{StateID: repairPlanStateID(state)}
125 }
126 snapshot := repairPlanFileSnapshot{}
127 state.Kind = "other"
128 state.Mode = uint32(info.Mode())
129 if info.Mode()&os.ModeSymlink != 0 {
130 state.Kind = "symlink"
131 state.LinkTarget, _ = os.Readlink(readPath)
132 } else if info.Mode().IsRegular() {
133 state.Kind = "file"
134 } else if info.IsDir() {
135 state.Kind = "directory"
136 }
137 if b, readErr := os.ReadFile(readPath); readErr == nil {
138 snapshot.Content = b
139 snapshot.Readable = true
140 }
141 snapshot.StateID = repairPlanReadStateIDFor(
142 identityPath,
143 info.Mode(),
144 state.Kind,
145 state.LinkTarget,
146 snapshot.Content,
147 snapshot.Readable,
148 )
149 return snapshot
150 }
151
152 // repairPlanReadStateIDFor binds the exact bytes or link target consumed by a
153 // repair operation. Checking the path before and after a read is insufficient:
154 // an uncooperative writer can temporarily replace its contents during the read
155 // and restore the expected node before the second path-based check.
156 func repairPlanReadStateIDFor(
157 identityPath string,
158 mode os.FileMode,
159 kind, linkTarget string,
160 content []byte,
161 readable bool,
162 ) string {
163 state := repairPlanFileStateDescriptor{
164 Target: repairPlanTargetIdentity(identityPath),
165 Kind: kind,
166 Mode: uint32(mode),
167 LinkTarget: linkTarget,
168 }
169 if readable {
170 sum := sha256.Sum256(content)
171 state.Content = hex.EncodeToString(sum[:])
172 } else if state.Kind == "file" || state.Kind == "symlink" {
173 state.Kind += "-unreadable"
174 }
175 return repairPlanStateID(state)
176 }
177
178 // repairPlanPublishedFileMode returns the FileMode that Lstat will report after
179 // publishing a regular file with the requested permission bits. Windows only
180 // honors the write bit and surfaces regular files as 0444 or 0666; pre-create
181 // ownership bindings must use that observed mode or undo will refuse to remove
182 // a file this repair just created.
183 func repairPlanPublishedFileMode(perm os.FileMode) os.FileMode {
184 perm &= os.ModePerm
185 if runtime.GOOS == "windows" {
186 if perm&0o222 == 0 {
187 return 0o444
188 }
189 return 0o666
190 }
191 return perm
192 }
193
194 // repairPlanPreparedCreateStateID binds a remove-on-undo create intent to the
195 // node that AtomicCreateFile will publish for the given content and mode.
196 func repairPlanPreparedCreateStateID(identityPath string, content []byte, perm os.FileMode) string {
197 return repairPlanReadStateIDFor(
198 identityPath,
199 repairPlanPublishedFileMode(perm),
200 "file",
201 "",
202 content,
203 true,
204 )
205 }
206
207 func repairPlanFileState(path string) string {
208 return repairPlanFileSnapshotAt(path).StateID
209 }
210
211 func verifyRepairPlanStateIDFor(readPath, identityPath, expected string) error {
212 actual := repairPlanFileSnapshotFor(readPath, identityPath).StateID
213 if expected != actual {
214 return fmt.Errorf("repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)", expected, actual)
215 }
216 return nil
217 }
218
219 func repairPlanDerivedStateSnapshot(target string) (string, map[string]string) {
220 paths := derivedStatePaths()
221 names := []string{target}
222 if target == "all" {
223 names = make([]string, 0, len(paths))
224 for name := range paths {
225 names = append(names, name)
226 }
227 sort.Strings(names)
228 }
229 states := make([]struct {
230 Name string `json:"name"`
231 State string `json:"state"`
232 }, 0, len(names))
233 fileStates := make(map[string]string, len(names))
234 for _, name := range names {
235 path := paths[name]
236 stateID := repairPlanFileState(path)
237 states = append(states, struct {
238 Name string `json:"name"`
239 State string `json:"state"`
240 }{Name: name, State: stateID})
241 fileStates[path] = stateID
242 }
243 return repairPlanStateID(states), fileStates
244 }
245
246 type ApplyPlanOptions struct {
247 Root string
248 AllowProject bool
249 // ExpectedPreviewID binds application to the preview that was confirmed.
250 // Empty preserves direct package callers that do not model an approval
251 // boundary; they are still bound to the preview captured at the start of
252 // this ApplyRepairPlan invocation. CLI confirmation paths always populate it.
253 ExpectedPreviewID string
254 }
255
256 type ApplyPlanResult struct {
257 Applied []string `json:"applied"`
258 }
259
260 func DecodeRepairPlan(data []byte) (RepairPlan, error) {
261 data = bytes.TrimSpace(data)
262 if bytes.HasPrefix(data, []byte("```")) {
263 if start := bytes.IndexByte(data, '{'); start >= 0 {
264 if end := bytes.LastIndexByte(data, '}'); end >= start {
265 data = data[start : end+1]
266 }
267 }
268 }
269 dec := json.NewDecoder(bytes.NewReader(data))
270 dec.DisallowUnknownFields()
271 var plan RepairPlan
272 if err := dec.Decode(&plan); err != nil {
273 return RepairPlan{}, fmt.Errorf("decode repair plan: %w", err)
274 }
275 var trailing any
276 if err := dec.Decode(&trailing); err != io.EOF {
277 if err == nil {
278 return RepairPlan{}, fmt.Errorf("decode repair plan: trailing JSON")
279 }
280 return RepairPlan{}, fmt.Errorf("decode repair plan: %w", err)
281 }
282 if err := ValidateRepairPlan(plan); err != nil {
283 return RepairPlan{}, err
284 }
285 return plan, nil
286 }
287
288 func ValidateRepairPlan(plan RepairPlan) error {
289 if plan.SchemaVersion != RepairPlanSchemaVersion {
290 return fmt.Errorf("repair plan schemaVersion must be %d", RepairPlanSchemaVersion)
291 }
292 if len(plan.Actions) > 8 {
293 return fmt.Errorf("repair plan must contain at most 8 actions")
294 }
295 if len(plan.Summary) > 1000 {
296 return fmt.Errorf("repair plan summary is too long")
297 }
298 if containsPlanControl(plan.Summary) {
299 return fmt.Errorf("repair plan summary contains control characters")
300 }
301 for i, action := range plan.Actions {
302 if len(action.Reason) > 500 {
303 return fmt.Errorf("repair action %d reason is too long", i+1)
304 }
305 if containsPlanControl(action.Reason) {
306 return fmt.Errorf("repair action %d reason contains control characters", i+1)
307 }
308 switch action.Type {
309 case "repair_config":
310 if action.Scope != "global" && action.Scope != "project" {
311 return fmt.Errorf("repair action %d: repair_config scope must be global or project", i+1)
312 }
313 if action.SnapshotID != "" || action.Target != "" {
314 return fmt.Errorf("repair action %d: repair_config has unexpected parameters", i+1)
315 }
316 case "restore_snapshot":
317 if strings.TrimSpace(action.SnapshotID) == "" || action.Scope != "" || action.Target != "" {
318 return fmt.Errorf("repair action %d: restore_snapshot requires only snapshotId", i+1)
319 }
320 case "rebuild_derived_state":
321 switch action.Target {
322 case "tabs", "projects", "window", "zoom", "all":
323 default:
324 return fmt.Errorf("repair action %d: invalid derived-state target", i+1)
325 }
326 if action.Scope != "" || action.SnapshotID != "" {
327 return fmt.Errorf("repair action %d: rebuild_derived_state has unexpected parameters", i+1)
328 }
329 case "rollback_update":
330 if action.Scope != "" || action.SnapshotID != "" || action.Target != "" {
331 return fmt.Errorf("repair action %d: rollback_update takes no parameters", i+1)
332 }
333 default:
334 return fmt.Errorf("repair action %d: type %q is not allowed", i+1, action.Type)
335 }
336 }
337 return nil
338 }
339
340 func containsPlanControl(text string) bool {
341 for _, r := range text {
342 if r < 0x20 || r == 0x7f {
343 return true
344 }
345 }
346 return false
347 }
348
349 func PreviewRepairPlan(plan RepairPlan, opts ApplyPlanOptions) ([]RepairPlanPreview, error) {
350 if err := ValidateRepairPlan(plan); err != nil {
351 return nil, err
352 }
353 previews := make([]RepairPlanPreview, 0, len(plan.Actions))
354 for i, action := range plan.Actions {
355 preview := RepairPlanPreview{Index: i + 1, Type: action.Type}
356 switch action.Type {
357 case "repair_config":
358 if action.Scope == "project" && !opts.AllowProject {
359 return nil, fmt.Errorf("action %d requires --allow-project", i+1)
360 }
361 path := config.UserConfigPath()
362 if action.Scope == "project" {
363 path = projectConfigPath(opts.Root)
364 }
365 before := repairPlanFileSnapshotAt(path)
366 after := repairPlanFileSnapshot{StateID: "none"}
367 if action.Scope == "global" {
368 after = repairPlanFileSnapshotAt(lastKnownGoodConfigPath())
369 }
370 preview.Description = "Quarantine invalid " + action.Scope + " configuration"
371 preview.Diff = textdiff.Build(action.Scope+"-config.toml", string(before.Content), string(after.Content), textdiff.Modify).Diff
372 preview.StateID = repairPlanStateID(struct {
373 Before string `json:"before"`
374 After string `json:"after"`
375 }{before.StateID, after.StateID})
376 preview.fileStates = map[string]string{path: before.StateID}
377 if action.Scope == "global" {
378 preview.fileStates[lastKnownGoodConfigPath()] = after.StateID
379 }
380 preview.afterContent = append([]byte(nil), after.Content...)
381 preview.afterReadable = after.Readable
382 case "restore_snapshot":
383 snap, err := configSnapshotByID(action.SnapshotID)
384 if err != nil {
385 return nil, err
386 }
387 after := repairPlanFileSnapshotAt(snap.Path)
388 metadata := repairPlanFileSnapshotAt(snap.Path + ".json")
389 collection := repairPlanFileSnapshotAt(snapshotDir())
390 if !after.Readable {
391 return nil, fmt.Errorf("config snapshot %q is unreadable", snap.ID)
392 }
393 if err := verifyConfirmedConfigSnapshot(snap, after.Content); err != nil {
394 return nil, err
395 }
396 before := repairPlanFileSnapshotAt(config.UserConfigPath())
397 preview.Description = "Restore verified global configuration snapshot " + snap.ID
398 preview.Diff = textdiff.Build("global-config.toml", string(before.Content), string(after.Content), textdiff.Modify).Diff
399 preview.StateID = repairPlanStateID(struct {
400 Current string `json:"current"`
401 Snapshot string `json:"snapshot"`
402 Metadata string `json:"metadata"`
403 Collection string `json:"collection"`
404 }{before.StateID, after.StateID, metadata.StateID, collection.StateID})
405 preview.fileStates = map[string]string{
406 config.UserConfigPath(): before.StateID,
407 snap.Path: after.StateID,
408 snap.Path + ".json": metadata.StateID,
409 snapshotDir(): collection.StateID,
410 }
411 preview.afterContent = append([]byte(nil), after.Content...)
412 preview.afterReadable = after.Readable
413 case "rebuild_derived_state":
414 preview.Description = "Quarantine and rebuild derived desktop state: " + action.Target
415 preview.StateID, preview.fileStates = repairPlanDerivedStateSnapshot(action.Target)
416 case "rollback_update":
417 tx, err := ReadPendingUpdate()
418 if err != nil {
419 return nil, fmt.Errorf("action %d: no rollback-ready update: %w", i+1, err)
420 }
421 preview.Description = fmt.Sprintf("Restore Reasonix %s over probationary %s", tx.FromVersion, tx.ToVersion)
422 preview.StateID, preview.fileStates = pendingUpdateBoundPreview(tx)
423 }
424 previews = append(previews, preview)
425 }
426 return previews, nil
427 }
428
429 func ApplyRepairPlan(plan RepairPlan, opts ApplyPlanOptions) (ApplyPlanResult, error) {
430 preview, err := PreviewRepairPlan(plan, opts)
431 if err != nil {
432 return ApplyPlanResult{Applied: []string{}}, err
433 }
434 expected := strings.TrimSpace(opts.ExpectedPreviewID)
435 if expected != "" {
436 actual := RepairPlanPreviewID(plan, preview)
437 if expected != actual {
438 return ApplyPlanResult{Applied: []string{}}, fmt.Errorf("repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)", expected, actual)
439 }
440 }
441 unlockTransaction, err := lockRepairTransaction()
442 if err != nil {
443 return ApplyPlanResult{Applied: []string{}}, err
444 }
445 defer unlockTransaction()
446 if err := reconcilePreparedRepairTransaction(); err != nil {
447 return ApplyPlanResult{}, fmt.Errorf("reconcile pending repair mutation: %w", err)
448 }
449 boundPreview := preview
450 result := ApplyPlanResult{Applied: []string{}}
451 // Every mutating action appends to this shared transaction before it persists
452 // progress. This keeps the complete applied prefix durable even if the process
453 // exits inside an action, before control returns to this loop.
454 planTx := newRepairTransaction(time.Now())
455 for i, action := range plan.Actions {
456 // Empty ExpectedPreviewID only skips the cross-invocation confirmation
457 // check above. The filesystem state observed by this invocation's preview
458 // is always rechecked under the mutation locks before applying.
459 applied, actionErr := applyRepairPlanAction(plan, action, boundPreview[i], opts, planTx, true)
460 result.Applied = append(result.Applied, applied...)
461 if actionErr != nil {
462 return result, fmt.Errorf("action %d: %w", i+1, actionErr)
463 }
464 }
465 return result, nil
466 }
467
468 func applyRepairPlanAction(
469 plan RepairPlan,
470 action RepairPlanAction,
471 bound RepairPlanPreview,
472 opts ApplyPlanOptions,
473 planTx *RepairTransaction,
474 enforcePreview bool,
475 ) ([]string, error) {
476 if action.Type == "rollback_update" {
477 // Target locks are taken inside rollback under the pending-update
478 // lock so Guard and the updater serialize on the same release-unit
479 // paths. Re-check the bound preview after those locks are held.
480 var rollback UpdateRollbackResult
481 var err error
482 if enforcePreview {
483 rollback, err = rollbackPendingUpdateState(bound.StateID, bound.fileStates)
484 } else {
485 rollback, err = RollbackPendingUpdate()
486 }
487 if err != nil {
488 return nil, err
489 }
490 if !rollback.RolledBack {
491 if enforcePreview {
492 return nil, fmt.Errorf("repair plan preview changed since confirmation; re-preview and re-confirm")
493 }
494 return nil, nil
495 }
496 return []string{"rolled back update to " + rollback.ToVersion}, nil
497 }
498
499 paths, err := repairPlanActionMutationPaths(action, opts)
500 if err != nil {
501 return nil, err
502 }
503 if enforcePreview {
504 paths = paths[:0]
505 for path := range bound.fileStates {
506 paths = append(paths, path)
507 }
508 }
509 unlock, err := lockRepairMutations(paths...)
510 if err != nil {
511 return nil, err
512 }
513 defer unlock()
514
515 if enforcePreview {
516 current, previewErr := PreviewRepairPlan(RepairPlan{
517 SchemaVersion: plan.SchemaVersion,
518 Summary: plan.Summary,
519 Actions: []RepairPlanAction{action},
520 }, opts)
521 if previewErr != nil {
522 return nil, fmt.Errorf("repair plan preview changed since confirmation; re-preview and re-confirm: %w", previewErr)
523 }
524 expectedAction := repairPlanActionPreviewID(action, bound)
525 actualAction := repairPlanActionPreviewID(action, current[0])
526 if expectedAction != actualAction {
527 return nil, fmt.Errorf("repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)", expectedAction, actualAction)
528 }
529 }
530 expectedStates := bound.fileStates
531 confirmedContent := bound.afterContent
532 hasConfirmedContent := bound.afterReadable
533 if !enforcePreview {
534 expectedStates = nil
535 confirmedContent = nil
536 hasConfirmedContent = false
537 }
538
539 switch action.Type {
540 case "repair_config":
541 report, err := inspectAndRepairConfigUnlocked(ConfigOptions{
542 Root: opts.Root,
543 Apply: true,
544 IncludeProject: action.Scope == "project",
545 OnlyScope: action.Scope,
546 expectedStates: expectedStates,
547 confirmedGlobalRestore: confirmedContent,
548 hasConfirmedRestore: hasConfirmedContent,
549 repairTransaction: planTx,
550 })
551 if err != nil {
552 return nil, err
553 }
554 return report.Applied, nil
555 case "restore_snapshot":
556 tx, err := restoreConfigSnapshotBoundUnlocked(action.SnapshotID, expectedStates, confirmedContent, planTx)
557 if err != nil {
558 return nil, err
559 }
560 return []string{"restored config snapshot (undo " + tx.ID + ")"}, nil
561 case "rebuild_derived_state":
562 return rebuildDerivedStateBoundUnlocked(action.Target, expectedStates, planTx)
563 default:
564 return nil, fmt.Errorf("unsupported repair action %q", action.Type)
565 }
566 }
567
568 // pendingUpdateBoundPreview binds the pending transaction identity and the
569 // live release-unit nodes that rollback would displace. The transaction alone
570 // is not enough: another installer can replace the current binaries while the
571 // pending JSON stays unchanged. App bundles bind a full tree digest for both
572 // the live bundle and its backup so interior executable drift invalidates the
573 // confirmation.
574 func pendingUpdateBoundPreview(tx *UpdateTransaction) (string, map[string]string) {
575 if tx == nil {
576 return "", nil
577 }
578 files := pendingUpdateFiles(tx)
579 type unitState struct {
580 State string `json:"state"`
581 }
582 current := make([]unitState, 0, len(files)+2)
583 fileStates := make(map[string]string, len(files)+2)
584 bind := func(path string) {
585 path = strings.TrimSpace(path)
586 if path == "" {
587 return
588 }
589 if _, ok := fileStates[path]; ok {
590 return
591 }
592 stateID := repairPlanReleaseNodeState(path)
593 fileStates[path] = stateID
594 current = append(current, unitState{State: stateID})
595 }
596 for _, f := range files {
597 bind(f.TargetPath)
598 if strings.TrimSpace(f.BackupPath) != "" {
599 bind(f.BackupPath)
600 }
601 }
602 if tx.TargetKind == "file" {
603 bind(installedFileUpdateStatePath(tx))
604 }
605 bind(tx.TargetPath)
606 if strings.EqualFold(strings.TrimSpace(tx.TargetKind), "app-bundle") || strings.TrimSpace(tx.BackupPath) != "" {
607 bind(tx.BackupPath)
608 }
609 return repairPlanStateID(struct {
610 Transaction string `json:"transaction"`
611 Current []unitState `json:"current"`
612 }{repairPlanStateID(tx), current}), fileStates
613 }
614
615 // repairPlanReleaseNodeState binds either a single-file identity or a full
616 // directory tree digest. Directory kind/mode alone is not enough for .app
617 // bundles: interior executables can change without touching the root node.
618 func repairPlanReleaseNodeState(path string) string {
619 return repairPlanReleaseNodeStateFor(path, path)
620 }
621
622 func repairPlanReleaseNodeStateFor(readPath, identityPath string) string {
623 info, err := os.Lstat(readPath)
624 if err != nil {
625 return repairPlanFileSnapshotFor(readPath, identityPath).StateID
626 }
627 if info.IsDir() {
628 return repairPlanTreeStateIDFor(readPath, identityPath)
629 }
630 return repairPlanFileSnapshotFor(readPath, identityPath).StateID
631 }
632
633 func verifyRepairPlanReleaseNodeStateFor(readPath, identityPath, expected string) error {
634 actual := repairPlanReleaseNodeStateFor(readPath, identityPath)
635 if expected != actual {
636 return fmt.Errorf("repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)", expected, actual)
637 }
638 return nil
639 }
640
641 type repairPlanTreeEntry struct {
642 Rel string `json:"rel"`
643 Kind string `json:"kind"`
644 Mode uint32 `json:"mode,omitempty"`
645 Content string `json:"content,omitempty"`
646 }
647
648 func repairPlanTreeEntries(root string) ([]repairPlanTreeEntry, error) {
649 entries := make([]repairPlanTreeEntry, 0, 64)
650 walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
651 if err != nil {
652 entries = append(entries, repairPlanTreeEntry{Rel: path, Kind: "unreadable"})
653 return nil
654 }
655 rel, relErr := filepath.Rel(root, path)
656 if relErr != nil {
657 rel = path
658 }
659 if rel == "." {
660 rel = ""
661 }
662 info, infoErr := d.Info()
663 if infoErr != nil {
664 entries = append(entries, repairPlanTreeEntry{Rel: rel, Kind: "unreadable"})
665 return nil
666 }
667 entry := repairPlanTreeEntry{Rel: filepath.ToSlash(rel), Mode: uint32(info.Mode())}
668 switch {
669 case info.Mode()&os.ModeSymlink != 0:
670 entry.Kind = "symlink"
671 if target, readErr := os.Readlink(path); readErr == nil {
672 entry.Content = target
673 } else {
674 entry.Kind = "symlink-unreadable"
675 }
676 case info.IsDir():
677 entry.Kind = "directory"
678 case info.Mode().IsRegular():
679 entry.Kind = "file"
680 if sum, hashErr := hashFile(path); hashErr == nil {
681 entry.Content = sum
682 } else {
683 entry.Kind = "file-unreadable"
684 }
685 default:
686 entry.Kind = "other"
687 }
688 entries = append(entries, entry)
689 return nil
690 })
691 sort.Slice(entries, func(i, j int) bool {
692 if entries[i].Rel == entries[j].Rel {
693 return entries[i].Kind < entries[j].Kind
694 }
695 return entries[i].Rel < entries[j].Rel
696 })
697 return entries, walkErr
698 }
699
700 // repairPlanTreeContentStateID hashes a directory tree without binding its
701 // root path. This lets an update handoff prove that the staged bundle and the
702 // installed bundle contain the same bytes even though they live at different
703 // paths.
704 func repairPlanTreeContentStateID(root string) (string, error) {
705 info, err := os.Lstat(root)
706 if err != nil {
707 return "", err
708 }
709 if !info.IsDir() {
710 return "", fmt.Errorf("expected directory, got %s", info.Mode().Type())
711 }
712 entries, err := repairPlanTreeEntries(root)
713 if err != nil {
714 return "", err
715 }
716 for _, entry := range entries {
717 switch entry.Kind {
718 case "unreadable", "file-unreadable", "symlink-unreadable":
719 return "", fmt.Errorf("cannot read bundle entry %q", entry.Rel)
720 case "other":
721 return "", fmt.Errorf("unsupported bundle entry %q", entry.Rel)
722 }
723 }
724 return repairPlanStateID(entries), nil
725 }
726
727 func repairPlanTreeStateIDFor(readRoot, identityRoot string) string {
728 entries, err := repairPlanTreeEntries(readRoot)
729 if err != nil {
730 entries = []repairPlanTreeEntry{{Rel: readRoot, Kind: "unreadable"}}
731 }
732 return repairPlanStateID(struct {
733 Target string `json:"target"`
734 Entries []repairPlanTreeEntry `json:"entries"`
735 }{repairPlanTargetIdentity(identityRoot), entries})
736 }
737
738 func pendingUpdateFiles(tx *UpdateTransaction) []UpdateTransactionFile {
739 if tx == nil {
740 return nil
741 }
742 if len(tx.Files) > 0 {
743 return tx.Files
744 }
745 return []UpdateTransactionFile{{
746 TargetPath: tx.TargetPath,
747 BackupPath: tx.BackupPath,
748 SHA256: tx.BackupSHA256,
749 }}
750 }
751
752 func pendingUpdateTargetPaths(tx *UpdateTransaction) []string {
753 if tx == nil {
754 return nil
755 }
756 files := pendingUpdateFiles(tx)
757 paths := make([]string, 0, len(files)+1)
758 seen := map[string]struct{}{}
759 add := func(path string) {
760 path = strings.TrimSpace(path)
761 if path == "" {
762 return
763 }
764 if _, ok := seen[path]; ok {
765 return
766 }
767 seen[path] = struct{}{}
768 paths = append(paths, path)
769 }
770 for _, f := range files {
771 add(f.TargetPath)
772 }
773 add(tx.TargetPath)
774 if strings.EqualFold(strings.TrimSpace(tx.TargetKind), "app-bundle") {
775 add(tx.BackupPath)
776 add(tx.OrphanedBackupPath)
777 }
778 return paths
779 }
780
781 func repairPlanActionMutationPaths(action RepairPlanAction, opts ApplyPlanOptions) ([]string, error) {
782 switch action.Type {
783 case "repair_config":
784 return configRepairTargetPaths(ConfigOptions{Root: opts.Root, IncludeProject: action.Scope == "project", OnlyScope: action.Scope})
785 case "restore_snapshot":
786 dir, contentPath, metadataPath, err := configSnapshotPaths(action.SnapshotID)
787 if err != nil {
788 return nil, err
789 }
790 return []string{config.UserConfigPath(), dir, contentPath, metadataPath}, nil
791 case "rebuild_derived_state":
792 return derivedStateTargetPaths(action.Target)
793 default:
794 return nil, nil
795 }
796 }
797
798 func verifyRepairPlanFileState(path string, expectedStates map[string]string) error {
799 if len(expectedStates) == 0 {
800 return nil
801 }
802 expected, ok := expectedStates[path]
803 if !ok {
804 return fmt.Errorf("repair plan preview did not bind target state; re-preview and re-confirm")
805 }
806 return verifyRepairPlanStateID(path, expected)
807 }
808
809 func verifyRepairPlanFileStates(expectedStates map[string]string) error {
810 if len(expectedStates) == 0 {
811 return nil
812 }
813 paths := make([]string, 0, len(expectedStates))
814 for path := range expectedStates {
815 paths = append(paths, path)
816 }
817 sort.Strings(paths)
818 for _, path := range paths {
819 if err := verifyRepairPlanFileState(path, expectedStates); err != nil {
820 return err
821 }
822 }
823 return nil
824 }
825
826 func verifyRepairPlanStateID(path, expected string) error {
827 actual := repairPlanFileState(path)
828 if expected != actual {
829 return fmt.Errorf("repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)", expected, actual)
830 }
831 return nil
832 }
833
834 func configSnapshotByID(id string) (ConfigSnapshot, error) {
835 snapshots, err := ListConfigSnapshots()
836 if err != nil {
837 return ConfigSnapshot{}, err
838 }
839 for _, snap := range snapshots {
840 if snap.ID == id {
841 return snap, nil
842 }
843 }
844 return ConfigSnapshot{}, fmt.Errorf("config snapshot %q not found", id)
845 }
846
847 func projectConfigPath(root string) string {
848 root = strings.TrimSpace(root)
849 if root == "" || root == "." {
850 return "reasonix.toml"
851 }
852 return filepath.Join(root, "reasonix.toml")
853 }
854
854 lines GO