Профиль — это отдельный сервер: свой мир, .ini, песочница, набор модов и лимит памяти JVM. Project Zomboid различает их по -servername, поэтому файлы профилей не пересекаются. Работает всегда один профиль: серверные файлы, игровые порты и файл запуска JVM общие, так что переключение разрешено только на остановленном сервере. - config: список профилей вместо одиночных server_name/admin_password/ java_memory; старые поля переносятся в первый профиль при загрузке; - supervisor: запускает активный профиль и выставляет его -Xmx на каждом старте, потому что файл запуска общий; - бэкапы: архив снимается с одного профиля (мир + его конфиги), имя профиля попадает в имя файла, ротация считается по профилям; - API профилей: список, создание с копированием настроек из другого профиля, правка, удаление и переключение активного; - панель: селектор профиля в шапке и вкладка со списком профилей. Удаление профиля убирает его только из панели: мир и конфиги остаются на диске, чтобы случайное нажатие не стоило сохранений. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
7.0 KiB
Go
225 lines
7.0 KiB
Go
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)
|
||
}
|
||
}
|