package backup import ( "archive/tar" "compress/gzip" "context" "os" "path/filepath" "testing" "github.com/mrleo1nid/pzmanager/internal/config" ) func TestSafeJoinRejectsTraversal(t *testing.T) { root := filepath.Join(os.TempDir(), "pzm-root") for _, name := range []string{"../evil", "../../etc/passwd", "Saves/../../evil"} { if _, err := safeJoin(root, name); err == nil { t.Errorf("путь %q принят, ожидался отказ", name) } } if _, err := safeJoin(root, "Saves/Multiplayer/map.bin"); err != nil { t.Errorf("обычный путь отклонён: %v", err) } } func TestSanitizeNote(t *testing.T) { cases := map[string]string{ "перед вайпом": "", "before wipe": "before_wipe", "v1.2/../x": "v12x", "": "", "__trim-me__": "trim-me", } for input, want := range cases { if got := sanitizeNote(input); got != want { t.Errorf("sanitizeNote(%q) = %q, ожидалось %q", input, got, want) } } } func TestNameValidationAndProfileParsing(t *testing.T) { m := New(&config.Config{}) if _, err := m.Path("../../etc/passwd"); err == nil { t.Error("имя с переходом по каталогам принято") } if _, err := m.Path("pz-vanilla-20260904-181500-auto.tar.gz"); err != nil { t.Errorf("корректное имя отклонено: %v", err) } // Архивы, снятые до появления профилей, тоже должны читаться. if _, err := m.Path("pz-20250903-214141.tar.gz"); err != nil { t.Errorf("архив старого формата отклонён: %v", err) } cases := map[string]string{ "pz-vanilla-20260904-181500-auto.tar.gz": "vanilla", "pz-modded_b42-20260904-181500.tar.gz": "modded_b42", "pz-20250903-214141.tar.gz": "", "не-архив.tar.gz": "", } for name, want := range cases { if got := m.ProfileOf(name); got != want { t.Errorf("ProfileOf(%q) = %q, ожидалось %q", name, got, want) } } } // TestProfileConfigFiles проверяет, что профиль забирает только свои файлы: // префиксы имён профилей часто пересекаются. func TestProfileConfigFiles(t *testing.T) { dir := t.TempDir() files := []string{ "test.ini", "test_SandboxVars.lua", "test_spawnpoints.lua", "test2.ini", "test2_SandboxVars.lua", "testing.ini", "test.ini.bak", "other.ini", } for _, name := range files { if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil { t.Fatal(err) } } got, err := profileConfigFiles(dir, "test") if err != nil { t.Fatal(err) } want := map[string]bool{ "test.ini": true, "test_SandboxVars.lua": true, "test_spawnpoints.lua": true, } if len(got) != len(want) { t.Fatalf("выбрано %d файлов, ожидалось %d: %v", len(got), len(want), got) } for _, path := range got { if !want[filepath.Base(path)] { t.Errorf("лишний файл в архиве профиля: %s", filepath.Base(path)) } } } // TestCreateAndRestore проверяет полный цикл для одного профиля: архивируем // мир и конфиг, портим их, восстанавливаем и убеждаемся, что вернулось // содержимое именно этого профиля. func TestCreateAndRestore(t *testing.T) { root := t.TempDir() zomboid := filepath.Join(root, "Zomboid") world := filepath.Join(zomboid, "Saves", "Multiplayer", "vanilla", "map_t.bin") iniPath := filepath.Join(zomboid, "Server", "vanilla.ini") otherWorld := filepath.Join(zomboid, "Saves", "Multiplayer", "modded", "map_t.bin") writeFile(t, world, "исходный мир") writeFile(t, iniPath, "MaxPlayers=32") writeFile(t, otherWorld, "чужой мир") cfg := &config.Config{ZomboidDir: zomboid} cfg.Backup.Dir = filepath.Join(root, "backups") m := New(cfg) archive, err := m.Create(context.Background(), func(string) {}, "vanilla", "test") if err != nil { t.Fatalf("Create: %v", err) } if archive.Profile != "vanilla" { t.Errorf("профиль архива = %q", archive.Profile) } writeFile(t, world, "испорчено") writeFile(t, iniPath, "MaxPlayers=1") writeFile(t, otherWorld, "чужой мир изменён") if err := m.Restore(context.Background(), func(string) {}, archive.Name); err != nil { t.Fatalf("Restore: %v", err) } if got := readFile(t, world); got != "исходный мир" { t.Errorf("мир после восстановления = %q", got) } if got := readFile(t, iniPath); got != "MaxPlayers=32" { t.Errorf("конфиг после восстановления = %q", got) } // Мир другого профиля восстановление трогать не должно. if got := readFile(t, otherWorld); got != "чужой мир изменён" { t.Errorf("затронут мир чужого профиля: %q", got) } } // TestPruneIsPerProfile проверяет, что ротация не выкидывает архивы соседних // профилей: у каждого профиля своя история. func TestPruneIsPerProfile(t *testing.T) { root := t.TempDir() cfg := &config.Config{ZomboidDir: filepath.Join(root, "Zomboid")} cfg.Backup.Dir = filepath.Join(root, "backups") m := New(cfg) if err := os.MkdirAll(cfg.Backup.Dir, 0o755); err != nil { t.Fatal(err) } names := []string{ "pz-vanilla-20260101-000000.tar.gz", "pz-vanilla-20260102-000000.tar.gz", "pz-vanilla-20260103-000000.tar.gz", "pz-modded-20260101-000000.tar.gz", } for _, name := range names { writeEmptyArchive(t, filepath.Join(cfg.Backup.Dir, name)) } if err := m.Prune("vanilla", 2, func(string) {}); err != nil { t.Fatal(err) } vanilla, err := m.ListForProfile("vanilla") if err != nil { t.Fatal(err) } if len(vanilla) != 2 { t.Errorf("у vanilla осталось %d архивов, ожидалось 2", len(vanilla)) } modded, err := m.ListForProfile("modded") if err != nil { t.Fatal(err) } if len(modded) != 1 { t.Errorf("ротация зацепила архивы профиля modded: осталось %d", len(modded)) } } func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func readFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(data) } // writeEmptyArchive создаёт валидный пустой tar.gz — List проверяет имя, а не // содержимое, но пусть файлы будут настоящими. func writeEmptyArchive(t *testing.T, path string) { t.Helper() f, err := os.Create(path) if err != nil { t.Fatal(err) } defer f.Close() gz := gzip.NewWriter(f) tw := tar.NewWriter(gz) if err := tw.Close(); err != nil { t.Fatal(err) } if err := gz.Close(); err != nil { t.Fatal(err) } }