panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
package regionmedic
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BackupRef identifies one panel backup tarball for an instance.
|
||||
type BackupRef struct {
|
||||
Path string `json:"path"`
|
||||
ID string `json:"id,omitempty"` // "bkp_<hex>" parsed from the name
|
||||
CreatedAt time.Time `json:"created_at"` // parsed from the filename (UTC)
|
||||
}
|
||||
|
||||
// backupTimeLayout matches the panel backup filename timestamp
|
||||
// (time.Now().UTC().Format) — see agent/internal/dispatch/backup.go.
|
||||
const backupTimeLayout = "20060102-150405"
|
||||
|
||||
// ListPanelBackups reads an instance's backup directory
|
||||
// (<agent --backup-dir>/<instance_id>/) and returns the backups sorted newest
|
||||
// first, parsing the UTC timestamp + bkp id out of each filename:
|
||||
//
|
||||
// 20060102-150405-bkp_<hex>.tar.gz
|
||||
//
|
||||
// Files that don't match the pattern are skipped.
|
||||
func ListPanelBackups(instanceBackupDir string) ([]BackupRef, error) {
|
||||
ents, err := os.ReadDir(instanceBackupDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var refs []BackupRef
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".tar.gz") {
|
||||
continue
|
||||
}
|
||||
ref, ok := parseBackupName(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ref.Path = filepath.Join(instanceBackupDir, name)
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
sort.Slice(refs, func(i, j int) bool { return refs[i].CreatedAt.After(refs[j].CreatedAt) })
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// parseBackupName parses "20060102-150405-bkp_<hex>.tar.gz".
|
||||
func parseBackupName(name string) (BackupRef, bool) {
|
||||
base := strings.TrimSuffix(name, ".tar.gz")
|
||||
// timestamp is the first 15 chars: YYYYMMDD-HHMMSS
|
||||
if len(base) < len(backupTimeLayout) {
|
||||
return BackupRef{}, false
|
||||
}
|
||||
tsPart := base[:len(backupTimeLayout)]
|
||||
ts, err := time.Parse(backupTimeLayout, tsPart)
|
||||
if err != nil {
|
||||
return BackupRef{}, false
|
||||
}
|
||||
ref := BackupRef{CreatedAt: ts.UTC()}
|
||||
if i := strings.Index(base, "bkp_"); i >= 0 {
|
||||
ref.ID = base[i:]
|
||||
}
|
||||
return ref, true
|
||||
}
|
||||
|
||||
// ExtractRegionFromBackup pulls one region file out of a panel backup tarball.
|
||||
// The tar stores paths relative to the backup root, and a single backup may
|
||||
// contain SEVERAL worlds (e.g. ".../Saves/Navezgane/MyGame/Region/r.0.0.7rg"
|
||||
// AND ".../Saves/West Apeeni Mountains/MapRender/Region/r.0.0.7rg"). The caller
|
||||
// MUST therefore pass a world-qualified entry suffix — e.g.
|
||||
// "Saves/Navezgane/MyGame/Region/r.0.0.7rg" — so the correct world's region is
|
||||
// returned; a bare "r.0.0.7rg" would be ambiguous and could heal from the wrong
|
||||
// world. An entry matches when its cleaned path equals entry or ends with
|
||||
// "/"+entry. Returns ErrRegionNotInBackup if no entry matches.
|
||||
func ExtractRegionFromBackup(backupPath, entry string) ([]byte, error) {
|
||||
f, err := os.Open(backupPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
want := strings.TrimPrefix(entry, "/")
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tar: %w", err)
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
||||
continue
|
||||
}
|
||||
clean := strings.TrimPrefix(path.Clean(hdr.Name), "./")
|
||||
if clean == want || strings.HasSuffix(clean, "/"+want) {
|
||||
b, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read entry: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrRegionNotInBackup, entry)
|
||||
}
|
||||
|
||||
// SaveRelEntry converts an absolute Region directory and a region filename into
|
||||
// the world-qualified backup entry suffix used by ExtractRegionFromBackup. A
|
||||
// regionDir of ".../Saves/Navezgane/MyGame/Region" + "r.0.0.7rg" yields
|
||||
// "Saves/Navezgane/MyGame/Region/r.0.0.7rg". If regionDir contains no "/Saves/"
|
||||
// segment it falls back to the (ambiguous) "Region/<name>".
|
||||
func SaveRelEntry(regionDir, regionFileName string) string {
|
||||
slashed := filepath.ToSlash(regionDir)
|
||||
if i := strings.Index(slashed, "/Saves/"); i >= 0 {
|
||||
return slashed[i+1:] + "/" + regionFileName // from "Saves/" onward
|
||||
}
|
||||
if strings.HasPrefix(slashed, "Saves/") {
|
||||
return slashed + "/" + regionFileName
|
||||
}
|
||||
return "Region/" + regionFileName
|
||||
}
|
||||
|
||||
// ErrRegionNotInBackup is returned when a backup doesn't contain the region.
|
||||
var ErrRegionNotInBackup = fmt.Errorf("region not found in backup")
|
||||
|
||||
// Candidate is a backup evaluated as a heal source for one region.
|
||||
type Candidate struct {
|
||||
Backup BackupRef `json:"backup"`
|
||||
Found bool `json:"found"` // region present in this backup
|
||||
Report RegionReport `json:"report"` // validation of the extracted region
|
||||
Bytes []byte `json:"-"` // extracted region bytes (best only)
|
||||
Note string `json:"note,omitempty"` // why skipped, if applicable
|
||||
}
|
||||
|
||||
// Clean reports whether this candidate's region image validated healthy.
|
||||
func (c Candidate) Clean() bool { return c.Found && c.Report.Healthy() }
|
||||
|
||||
// FindLastGoodRegion walks backups newest→oldest, extracts the region (using a
|
||||
// world-qualified entry suffix — see SaveRelEntry) from each and validates it,
|
||||
// and returns the newest CLEAN candidate (best.Found && best.Clean()). The full
|
||||
// evaluated list is returned for logging/auditing. Only the chosen best carries
|
||||
// Bytes (others are dropped to save memory). If no clean copy is found,
|
||||
// best.Found is false.
|
||||
//
|
||||
// backups should already be sorted newest-first (ListPanelBackups does this);
|
||||
// FindLastGoodRegion re-sorts defensively. An optional notAfter bound skips
|
||||
// backups created at/after it (use the corruption time to avoid trusting a
|
||||
// snapshot that already captured the damage); pass the zero time to disable.
|
||||
func FindLastGoodRegion(backups []BackupRef, entry string, deep bool, notAfter time.Time) (best Candidate, evaluated []Candidate, err error) {
|
||||
sorted := append([]BackupRef(nil), backups...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].CreatedAt.After(sorted[j].CreatedAt) })
|
||||
|
||||
for _, b := range sorted {
|
||||
c := Candidate{Backup: b}
|
||||
if !notAfter.IsZero() && !b.CreatedAt.Before(notAfter) {
|
||||
c.Note = "skipped: at/after corruption time"
|
||||
evaluated = append(evaluated, c)
|
||||
continue
|
||||
}
|
||||
data, exErr := ExtractRegionFromBackup(b.Path, entry)
|
||||
if exErr != nil {
|
||||
c.Note = exErr.Error()
|
||||
evaluated = append(evaluated, c)
|
||||
continue
|
||||
}
|
||||
c.Found = true
|
||||
c.Report = ValidateRegionBytes(data, deep)
|
||||
if c.Report.Healthy() && !best.Found {
|
||||
c.Bytes = data
|
||||
best = c
|
||||
}
|
||||
evaluated = append(evaluated, c)
|
||||
}
|
||||
return best, evaluated, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package regionmedic detects and surgically repairs corrupted 7 Days to Die
|
||||
// region (".7rg") files using the panel's own backups.
|
||||
//
|
||||
// Background
|
||||
//
|
||||
// 7DTD stores the world as a grid of 512×512-block "region" files, each
|
||||
// holding a 32×32 grid of chunks. When the server process dies mid-save (a
|
||||
// bare `docker stop` SIGKILL with no `saveworld`, an OOM kill, a crash) a
|
||||
// region file can be left with a torn chunk: the chunk's bytes no longer begin
|
||||
// with the expected header. On the next load 7DTD logs "Wrong chunk header!",
|
||||
// writes an `error_backup_<cx>_<cz>` salvage file, and drops the chunk — so a
|
||||
// base straddling the damaged region loses exactly the half that lived in it.
|
||||
//
|
||||
// This package reproduces, as deterministic code, the manual recovery that
|
||||
// works: localize the damaged region(s) from the error_backup files, find the
|
||||
// newest pre-corruption backup whose copy of that region validates clean,
|
||||
// snapshot the corrupt live file, swap the clean copy in (in place, preserving
|
||||
// permissions), and quarantine the error_backup salvage files.
|
||||
//
|
||||
// The .7rg on-disk format (reverse-engineered + verified byte-for-byte against
|
||||
// real region files, see RE notes in the repo history):
|
||||
//
|
||||
// Sector size = 4096 bytes; file size is always a multiple of 4096.
|
||||
// Region = 32×32 = 1024 chunks.
|
||||
// Sector 0 = header: magic "7rg"+0x01 at 0x000; bytes 0x004..0xFFF zero.
|
||||
// Sector 1 = location table at 0x1000: 1024 entries × 4 bytes, each
|
||||
// [3-byte little-endian sector offset][1-byte sector count];
|
||||
// an absent chunk is 0x00000000. Table index = localX + localZ*32
|
||||
// (X varies fastest). Chunk blob lives at sectorOffset*4096.
|
||||
// Chunk blob = [u32 LE blockLength][12 bytes zero][magic "ttc\0"]
|
||||
// [u32 LE const 47][raw DEFLATE stream].
|
||||
// blockLength = 8 + len(deflate); the on-disk blob occupies
|
||||
// 16 + blockLength bytes, rounded up to whole sectors (the
|
||||
// stored sector count may be one larger — allocator slack).
|
||||
// The decompressed payload begins int32 chunkX, int32 0,
|
||||
// int32 chunkZ.
|
||||
//
|
||||
// The "ttc\0" magic at blob offset 0x10 is the exact byte sequence 7DTD checks
|
||||
// when it logs "Wrong chunk header!", so it is both what Validate flags and the
|
||||
// minimal faithful corruption used by the test harness.
|
||||
//
|
||||
// Frozen API surface (do not break callers — the agent dispatch handler, the
|
||||
// region-medic CLI, and the unit tests all depend on these signatures):
|
||||
//
|
||||
// Validation: ValidateRegionBytes, ValidateRegionFile -> RegionReport
|
||||
// Localize: RegionCoord, RegionForChunk, ParseErrorBackup, ParseRegionFileName
|
||||
// Scan: ScanRegionDir -> RegionDirScan / AffectedRegion
|
||||
// Backups: BackupRef, ListPanelBackups, ExtractRegionFromBackup,
|
||||
// FindLastGoodRegion -> Candidate
|
||||
// Heal: PlanHeal -> HealPlan; ApplyHeal -> HealResult
|
||||
//
|
||||
// Everything here is pure (filesystem + archive/tar + compress/flate, stdlib
|
||||
// only). It performs no Docker or network actions; orchestration (graceful
|
||||
// save, stop, start, log-watch) is the caller's job, which keeps the decision
|
||||
// logic fully unit-testable.
|
||||
package regionmedic
|
||||
@@ -0,0 +1,193 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package regionmedic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// floorMod returns the mathematical (floored) modulo, always in [0, m).
|
||||
func floorMod(a, m int) int { return ((a % m) + m) % m }
|
||||
|
||||
// floorDiv returns the floored integer division (rounds toward -inf).
|
||||
func floorDiv(a, m int) int {
|
||||
q := a / m
|
||||
if (a%m != 0) && ((a < 0) != (m < 0)) {
|
||||
q--
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// RegionCoord identifies a region file r.<X>.<Z>.7rg in the 32×32-chunk grid.
|
||||
type RegionCoord struct {
|
||||
X int `json:"x"`
|
||||
Z int `json:"z"`
|
||||
}
|
||||
|
||||
// FileName returns the on-disk region filename, e.g. "r.-2.0.7rg".
|
||||
func (rc RegionCoord) FileName() string { return fmt.Sprintf("r.%d.%d.7rg", rc.X, rc.Z) }
|
||||
|
||||
// String returns the canonical region id, e.g. "r.-2.0".
|
||||
func (rc RegionCoord) String() string { return fmt.Sprintf("r.%d.%d", rc.X, rc.Z) }
|
||||
|
||||
// RegionForChunk maps a global chunk coordinate to its region (floored /32).
|
||||
func RegionForChunk(cx, cz int) RegionCoord {
|
||||
return RegionCoord{X: floorDiv(cx, RegionSide), Z: floorDiv(cz, RegionSide)}
|
||||
}
|
||||
|
||||
// ParseRegionFileName parses "r.<X>.<Z>.7rg" -> RegionCoord. ok=false otherwise.
|
||||
// Handles negative coordinates, e.g. "r.-2.0.7rg" -> {X:-2, Z:0}.
|
||||
func ParseRegionFileName(name string) (RegionCoord, bool) {
|
||||
if !strings.HasPrefix(name, "r.") || !strings.HasSuffix(name, ".7rg") {
|
||||
return RegionCoord{}, false
|
||||
}
|
||||
mid := strings.TrimSuffix(strings.TrimPrefix(name, "r."), ".7rg")
|
||||
// mid is "<X>.<Z>" where X and Z may be negative. Split on the dot that
|
||||
// separates them: there is exactly one '.' between two integers.
|
||||
x, z, ok := splitTwoInts(mid)
|
||||
if !ok {
|
||||
return RegionCoord{}, false
|
||||
}
|
||||
return RegionCoord{X: x, Z: z}, true
|
||||
}
|
||||
|
||||
// ParseErrorBackup parses 7DTD chunk-salvage filenames written on a failed load:
|
||||
//
|
||||
// error_backup_<cx>_<cz>.comp.bak
|
||||
// error_backup_<cx>_<cz>.uncomp.bak
|
||||
//
|
||||
// returning the global chunk coordinates. ok=false if name is not such a file.
|
||||
func ParseErrorBackup(name string) (cx, cz int, ok bool) {
|
||||
const pfx = "error_backup_"
|
||||
if !strings.HasPrefix(name, pfx) {
|
||||
return 0, 0, false
|
||||
}
|
||||
rest := name[len(pfx):]
|
||||
// Strip the known suffixes.
|
||||
switch {
|
||||
case strings.HasSuffix(rest, ".comp.bak"):
|
||||
rest = strings.TrimSuffix(rest, ".comp.bak")
|
||||
case strings.HasSuffix(rest, ".uncomp.bak"):
|
||||
rest = strings.TrimSuffix(rest, ".uncomp.bak")
|
||||
default:
|
||||
return 0, 0, false
|
||||
}
|
||||
// rest is "<cx>_<cz>" with possibly-negative ints separated by '_'.
|
||||
i := strings.LastIndex(rest, "_")
|
||||
if i <= 0 || i >= len(rest)-1 {
|
||||
return 0, 0, false
|
||||
}
|
||||
a, err1 := strconv.Atoi(rest[:i])
|
||||
b, err2 := strconv.Atoi(rest[i+1:])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return a, b, true
|
||||
}
|
||||
|
||||
// splitTwoInts splits "<a>.<b>" into two ints where each may be negative.
|
||||
// The separator is the first '.' that is not a leading minus sign position.
|
||||
func splitTwoInts(s string) (int, int, bool) {
|
||||
// Find a '.' that splits into two parseable ints. Scan candidate dots.
|
||||
for i := 1; i < len(s); i++ {
|
||||
if s[i] != '.' {
|
||||
continue
|
||||
}
|
||||
a, err1 := strconv.Atoi(s[:i])
|
||||
b, err2 := strconv.Atoi(s[i+1:])
|
||||
if err1 == nil && err2 == nil {
|
||||
return a, b, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package regionmedic
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- fixture builders -------------------------------------------------------
|
||||
|
||||
func deflateBytes(b []byte) []byte {
|
||||
var buf bytes.Buffer
|
||||
w, _ := flate.NewWriter(&buf, flate.DefaultCompression)
|
||||
_, _ = w.Write(b)
|
||||
_ = w.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// buildRegion synthesizes a valid .7rg image for the given region coordinate
|
||||
// with the listed chunk indices present. Each chunk's decompressed payload
|
||||
// carries its correct GLOBAL coords so deep validation passes.
|
||||
func buildRegion(region RegionCoord, present []int) []byte {
|
||||
header := make([]byte, SectorSize)
|
||||
copy(header, regionMagic)
|
||||
loctable := make([]byte, SectorSize)
|
||||
var data []byte
|
||||
nextSector := headerSectors // first data sector index = 2
|
||||
|
||||
for _, idx := range present {
|
||||
lx, lz := idx%RegionSide, idx/RegionSide
|
||||
gx := region.X*RegionSide + lx
|
||||
gz := region.Z*RegionSide + lz
|
||||
payload := make([]byte, 16)
|
||||
binary.LittleEndian.PutUint32(payload[0:4], uint32(int32(gx)))
|
||||
binary.LittleEndian.PutUint32(payload[8:12], uint32(int32(gz)))
|
||||
payload[12] = 0xAB // arbitrary body
|
||||
comp := deflateBytes(payload)
|
||||
blockLen := 8 + len(comp)
|
||||
need := (24 + len(comp) + SectorSize - 1) / SectorSize
|
||||
blob := make([]byte, need*SectorSize)
|
||||
binary.LittleEndian.PutUint32(blob[0:4], uint32(blockLen))
|
||||
copy(blob[16:20], chunkMagic)
|
||||
binary.LittleEndian.PutUint32(blob[20:24], uint32(chunkConstVal))
|
||||
copy(blob[24:], comp)
|
||||
|
||||
off := nextSector
|
||||
e := loctable[idx*4 : idx*4+4]
|
||||
e[0] = byte(off & 0xff)
|
||||
e[1] = byte((off >> 8) & 0xff)
|
||||
e[2] = byte((off >> 16) & 0xff)
|
||||
e[3] = byte(need)
|
||||
data = append(data, blob...)
|
||||
nextSector += need
|
||||
}
|
||||
out := append(append(append([]byte{}, header...), loctable...), data...)
|
||||
return out
|
||||
}
|
||||
|
||||
// chunkBlobOffset returns the file byte offset of the "ttc" magic for a present
|
||||
// chunk index — i.e. the exact byte the corruption recipe overwrites.
|
||||
func chunkMagicOffset(data []byte, idx int) int {
|
||||
e := data[locTableOffset+idx*4 : locTableOffset+idx*4+4]
|
||||
off := int(e[0]) | int(e[1])<<8 | int(e[2])<<16
|
||||
return off*SectorSize + 16
|
||||
}
|
||||
|
||||
// --- validation tests -------------------------------------------------------
|
||||
|
||||
func TestValidateGoodRegionOrigin(t *testing.T) {
|
||||
data := buildRegion(RegionCoord{0, 0}, []int{0, 1, 33, 1023})
|
||||
rep := ValidateRegionBytes(data, true)
|
||||
if !rep.Healthy() {
|
||||
t.Fatalf("expected healthy, got err=%q bad=%+v", rep.Err, rep.BadChunks)
|
||||
}
|
||||
if rep.PresentChunks != 4 {
|
||||
t.Fatalf("present=%d want 4", rep.PresentChunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGoodRegionNegative(t *testing.T) {
|
||||
// Region r.-2.0 exercises floored-modulo coord checking for negative globals.
|
||||
data := buildRegion(RegionCoord{-2, 0}, []int{0, 1, 31, 500})
|
||||
rep := ValidateRegionBytes(data, true)
|
||||
if !rep.Healthy() {
|
||||
t.Fatalf("expected healthy negative region, got err=%q bad=%+v", rep.Err, rep.BadChunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongChunkHeaderDetected(t *testing.T) {
|
||||
data := buildRegion(RegionCoord{0, 0}, []int{0, 5})
|
||||
// Faithful corruption: clobber the "ttc" magic of chunk index 5.
|
||||
off := chunkMagicOffset(data, 5)
|
||||
data[off] = 0x00 // 't' -> 0x00
|
||||
rep := ValidateRegionBytes(data, true)
|
||||
if rep.Healthy() {
|
||||
t.Fatal("expected corruption to be detected")
|
||||
}
|
||||
if len(rep.BadChunks) != 1 || rep.BadChunks[0].Index != 5 || rep.BadChunks[0].Reason != ReasonWrongHeader {
|
||||
t.Fatalf("want one wrong-header bad chunk idx5, got %+v", rep.BadChunks)
|
||||
}
|
||||
// The other chunk (0) must still be fine and counted present.
|
||||
if rep.PresentChunks != 2 {
|
||||
t.Fatalf("present=%d want 2", rep.PresentChunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadMagic(t *testing.T) {
|
||||
data := buildRegion(RegionCoord{0, 0}, []int{0})
|
||||
data[1] = 'X'
|
||||
if ValidateRegionBytes(data, false).Err == "" {
|
||||
t.Fatal("expected fatal err on bad magic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonSectorAligned(t *testing.T) {
|
||||
if ValidateRegionBytes(make([]byte, 5000), false).Err == "" {
|
||||
t.Fatal("expected fatal err on non-aligned size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutOfBoundsEntry(t *testing.T) {
|
||||
data := buildRegion(RegionCoord{0, 0}, []int{0})
|
||||
// Point chunk index 1's entry far past EOF.
|
||||
e := data[locTableOffset+1*4 : locTableOffset+1*4+4]
|
||||
e[0], e[1], e[2], e[3] = 0xFF, 0xFF, 0x00, 1 // offset 0xFFFF sectors
|
||||
rep := ValidateRegionBytes(data, false)
|
||||
if rep.Healthy() || len(rep.BadChunks) != 1 || rep.BadChunks[0].Reason != ReasonOutOfBounds {
|
||||
t.Fatalf("want out-of-bounds bad chunk, got err=%q bad=%+v", rep.Err, rep.BadChunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlapDetected(t *testing.T) {
|
||||
data := buildRegion(RegionCoord{0, 0}, []int{0, 1})
|
||||
// Make chunk 1's entry point at chunk 0's sector (overlap).
|
||||
e0 := data[locTableOffset : locTableOffset+4]
|
||||
e1 := data[locTableOffset+4 : locTableOffset+8]
|
||||
copy(e1, e0)
|
||||
rep := ValidateRegionBytes(data, false)
|
||||
foundOverlap := false
|
||||
for _, bc := range rep.BadChunks {
|
||||
if bc.Reason == ReasonOverlap {
|
||||
foundOverlap = true
|
||||
}
|
||||
}
|
||||
if !foundOverlap {
|
||||
t.Fatalf("expected overlap detection, got %+v", rep.BadChunks)
|
||||
}
|
||||
}
|
||||
|
||||
// --- localize tests ---------------------------------------------------------
|
||||
|
||||
func TestParseErrorBackup(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cx, cz int
|
||||
ok bool
|
||||
}{
|
||||
{"error_backup_-28_12.comp.bak", -28, 12, true},
|
||||
{"error_backup_-29_-13.uncomp.bak", -29, -13, true},
|
||||
{"error_backup_5_7.comp.bak", 5, 7, true},
|
||||
{"r.0.0.7rg", 0, 0, false},
|
||||
{"error_backup_5_7.txt", 0, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
cx, cz, ok := ParseErrorBackup(c.name)
|
||||
if ok != c.ok || (ok && (cx != c.cx || cz != c.cz)) {
|
||||
t.Errorf("%s -> (%d,%d,%v) want (%d,%d,%v)", c.name, cx, cz, ok, c.cx, c.cz, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRegionFileName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rc RegionCoord
|
||||
ok bool
|
||||
}{
|
||||
{"r.-2.0.7rg", RegionCoord{-2, 0}, true},
|
||||
{"r.10.-5.7rg", RegionCoord{10, -5}, true},
|
||||
{"r.0.0.7rg", RegionCoord{0, 0}, true},
|
||||
{"error_backup_5_7.comp.bak", RegionCoord{}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rc, ok := ParseRegionFileName(c.name)
|
||||
if ok != c.ok || (ok && rc != c.rc) {
|
||||
t.Errorf("%s -> (%v,%v) want (%v,%v)", c.name, rc, ok, c.rc, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegionForChunk(t *testing.T) {
|
||||
cases := []struct {
|
||||
cx, cz int
|
||||
rc RegionCoord
|
||||
}{
|
||||
{0, 0, RegionCoord{0, 0}},
|
||||
{31, 31, RegionCoord{0, 0}},
|
||||
{-1, -1, RegionCoord{-1, -1}},
|
||||
{-32, 0, RegionCoord{-1, 0}},
|
||||
{-33, 12, RegionCoord{-2, 0}},
|
||||
{-64, 0, RegionCoord{-2, 0}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := RegionForChunk(c.cx, c.cz); got != c.rc {
|
||||
t.Errorf("RegionForChunk(%d,%d)=%v want %v", c.cx, c.cz, got, c.rc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- scan test --------------------------------------------------------------
|
||||
|
||||
func TestScanRegionDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 3 salvage files in r.-2.0 (chunks -33..-64 / 0..31), 1 in r.2.1.
|
||||
writeEmpty(t, dir, "error_backup_-33_12.comp.bak")
|
||||
writeEmpty(t, dir, "error_backup_-33_12.uncomp.bak") // same chunk, dedup
|
||||
writeEmpty(t, dir, "error_backup_-40_20.comp.bak")
|
||||
writeEmpty(t, dir, "error_backup_70_40.comp.bak") // region r.2.1
|
||||
// a live region file for r.-2.0
|
||||
os.WriteFile(filepath.Join(dir, "r.-2.0.7rg"), buildRegion(RegionCoord{-2, 0}, []int{0}), 0o644)
|
||||
|
||||
scan, err := ScanRegionDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scan.ErrorBackups != 4 {
|
||||
t.Errorf("error_backups=%d want 4", scan.ErrorBackups)
|
||||
}
|
||||
if len(scan.Affected) != 2 {
|
||||
t.Fatalf("affected=%d want 2", len(scan.Affected))
|
||||
}
|
||||
top := scan.Affected[0]
|
||||
if top.Region != (RegionCoord{-2, 0}) || top.ErrorBackups != 3 {
|
||||
t.Errorf("top affected=%v files=%d want r.-2.0 files=3", top.Region, top.ErrorBackups)
|
||||
}
|
||||
if len(top.Chunks) != 2 {
|
||||
t.Errorf("distinct chunks=%d want 2", len(top.Chunks))
|
||||
}
|
||||
if !top.FilePresent {
|
||||
t.Error("expected r.-2.0 live file present")
|
||||
}
|
||||
}
|
||||
|
||||
// --- backup extract + find-last-good ---------------------------------------
|
||||
|
||||
const navSave = "Saves/Navezgane/MyGame/Region"
|
||||
|
||||
// writeBackupTar writes a tarball whose Navezgane Region/<regionName> entry is
|
||||
// regionBytes. If decoyBytes is non-nil it ALSO writes a second world
|
||||
// (West Apeeni Mountains/MapRender) with the SAME region filename but different
|
||||
// bytes — to prove world-qualified extraction picks the right one.
|
||||
func writeBackupTar(t *testing.T, dir, name, regionName string, regionBytes, decoyBytes []byte) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, name)
|
||||
f, err := os.Create(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
gw := gzip.NewWriter(f)
|
||||
tw := tar.NewWriter(gw)
|
||||
add := func(entry string, b []byte) {
|
||||
tw.WriteHeader(&tar.Header{Name: entry, Mode: 0o644, Size: int64(len(b)), Typeflag: tar.TypeReg})
|
||||
tw.Write(b)
|
||||
}
|
||||
if decoyBytes != nil {
|
||||
// Decoy world FIRST in tar order, so a naive bare-name match would grab it.
|
||||
add(".local/share/7DaysToDie/Saves/West Apeeni Mountains/MapRender/Region/"+regionName, decoyBytes)
|
||||
}
|
||||
if regionBytes != nil {
|
||||
add(".local/share/7DaysToDie/"+navSave+"/"+regionName, regionBytes)
|
||||
}
|
||||
add("serverconfig.xml", []byte("xml"))
|
||||
tw.Close()
|
||||
gw.Close()
|
||||
return p
|
||||
}
|
||||
|
||||
func TestExtractRegionFromBackup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
region := buildRegion(RegionCoord{0, 0}, []int{0, 1})
|
||||
decoy := buildRegion(RegionCoord{0, 0}, []int{0, 1, 2, 3}) // different bytes
|
||||
p := writeBackupTar(t, dir, "20260101-120000-bkp_aaaa.tar.gz", "r.0.0.7rg", region, decoy)
|
||||
|
||||
// World-qualified entry must return the Navezgane copy, NOT the decoy.
|
||||
got, err := ExtractRegionFromBackup(p, navSave+"/r.0.0.7rg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, region) {
|
||||
t.Fatal("world-qualified extract returned wrong world's region")
|
||||
}
|
||||
if bytes.Equal(got, decoy) {
|
||||
t.Fatal("extracted the decoy world's region — disambiguation failed")
|
||||
}
|
||||
if _, err := ExtractRegionFromBackup(p, navSave+"/r.9.9.7rg"); err == nil {
|
||||
t.Fatal("expected not-found error for absent region")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRelEntry(t *testing.T) {
|
||||
got := SaveRelEntry("/var/lib/docker/volumes/x/_data/.local/share/7DaysToDie/Saves/Navezgane/MyGame/Region", "r.-2.0.7rg")
|
||||
if got != "Saves/Navezgane/MyGame/Region/r.-2.0.7rg" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindLastGoodRegion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
region := RegionCoord{-2, 0}
|
||||
good := buildRegion(region, []int{0, 1, 2})
|
||||
corrupt := buildRegion(region, []int{0, 1, 2})
|
||||
corrupt[chunkMagicOffset(corrupt, 1)] = 0x00 // break chunk 1
|
||||
|
||||
// oldest clean, middle clean, newest corrupt (each also carries a decoy world)
|
||||
writeBackupTar(t, dir, "20260101-010000-bkp_old.tar.gz", region.FileName(), good, good)
|
||||
writeBackupTar(t, dir, "20260102-010000-bkp_mid.tar.gz", region.FileName(), good, good)
|
||||
writeBackupTar(t, dir, "20260103-010000-bkp_new.tar.gz", region.FileName(), corrupt, good)
|
||||
|
||||
refs, err := ListPanelBackups(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) != 3 {
|
||||
t.Fatalf("refs=%d want 3", len(refs))
|
||||
}
|
||||
// newest first
|
||||
if !refs[0].CreatedAt.After(refs[1].CreatedAt) {
|
||||
t.Fatal("backups not sorted newest-first")
|
||||
}
|
||||
|
||||
best, evaluated, err := FindLastGoodRegion(refs, navSave+"/"+region.FileName(), true, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !best.Found || !best.Clean() {
|
||||
t.Fatalf("expected a clean best, got found=%v clean=%v", best.Found, best.Clean())
|
||||
}
|
||||
// Best must be the middle (newest CLEAN), not the corrupt newest.
|
||||
if best.Backup.ID != "bkp_mid" {
|
||||
t.Fatalf("best=%s want bkp_mid (newest clean)", best.Backup.ID)
|
||||
}
|
||||
if len(evaluated) != 3 {
|
||||
t.Fatalf("evaluated=%d want 3", len(evaluated))
|
||||
}
|
||||
}
|
||||
|
||||
// --- full plan + apply heal -------------------------------------------------
|
||||
|
||||
func TestPlanAndApplyHeal(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
// Realistic region dir so SaveRelEntry yields a world-qualified suffix.
|
||||
regionDir := filepath.Join(base, ".local/share/7DaysToDie", navSave)
|
||||
backupDir := filepath.Join(base, "backups")
|
||||
os.MkdirAll(regionDir, 0o755)
|
||||
os.MkdirAll(backupDir, 0o755)
|
||||
|
||||
region := RegionCoord{-2, 0}
|
||||
clean := buildRegion(region, []int{0, 1, 2, 33})
|
||||
|
||||
// Live (corrupt) region file: break one chunk header.
|
||||
corrupt := append([]byte{}, clean...)
|
||||
corrupt[chunkMagicOffset(corrupt, 2)] = 0x00
|
||||
liveFile := filepath.Join(regionDir, region.FileName())
|
||||
if err := os.WriteFile(liveFile, corrupt, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Salvage files for this region.
|
||||
writeEmpty(t, regionDir, "error_backup_-64_0.comp.bak")
|
||||
writeEmpty(t, regionDir, "error_backup_-64_0.uncomp.bak")
|
||||
|
||||
// A clean backup containing the good region (plus a decoy world that must
|
||||
// NOT be chosen).
|
||||
decoy := buildRegion(RegionCoord{99, 99}, []int{0})
|
||||
writeBackupTar(t, backupDir, "20260101-120000-bkp_clean.tar.gz", region.FileName(), clean, decoy)
|
||||
|
||||
refs, _ := ListPanelBackups(backupDir)
|
||||
plan, err := PlanHeal(regionDir, region, refs, true, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plan.LiveReport.Healthy() {
|
||||
t.Fatal("expected live report to flag corruption")
|
||||
}
|
||||
if !plan.Ready() {
|
||||
t.Fatalf("plan not ready: source found=%v clean=%v file=%q", plan.Source.Found, plan.Source.Clean(), plan.RegionFile)
|
||||
}
|
||||
if len(plan.ErrorBackups) != 2 {
|
||||
t.Fatalf("error backups in plan=%d want 2", len(plan.ErrorBackups))
|
||||
}
|
||||
|
||||
snapDir := filepath.Join(base, "snap")
|
||||
qDir := filepath.Join(base, "quarantine")
|
||||
res, err := ApplyHeal(plan, snapDir, qDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Live file now equals the clean bytes.
|
||||
healed, _ := os.ReadFile(liveFile)
|
||||
if sha256.Sum256(healed) != sha256.Sum256(clean) {
|
||||
t.Fatal("healed live file does not match clean source")
|
||||
}
|
||||
if !ValidateRegionFile(liveFile, true).Healthy() {
|
||||
t.Fatal("healed file failed revalidation")
|
||||
}
|
||||
// Snapshot holds the corrupt original.
|
||||
snap, _ := os.ReadFile(res.SnapshotPath)
|
||||
if sha256.Sum256(snap) != sha256.Sum256(corrupt) {
|
||||
t.Fatal("snapshot does not match the corrupt original")
|
||||
}
|
||||
// Salvage files moved out of the region dir.
|
||||
if _, err := os.Stat(filepath.Join(regionDir, "error_backup_-64_0.comp.bak")); !os.IsNotExist(err) {
|
||||
t.Fatal("error_backup still present in region dir after heal")
|
||||
}
|
||||
if len(res.QuarantinedFiles) != 2 {
|
||||
t.Fatalf("quarantined=%d want 2", len(res.QuarantinedFiles))
|
||||
}
|
||||
}
|
||||
|
||||
func writeEmpty(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte{}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package regionmedic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ChunkCoord is a global chunk coordinate.
|
||||
type ChunkCoord struct {
|
||||
X int `json:"x"`
|
||||
Z int `json:"z"`
|
||||
}
|
||||
|
||||
// AffectedRegion is one region with corruption evidence (error_backup salvage
|
||||
// files whose chunks fall inside it), plus whether its live region file exists.
|
||||
type AffectedRegion struct {
|
||||
Region RegionCoord `json:"region"`
|
||||
ErrorBackups int `json:"error_backups"`
|
||||
Chunks []ChunkCoord `json:"chunks,omitempty"`
|
||||
RegionFile string `json:"region_file,omitempty"` // absolute path if present
|
||||
FilePresent bool `json:"file_present"`
|
||||
}
|
||||
|
||||
// RegionDirScan is the result of scanning a 7DTD save Region/ directory.
|
||||
type RegionDirScan struct {
|
||||
Dir string `json:"dir"`
|
||||
ErrorBackups int `json:"error_backups"` // total salvage files
|
||||
ErrorBackupDirs int `json:"error_backup_chunks"` // distinct chunks
|
||||
Affected []AffectedRegion `json:"affected"` // sorted by count desc
|
||||
}
|
||||
|
||||
// ScanRegionDir lists error_backup_* salvage files in dir, maps each to its
|
||||
// region, and clusters them. The returned Affected slice is sorted by
|
||||
// ErrorBackups descending (most-damaged region first). A region appears only if
|
||||
// it has at least one error_backup chunk.
|
||||
func ScanRegionDir(dir string) (RegionDirScan, error) {
|
||||
out := RegionDirScan{Dir: dir}
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
// region -> set of distinct chunk coords (dedup .comp/.uncomp pairs)
|
||||
type acc struct {
|
||||
files int
|
||||
chunks map[ChunkCoord]struct{}
|
||||
}
|
||||
clusters := map[RegionCoord]*acc{}
|
||||
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
cx, cz, ok := ParseErrorBackup(e.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out.ErrorBackups++
|
||||
rc := RegionForChunk(cx, cz)
|
||||
a := clusters[rc]
|
||||
if a == nil {
|
||||
a = &acc{chunks: map[ChunkCoord]struct{}{}}
|
||||
clusters[rc] = a
|
||||
}
|
||||
a.files++
|
||||
a.chunks[ChunkCoord{X: cx, Z: cz}] = struct{}{}
|
||||
}
|
||||
|
||||
for rc, a := range clusters {
|
||||
ar := AffectedRegion{Region: rc, ErrorBackups: a.files}
|
||||
for c := range a.chunks {
|
||||
ar.Chunks = append(ar.Chunks, c)
|
||||
}
|
||||
sort.Slice(ar.Chunks, func(i, j int) bool {
|
||||
if ar.Chunks[i].X != ar.Chunks[j].X {
|
||||
return ar.Chunks[i].X < ar.Chunks[j].X
|
||||
}
|
||||
return ar.Chunks[i].Z < ar.Chunks[j].Z
|
||||
})
|
||||
out.ErrorBackupDirs += len(ar.Chunks)
|
||||
rf := filepath.Join(dir, rc.FileName())
|
||||
if st, err := os.Stat(rf); err == nil && !st.IsDir() {
|
||||
ar.RegionFile = rf
|
||||
ar.FilePresent = true
|
||||
}
|
||||
out.Affected = append(out.Affected, ar)
|
||||
}
|
||||
|
||||
sort.Slice(out.Affected, func(i, j int) bool {
|
||||
if out.Affected[i].ErrorBackups != out.Affected[j].ErrorBackups {
|
||||
return out.Affected[i].ErrorBackups > out.Affected[j].ErrorBackups
|
||||
}
|
||||
// stable tiebreak by region id
|
||||
if out.Affected[i].Region.X != out.Affected[j].Region.X {
|
||||
return out.Affected[i].Region.X < out.Affected[j].Region.X
|
||||
}
|
||||
return out.Affected[i].Region.Z < out.Affected[j].Region.Z
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package regionmedic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Format constants for the 7DTD ".7rg" region file.
|
||||
const (
|
||||
SectorSize = 4096
|
||||
RegionChunks = 1024 // 32 x 32
|
||||
RegionSide = 32
|
||||
headerSectors = 2 // sector 0 = header, sector 1 = location table
|
||||
locTableOffset = 1 * SectorSize
|
||||
chunkConstVal = 47
|
||||
)
|
||||
|
||||
var (
|
||||
regionMagic = []byte{0x37, 0x72, 0x67, 0x01} // "7rg" + version 1
|
||||
chunkMagic = []byte{0x74, 0x74, 0x63, 0x00} // "ttc\0"
|
||||
)
|
||||
|
||||
// RegionReport is the outcome of validating a single .7rg image.
|
||||
type RegionReport struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
TotalSectors int `json:"total_sectors"`
|
||||
PresentChunks int `json:"present_chunks"`
|
||||
BadChunks []BadChunk `json:"bad_chunks,omitempty"`
|
||||
// Err is set for a fatal, file-level problem (bad magic, non-sector-aligned
|
||||
// size, truncated tables) that makes the image unusable as a region at all.
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// Healthy reports whether the region image is structurally sound: no fatal
|
||||
// error and no individually bad chunk.
|
||||
func (r RegionReport) Healthy() bool { return r.Err == "" && len(r.BadChunks) == 0 }
|
||||
|
||||
// BadChunk describes one structurally invalid chunk within a region.
|
||||
type BadChunk struct {
|
||||
Index int `json:"index"`
|
||||
LocalX int `json:"local_x"`
|
||||
LocalZ int `json:"local_z"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Reasons reported on BadChunk.Reason.
|
||||
const (
|
||||
ReasonWrongHeader = "wrong chunk header" // the "Wrong chunk header!" trigger
|
||||
ReasonBadConst = "bad format constant"
|
||||
ReasonOutOfBounds = "sector out of bounds"
|
||||
ReasonOverlap = "overlapping sectors"
|
||||
ReasonShortRead = "blob truncated"
|
||||
ReasonBadLength = "implausible block length"
|
||||
ReasonDeflate = "deflate error"
|
||||
ReasonCoordMismatch = "embedded coord mismatch"
|
||||
)
|
||||
|
||||
// ValidateRegionFile reads path and validates it. deep=true additionally
|
||||
// raw-inflates every present chunk and checks its embedded coordinates.
|
||||
func ValidateRegionFile(path string, deep bool) RegionReport {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return RegionReport{Path: path, Err: fmt.Sprintf("read: %v", err)}
|
||||
}
|
||||
r := ValidateRegionBytes(data, deep)
|
||||
r.Path = path
|
||||
return r
|
||||
}
|
||||
|
||||
// ValidateRegionBytes validates a .7rg image held in memory.
|
||||
//
|
||||
// Structural checks (always): sector-aligned size; "7rg" magic; empty header
|
||||
// remainder; every present chunk's location entry in-bounds and non-overlapping;
|
||||
// each chunk blob's "ttc\0" magic + format constant + plausible block length.
|
||||
// deep=true also inflates each chunk and verifies the decompressed payload's
|
||||
// chunkX/chunkZ match the table index.
|
||||
func ValidateRegionBytes(data []byte, deep bool) RegionReport {
|
||||
rep := RegionReport{SizeBytes: int64(len(data))}
|
||||
|
||||
if len(data) == 0 || len(data)%SectorSize != 0 {
|
||||
rep.Err = fmt.Sprintf("size %d is not a positive multiple of %d", len(data), SectorSize)
|
||||
return rep
|
||||
}
|
||||
total := len(data) / SectorSize
|
||||
rep.TotalSectors = total
|
||||
if total < headerSectors {
|
||||
rep.Err = "file shorter than header+location table"
|
||||
return rep
|
||||
}
|
||||
if !bytes.Equal(data[0:4], regionMagic) {
|
||||
rep.Err = fmt.Sprintf("bad magic % x (want % x)", data[0:4], regionMagic)
|
||||
return rep
|
||||
}
|
||||
// Header remainder must be empty (cheap corruption tripwire).
|
||||
for _, b := range data[4:SectorSize] {
|
||||
if b != 0 {
|
||||
rep.Err = "non-zero bytes in header block"
|
||||
return rep
|
||||
}
|
||||
}
|
||||
|
||||
used := make([]bool, total)
|
||||
used[0] = true // header
|
||||
used[1] = true // location table
|
||||
|
||||
for idx := 0; idx < RegionChunks; idx++ {
|
||||
e := data[locTableOffset+idx*4 : locTableOffset+idx*4+4]
|
||||
off := int(e[0]) | int(e[1])<<8 | int(e[2])<<16
|
||||
cnt := int(e[3])
|
||||
if off == 0 && cnt == 0 {
|
||||
continue // absent chunk — legitimate
|
||||
}
|
||||
rep.PresentChunks++
|
||||
lx, lz := idx%RegionSide, idx/RegionSide
|
||||
bad := func(reason string) { rep.BadChunks = append(rep.BadChunks, BadChunk{Index: idx, LocalX: lx, LocalZ: lz, Reason: reason}) }
|
||||
|
||||
if off < headerSectors || cnt < 1 || off+cnt > total {
|
||||
bad(ReasonOutOfBounds)
|
||||
continue
|
||||
}
|
||||
overlap := false
|
||||
for s := off; s < off+cnt; s++ {
|
||||
if used[s] {
|
||||
overlap = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if overlap {
|
||||
bad(ReasonOverlap)
|
||||
continue
|
||||
}
|
||||
for s := off; s < off+cnt; s++ {
|
||||
used[s] = true
|
||||
}
|
||||
|
||||
base := off * SectorSize
|
||||
capacity := cnt * SectorSize
|
||||
// Need at least the 24-byte blob header.
|
||||
if capacity < 24 {
|
||||
bad(ReasonShortRead)
|
||||
continue
|
||||
}
|
||||
blockLen := int(binary.LittleEndian.Uint32(data[base : base+4]))
|
||||
// blob occupies 16 + blockLen bytes; must fit the allocated sectors.
|
||||
if blockLen < 8 || 16+blockLen > capacity {
|
||||
bad(ReasonBadLength)
|
||||
continue
|
||||
}
|
||||
if !bytes.Equal(data[base+16:base+20], chunkMagic) {
|
||||
bad(ReasonWrongHeader) // <-- the "Wrong chunk header!" failure
|
||||
continue
|
||||
}
|
||||
if binary.LittleEndian.Uint32(data[base+20:base+24]) != chunkConstVal {
|
||||
bad(ReasonBadConst)
|
||||
continue
|
||||
}
|
||||
if deep {
|
||||
// Deep check: confirm the chunk's raw DEFLATE stream actually
|
||||
// inflates. A torn chunk whose "ttc" header happens to survive but
|
||||
// whose compressed body is garbage is caught here. The DEFLATE
|
||||
// stream is exactly blockLen-8 bytes starting at base+24.
|
||||
//
|
||||
// NOTE: we deliberately do NOT compare the decompressed payload's
|
||||
// embedded coordinates. Real pregenerated (RWG-baked) regions store
|
||||
// those in a layout that does not match a naive global-coord
|
||||
// assumption, so a coord comparison false-positives on perfectly
|
||||
// healthy regions — which would make every backup look corrupt and
|
||||
// block healing. The "ttc" magic check above is exactly what 7DTD
|
||||
// itself validates for "Wrong chunk header!", so structural validity
|
||||
// is the trustworthy signal.
|
||||
compLen := blockLen - 8
|
||||
start := base + 24
|
||||
if start+compLen > len(data) {
|
||||
bad(ReasonShortRead)
|
||||
continue
|
||||
}
|
||||
if _, err := io.ReadAll(flate.NewReader(bytes.NewReader(data[start : start+compLen]))); err != nil {
|
||||
bad(ReasonDeflate)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return rep
|
||||
}
|
||||
Reference in New Issue
Block a user