| 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, ¤t); err != nil || validateConfigSnapshot(dir, ¤t) != 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 |