panel public release

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 01:13:02 -07:00
commit 8a94ffd58f
2165 changed files with 301493 additions and 0 deletions
+191
View File
@@ -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
}
+56
View File
@@ -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
+193
View File
@@ -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)
}
+103
View File
@@ -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
}
+433
View File
@@ -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)
}
}
+101
View File
@@ -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
}
+189
View File
@@ -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
}