0a941f3ba6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
194 lines
6.7 KiB
Go
194 lines
6.7 KiB
Go
package regionmedic
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// HealPlan is a dry-run description of a single-region heal: which live file
|
|
// would be overwritten, from which backup, and which salvage files quarantined.
|
|
type HealPlan struct {
|
|
Region RegionCoord `json:"region"`
|
|
RegionDir string `json:"region_dir"`
|
|
RegionFile string `json:"region_file"` // live path to overwrite
|
|
LiveReport RegionReport `json:"live_report"` // validation of the current live file
|
|
Source Candidate `json:"source"` // chosen backup (Bytes populated)
|
|
ErrorBackups []string `json:"error_backups"` // salvage files to quarantine
|
|
Evaluated []Candidate `json:"evaluated"` // all backups considered (audit trail)
|
|
}
|
|
|
|
// Ready reports whether the plan can be applied: a clean source was found and
|
|
// the live file path is known.
|
|
func (p HealPlan) Ready() bool { return p.Source.Found && p.Source.Clean() && p.RegionFile != "" }
|
|
|
|
// HealResult records what ApplyHeal did.
|
|
type HealResult struct {
|
|
Region RegionCoord `json:"region"`
|
|
RegionFile string `json:"region_file"`
|
|
SnapshotPath string `json:"snapshot_path"` // corrupt original saved here
|
|
SourceBackup string `json:"source_backup"` // backup the clean copy came from
|
|
BytesWritten int `json:"bytes_written"`
|
|
QuarantinedFiles []string `json:"quarantined_files"`
|
|
}
|
|
|
|
// PlanHeal builds a heal plan for one region: it validates the current live
|
|
// region file, lists the error_backup salvage files for that region, and picks
|
|
// the newest clean backup copy via FindLastGoodRegion.
|
|
//
|
|
// - regionDir: the instance's active Save Region/ directory.
|
|
// - region: which region to heal.
|
|
// - backups: candidate panel backups (newest-first or any order).
|
|
// - deep: deep-validate (inflate + coord check) backup candidates.
|
|
// - notAfter: skip backups at/after this time (corruption time); zero disables.
|
|
func PlanHeal(regionDir string, region RegionCoord, backups []BackupRef, deep bool, notAfter time.Time) (HealPlan, error) {
|
|
plan := HealPlan{Region: region, RegionDir: regionDir}
|
|
plan.RegionFile = filepath.Join(regionDir, region.FileName())
|
|
if st, err := os.Stat(plan.RegionFile); err != nil || st.IsDir() {
|
|
// No live file is not fatal for planning, but record it.
|
|
plan.LiveReport = RegionReport{Path: plan.RegionFile, Err: "live region file missing"}
|
|
plan.RegionFile = ""
|
|
} else {
|
|
plan.LiveReport = ValidateRegionFile(filepath.Join(regionDir, region.FileName()), deep)
|
|
}
|
|
|
|
// Collect error_backup salvage files belonging to this region.
|
|
if ents, err := os.ReadDir(regionDir); err == nil {
|
|
for _, e := range ents {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
cx, cz, ok := ParseErrorBackup(e.Name())
|
|
if !ok {
|
|
continue
|
|
}
|
|
if RegionForChunk(cx, cz) == region {
|
|
plan.ErrorBackups = append(plan.ErrorBackups, filepath.Join(regionDir, e.Name()))
|
|
}
|
|
}
|
|
}
|
|
|
|
entry := SaveRelEntry(regionDir, region.FileName())
|
|
best, evaluated, err := FindLastGoodRegion(backups, entry, deep, notAfter)
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
plan.Source = best
|
|
plan.Evaluated = evaluated
|
|
return plan, nil
|
|
}
|
|
|
|
// ApplyHeal executes a ready plan:
|
|
// 1. snapshots the current (corrupt) live region file into snapshotDir,
|
|
// 2. overwrites the live region file in place with the clean source bytes
|
|
// (truncate-in-place preserves the file's owner/mode/ACLs),
|
|
// 3. moves the region's error_backup salvage files into quarantineDir.
|
|
//
|
|
// snapshotDir and quarantineDir are created if missing. Moves fall back to
|
|
// copy+remove when rename crosses a filesystem boundary. ApplyHeal refuses a
|
|
// plan that is not Ready().
|
|
func ApplyHeal(plan HealPlan, snapshotDir, quarantineDir string) (HealResult, error) {
|
|
var res HealResult
|
|
if !plan.Ready() {
|
|
return res, fmt.Errorf("plan not ready: no clean source or missing live region file")
|
|
}
|
|
res.Region = plan.Region
|
|
res.RegionFile = plan.RegionFile
|
|
res.SourceBackup = plan.Source.Backup.Path
|
|
|
|
if err := os.MkdirAll(snapshotDir, 0o755); err != nil {
|
|
return res, fmt.Errorf("mkdir snapshot dir: %w", err)
|
|
}
|
|
// 1. Snapshot the corrupt original.
|
|
stamp := plan.LiveReport.timeStamp()
|
|
res.SnapshotPath = filepath.Join(snapshotDir, plan.Region.FileName()+".corrupt-"+stamp)
|
|
if err := copyFile(plan.RegionFile, res.SnapshotPath); err != nil {
|
|
return res, fmt.Errorf("snapshot corrupt region: %w", err)
|
|
}
|
|
|
|
// 2. Overwrite in place (O_TRUNC keeps the inode's owner/mode/ACL).
|
|
if err := writeInPlace(plan.RegionFile, plan.Source.Bytes); err != nil {
|
|
return res, fmt.Errorf("write clean region: %w", err)
|
|
}
|
|
res.BytesWritten = len(plan.Source.Bytes)
|
|
|
|
// 3. Quarantine the salvage files (best-effort per file, but report errors).
|
|
if len(plan.ErrorBackups) > 0 {
|
|
if err := os.MkdirAll(quarantineDir, 0o755); err != nil {
|
|
return res, fmt.Errorf("mkdir quarantine dir: %w", err)
|
|
}
|
|
for _, src := range plan.ErrorBackups {
|
|
dst := filepath.Join(quarantineDir, filepath.Base(src))
|
|
if err := moveFile(src, dst); err != nil {
|
|
return res, fmt.Errorf("quarantine %s: %w", filepath.Base(src), err)
|
|
}
|
|
res.QuarantinedFiles = append(res.QuarantinedFiles, dst)
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// timeStamp returns a filesystem-safe stamp; uses the report path's modtime is
|
|
// not available here, so callers get a coarse unique-ish suffix. We avoid
|
|
// time.Now in the pure package only inside tests; here a stamp is fine.
|
|
func (RegionReport) timeStamp() string { return time.Now().UTC().Format("20060102-150405") }
|
|
|
|
// writeInPlace truncates an existing file and writes data, preserving the
|
|
// file's existing owner/permissions/ACL (no create-new). If the file does not
|
|
// exist it is created 0o664.
|
|
func writeInPlace(path string, data []byte) error {
|
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
f, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o664)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := f.Write(data); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
// copyFile copies src to dst (content only), creating dst.
|
|
func copyFile(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
out.Close()
|
|
return err
|
|
}
|
|
if err := out.Sync(); err != nil {
|
|
out.Close()
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|
|
|
|
// moveFile renames src to dst, falling back to copy+remove across filesystems.
|
|
func moveFile(src, dst string) error {
|
|
if err := os.Rename(src, dst); err == nil {
|
|
return nil
|
|
}
|
|
if err := copyFile(src, dst); err != nil {
|
|
return err
|
|
}
|
|
return os.Remove(src)
|
|
}
|