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_" 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 // (//) and returns the backups sorted newest // first, parsing the UTC timestamp + bkp id out of each filename: // // 20060102-150405-bkp_.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_.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/". 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 }