0f6aea796c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
434 lines
14 KiB
Go
434 lines
14 KiB
Go
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)
|
|
}
|
|
}
|