| 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 |