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