panel v0.9.1 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user