| 1 | package repair |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/config" |
| 11 | "reasonix/internal/fileutil" |
| 12 | ) |
| 13 | |
| 14 | type ConfigCheck struct { |
| 15 | Scope string `json:"scope"` |
| 16 | Path string `json:"path"` |
| 17 | Exists bool `json:"exists"` |
| 18 | Valid bool `json:"valid"` |
| 19 | Error string `json:"error,omitempty"` |
| 20 | SnapshotPath string `json:"snapshotPath,omitempty"` |
| 21 | } |
| 22 | |
| 23 | type ConfigReport struct { |
| 24 | Checks []ConfigCheck `json:"checks"` |
| 25 | Applied []string `json:"applied"` |
| 26 | } |
| 27 | |
| 28 | type ConfigOptions struct { |
| 29 | Root string |
| 30 | Apply bool |
| 31 | IncludeProject bool |
| 32 | OnlyScope string |
| 33 | Now func() time.Time |
| 34 | |
| 35 | expectedStates map[string]string |
| 36 | confirmedGlobalRestore []byte |
| 37 | hasConfirmedRestore bool |
| 38 | repairTransaction *RepairTransaction |
| 39 | } |
| 40 | |
| 41 | func InspectAndRepairConfig(opts ConfigOptions) (ConfigReport, error) { |
| 42 | if !opts.Apply { |
| 43 | return inspectAndRepairConfigUnlocked(opts) |
| 44 | } |
| 45 | paths, err := configRepairTargetPaths(opts) |
| 46 | if err != nil { |
| 47 | return ConfigReport{}, err |
| 48 | } |
| 49 | // Direct callers do not carry an external preview ID. Bind their invocation |
| 50 | // before waiting on either lock so a newer config or snapshot cannot become |
| 51 | // the implicit object of an older call. |
| 52 | if opts.expectedStates == nil { |
| 53 | opts.expectedStates = make(map[string]string, len(paths)) |
| 54 | for _, path := range paths { |
| 55 | opts.expectedStates[path] = repairPlanFileState(path) |
| 56 | } |
| 57 | if snapshot := lastKnownGoodConfigPath(); snapshot != "" { |
| 58 | bound := repairPlanFileSnapshotAt(snapshot) |
| 59 | opts.confirmedGlobalRestore = append([]byte(nil), bound.Content...) |
| 60 | opts.hasConfirmedRestore = bound.Readable |
| 61 | } |
| 62 | } |
| 63 | unlockTransaction, err := lockRepairTransaction() |
| 64 | if err != nil { |
| 65 | return ConfigReport{}, err |
| 66 | } |
| 67 | defer unlockTransaction() |
| 68 | if err := reconcilePreparedRepairTransaction(); err != nil { |
| 69 | return ConfigReport{}, fmt.Errorf("repair config: reconcile pending mutation: %w", err) |
| 70 | } |
| 71 | unlock, err := lockRepairMutations(paths...) |
| 72 | if err != nil { |
| 73 | return ConfigReport{}, err |
| 74 | } |
| 75 | defer unlock() |
| 76 | if err := verifyRepairPlanFileStates(opts.expectedStates); err != nil { |
| 77 | return ConfigReport{}, err |
| 78 | } |
| 79 | return inspectAndRepairConfigUnlocked(opts) |
| 80 | } |
| 81 | |
| 82 | func inspectAndRepairConfigUnlocked(opts ConfigOptions) (ConfigReport, error) { |
| 83 | if opts.OnlyScope != "" && opts.OnlyScope != "global" && opts.OnlyScope != "project" { |
| 84 | return ConfigReport{}, fmt.Errorf("unknown config repair scope %q", opts.OnlyScope) |
| 85 | } |
| 86 | if opts.Now == nil { |
| 87 | opts.Now = time.Now |
| 88 | } |
| 89 | global := config.UserConfigPath() |
| 90 | project := filepath.Join(opts.Root, "reasonix.toml") |
| 91 | if opts.Root == "" || opts.Root == "." { |
| 92 | project = "reasonix.toml" |
| 93 | } |
| 94 | paths := []struct{ scope, path string }{{"global", global}, {"project", project}} |
| 95 | report := ConfigReport{Checks: make([]ConfigCheck, 0, len(paths)), Applied: []string{}} |
| 96 | tx := opts.repairTransaction |
| 97 | if tx == nil { |
| 98 | tx = newRepairTransaction(opts.Now()) |
| 99 | } |
| 100 | for _, item := range paths { |
| 101 | check := inspectConfig(item.scope, item.path) |
| 102 | if item.scope == "global" { |
| 103 | check.SnapshotPath = lastKnownGoodConfigPath() |
| 104 | } |
| 105 | report.Checks = append(report.Checks, check) |
| 106 | if !opts.Apply || !check.Exists || check.Valid || (opts.OnlyScope != "" && item.scope != opts.OnlyScope) || (item.scope == "project" && !opts.IncludeProject) { |
| 107 | continue |
| 108 | } |
| 109 | if err := verifyRepairPlanFileState(item.path, opts.expectedStates); err != nil { |
| 110 | return report, err |
| 111 | } |
| 112 | if item.scope == "global" { |
| 113 | if err := verifyRepairPlanFileState(lastKnownGoodConfigPath(), opts.expectedStates); err != nil { |
| 114 | return report, err |
| 115 | } |
| 116 | } |
| 117 | repairMutationBeforeRename(item.path) |
| 118 | if err := verifyRepairPlanFileState(item.path, opts.expectedStates); err != nil { |
| 119 | return report, err |
| 120 | } |
| 121 | if item.scope == "global" { |
| 122 | if err := verifyRepairPlanFileState(lastKnownGoodConfigPath(), opts.expectedStates); err != nil { |
| 123 | return report, err |
| 124 | } |
| 125 | } |
| 126 | quarantine := item.path + ".reasonix-quarantine-" + opts.Now().UTC().Format("20060102T150405Z") |
| 127 | changeIndex := len(tx.Changes) |
| 128 | tx.Changes = append(tx.Changes, preparedRepairChangeForPrevious(item.scope, item.path, quarantine)) |
| 129 | if err := persistPreparedRepairTransaction(tx); err != nil { |
| 130 | return report, fmt.Errorf("prepare quarantine %s config: %w", item.scope, err) |
| 131 | } |
| 132 | repairMutationAfterPrepare(item.path) |
| 133 | if err := renameRepairNodeNoReplace(item.path, quarantine); err != nil { |
| 134 | return report, fmt.Errorf("quarantine %s config: %w", item.scope, err) |
| 135 | } |
| 136 | repairMutationAfterRename(item.path) |
| 137 | if expected := opts.expectedStates[item.path]; expected != "" { |
| 138 | if err := verifyRepairPlanStateIDFor(quarantine, item.path, expected); err != nil { |
| 139 | if restoreErr := restoreRepairNodeIfAbsent(quarantine, item.path); restoreErr != nil { |
| 140 | return report, fmt.Errorf("quarantine %s config changed after confirmation and restore failed: %v: %w", item.scope, restoreErr, err) |
| 141 | } |
| 142 | return report, err |
| 143 | } |
| 144 | } |
| 145 | if durable, err := commitPreparedRepairTransaction(tx, changeIndex); err != nil { |
| 146 | if durable { |
| 147 | return report, fmt.Errorf("commit quarantine %s config undo state: cleanup pending journal: %w", item.scope, err) |
| 148 | } |
| 149 | restoreErr := restoreRepairNodeIfAbsent(quarantine, item.path) |
| 150 | if restoreErr != nil { |
| 151 | return report, fmt.Errorf("commit quarantine %s config undo state: %w; confirmed config retained at %s: %v", item.scope, err, quarantine, restoreErr) |
| 152 | } |
| 153 | return report, fmt.Errorf("commit quarantine %s config undo state: %w", item.scope, err) |
| 154 | } |
| 155 | if _, err := os.Lstat(item.path); err == nil { |
| 156 | appendRepairLogBestEffort(tx) |
| 157 | return report, fmt.Errorf("repair plan preview changed since confirmation; target was recreated during quarantine; confirmed state remains at %s", quarantine) |
| 158 | } else if !os.IsNotExist(err) { |
| 159 | return report, err |
| 160 | } |
| 161 | report.Applied = append(report.Applied, "quarantined "+item.scope+" config at "+quarantine) |
| 162 | if item.scope == "global" { |
| 163 | restoreErr := os.ErrNotExist |
| 164 | if opts.expectedStates != nil { |
| 165 | if opts.hasConfirmedRestore { |
| 166 | if restoreErr = config.ValidateBytes(opts.confirmedGlobalRestore); restoreErr == nil { |
| 167 | restoreErr = fileutil.AtomicCreateFile(item.path, opts.confirmedGlobalRestore, 0o600) |
| 168 | } |
| 169 | } |
| 170 | } else { |
| 171 | restoreErr = restoreLastKnownGoodConfig(item.path) |
| 172 | } |
| 173 | if restoreErr == nil { |
| 174 | report.Applied = append(report.Applied, "restored global config from last-known-good snapshot") |
| 175 | } else if opts.expectedStates != nil && opts.hasConfirmedRestore { |
| 176 | return report, fmt.Errorf("restore confirmed last-known-good config: %w", restoreErr) |
| 177 | } |
| 178 | } |
| 179 | report.Checks[len(report.Checks)-1] = inspectConfig(item.scope, item.path) |
| 180 | if item.scope == "global" { |
| 181 | report.Checks[len(report.Checks)-1].SnapshotPath = lastKnownGoodConfigPath() |
| 182 | } |
| 183 | } |
| 184 | if len(tx.Changes) > 0 { |
| 185 | appendRepairLogBestEffort(tx) |
| 186 | } |
| 187 | return report, nil |
| 188 | } |
| 189 | |
| 190 | func configRepairTargetPaths(opts ConfigOptions) ([]string, error) { |
| 191 | if opts.OnlyScope != "" && opts.OnlyScope != "global" && opts.OnlyScope != "project" { |
| 192 | return nil, fmt.Errorf("unknown config repair scope %q", opts.OnlyScope) |
| 193 | } |
| 194 | globalPaths := func() []string { |
| 195 | paths := []string{config.UserConfigPath()} |
| 196 | if snapshot := lastKnownGoodConfigPath(); snapshot != "" { |
| 197 | paths = append(paths, snapshot) |
| 198 | } |
| 199 | return paths |
| 200 | } |
| 201 | project := filepath.Join(opts.Root, "reasonix.toml") |
| 202 | if opts.Root == "" || opts.Root == "." { |
| 203 | project = "reasonix.toml" |
| 204 | } |
| 205 | switch opts.OnlyScope { |
| 206 | case "global": |
| 207 | return globalPaths(), nil |
| 208 | case "project": |
| 209 | return []string{project}, nil |
| 210 | default: |
| 211 | paths := globalPaths() |
| 212 | if opts.IncludeProject { |
| 213 | paths = append(paths, project) |
| 214 | } |
| 215 | return paths, nil |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | func inspectConfig(scope, path string) ConfigCheck { |
| 220 | check := ConfigCheck{Scope: scope, Path: path, Valid: true} |
| 221 | if path == "" { |
| 222 | return check |
| 223 | } |
| 224 | if _, err := os.Lstat(path); err != nil { |
| 225 | if !os.IsNotExist(err) { |
| 226 | check.Valid = false |
| 227 | check.Error = err.Error() |
| 228 | } |
| 229 | return check |
| 230 | } |
| 231 | check.Exists = true |
| 232 | b, err := os.ReadFile(path) |
| 233 | if err == nil { |
| 234 | err = config.ValidateBytes(b) |
| 235 | } |
| 236 | if err != nil { |
| 237 | check.Valid = false |
| 238 | check.Error = err.Error() |
| 239 | } |
| 240 | return check |
| 241 | } |
| 242 | |
| 243 | type snapshotMeta struct { |
| 244 | SchemaVersion int `json:"schemaVersion"` |
| 245 | SourcePath string `json:"sourcePath"` |
| 246 | RecordedAt string `json:"recordedAt"` |
| 247 | Version string `json:"version,omitempty"` |
| 248 | } |
| 249 | |
| 250 | func RecordHealthyConfig(version string) error { |
| 251 | path := config.UserConfigPath() |
| 252 | if path == "" { |
| 253 | return nil |
| 254 | } |
| 255 | snapshot := lastKnownGoodConfigPath() |
| 256 | if snapshot == "" { |
| 257 | return nil |
| 258 | } |
| 259 | unlock, err := lockRepairMutations(path, snapshot, snapshot+".json", snapshotDir()) |
| 260 | if err != nil { |
| 261 | return err |
| 262 | } |
| 263 | defer unlock() |
| 264 | |
| 265 | b, err := os.ReadFile(path) |
| 266 | if err != nil { |
| 267 | if os.IsNotExist(err) { |
| 268 | return nil |
| 269 | } |
| 270 | return err |
| 271 | } |
| 272 | if err := config.ValidateBytes(b); err != nil { |
| 273 | return err |
| 274 | } |
| 275 | now := time.Now().UTC() |
| 276 | meta := snapshotMeta{SchemaVersion: 1, SourcePath: path, RecordedAt: now.Format(time.RFC3339Nano), Version: version} |
| 277 | encoded, err := json.MarshalIndent(meta, "", " ") |
| 278 | if err != nil { |
| 279 | return err |
| 280 | } |
| 281 | // Publish the immutable, versioned recovery point first. If a later fixed |
| 282 | // last-known-good write fails, readers retain the previous fixed snapshot |
| 283 | // while the newly recorded version remains independently recoverable. |
| 284 | if err := recordConfigSnapshot(path, b, version, now); err != nil { |
| 285 | return err |
| 286 | } |
| 287 | if err := fileutil.AtomicWriteFile(snapshot, b, 0o600); err != nil { |
| 288 | return err |
| 289 | } |
| 290 | // The metadata is informational; restore consumes and validates only the |
| 291 | // content file. Both writers are serialized by the same mutation locks, so |
| 292 | // a failed metadata replacement cannot expose unverified recovery bytes. |
| 293 | if err := fileutil.AtomicWriteFile(snapshot+".json", append(encoded, '\n'), 0o600); err != nil { |
| 294 | return err |
| 295 | } |
| 296 | return nil |
| 297 | } |
| 298 | |
| 299 | func lastKnownGoodConfigPath() string { |
| 300 | root := config.MemoryUserDir() |
| 301 | if root == "" { |
| 302 | return "" |
| 303 | } |
| 304 | return filepath.Join(root, "repair", "config.toml.last-known-good") |
| 305 | } |
| 306 | |
| 307 | func restoreLastKnownGoodConfig(dest string) error { |
| 308 | snapshot := lastKnownGoodConfigPath() |
| 309 | b, err := os.ReadFile(snapshot) |
| 310 | if err != nil { |
| 311 | return err |
| 312 | } |
| 313 | if err := config.ValidateBytes(b); err != nil { |
| 314 | return err |
| 315 | } |
| 316 | return fileutil.AtomicCreateFile(dest, b, 0o600) |
| 317 | } |
| 318 |