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