Импорт пресетов сборок из ZIP-пакета
Крупные сборки раздают готовый комплект настроек архивом. Панель разбирает его сама: находит серверный .ini, песочницу и точки появления, показывает, что нашла (сколько модов, карт, параметров), и только по подтверждению раскладывает файлы в новый или существующий профиль. Порты, пароли и название сервера при этом можно оставить свои — они относятся к машине, а не к сборке; у нового профиля они берутся из активного. Прежние конфиги остаются рядом как .bak, мир не трогается, а импорт в работающий профиль отклоняется: PZ перезаписал бы файлы своими при остановке. Посторонние .ini из архива (настройки модов) за конфиг сервера не принимаются: он опознаётся по знакомым параметрам. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
// Package preset разбирает пакеты настроек, которые раздают сборки модов.
|
||||
//
|
||||
// Сборка обычно приходит одним архивом с папкой Server внутри: там лежат
|
||||
// готовый .ini со списком модов, настройки песочницы и точки появления. Всё
|
||||
// остальное в архиве (файлы одиночной игры, картинки, changelog) менеджеру
|
||||
// не нужно и молча пропускается.
|
||||
package preset
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||
)
|
||||
|
||||
// Пределы разбора. Архив приходит от человека, но верить ему нельзя: без
|
||||
// ограничений маленький файл разворачивается в гигабайты.
|
||||
const (
|
||||
// MaxArchiveSize — предел на сам загружаемый файл.
|
||||
MaxArchiveSize = 32 << 20
|
||||
maxFileSize = 16 << 20
|
||||
maxTotalSize = 64 << 20
|
||||
maxEntries = 10000
|
||||
)
|
||||
|
||||
// ErrNoServerFiles возвращается, когда в архиве нет ни одного серверного файла.
|
||||
var ErrNoServerFiles = errors.New("в архиве нет серверных файлов Project Zomboid")
|
||||
|
||||
// Files — серверная часть пакета: то, что PZ читает при запуске.
|
||||
type Files struct {
|
||||
// Name — имя, под которым файлы лежали в архиве (sophie-server-42-2).
|
||||
Name string
|
||||
INI []byte
|
||||
Sandbox []byte
|
||||
SpawnRegions []byte
|
||||
}
|
||||
|
||||
// Summary — то, что панель показывает до импорта, чтобы человек понимал, что
|
||||
// именно он собирается записать поверх своих настроек.
|
||||
type Summary struct {
|
||||
Name string `json:"name"`
|
||||
ServerName string `json:"server_name"`
|
||||
Maps []string `json:"maps"`
|
||||
Mods int `json:"mods"`
|
||||
WorkshopItems int `json:"workshop_items"`
|
||||
Settings int `json:"settings"`
|
||||
SandboxSettings int `json:"sandbox_settings"`
|
||||
HasINI bool `json:"has_ini"`
|
||||
HasSandbox bool `json:"has_sandbox"`
|
||||
HasSpawnRegions bool `json:"has_spawn_regions"`
|
||||
}
|
||||
|
||||
// markers — параметры, по которым .ini опознаётся как конфиг сервера. В
|
||||
// архивах сборок хватает и других .ini (настройки модов, например), и брать
|
||||
// их за конфиг нельзя.
|
||||
var markers = []string{
|
||||
"PublicName", "PublicDescription", "DefaultPort", "UDPPort", "MaxPlayers",
|
||||
"Mods", "WorkshopItems", "Map", "PVP", "Open", "RCONPort", "ServerWelcomeMessage",
|
||||
"SaveWorldEveryMinutes", "SpawnPoint",
|
||||
}
|
||||
|
||||
const markersRequired = 3
|
||||
|
||||
// FromZip достаёт из архива все наборы серверных файлов. Наборов может быть
|
||||
// несколько: сборки иногда кладут рядом варианты конфигов.
|
||||
func FromZip(data []byte) ([]Files, error) {
|
||||
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("архив не читается: %w", err)
|
||||
}
|
||||
if len(reader.File) > maxEntries {
|
||||
return nil, fmt.Errorf("в архиве слишком много файлов: %d", len(reader.File))
|
||||
}
|
||||
|
||||
sets := map[string]*Files{}
|
||||
total := 0
|
||||
|
||||
for _, entry := range reader.File {
|
||||
if entry.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
name := path.Base(strings.ReplaceAll(entry.Name, "\\", "/"))
|
||||
base, kind := classify(name)
|
||||
if kind == kindOther {
|
||||
continue
|
||||
}
|
||||
if entry.UncompressedSize64 > maxFileSize {
|
||||
continue
|
||||
}
|
||||
if total+int(entry.UncompressedSize64) > maxTotalSize {
|
||||
return nil, errors.New("распакованный архив слишком велик")
|
||||
}
|
||||
|
||||
content, err := readEntry(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total += len(content)
|
||||
|
||||
if kind == kindINI && !looksLikeServerConfig(content) {
|
||||
continue
|
||||
}
|
||||
|
||||
set := sets[base]
|
||||
if set == nil {
|
||||
set = &Files{Name: base}
|
||||
sets[base] = set
|
||||
}
|
||||
switch kind {
|
||||
case kindINI:
|
||||
set.INI = content
|
||||
case kindSandbox:
|
||||
set.Sandbox = content
|
||||
case kindSpawnRegions:
|
||||
set.SpawnRegions = content
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Files, 0, len(sets))
|
||||
for _, set := range sets {
|
||||
// Одни точки появления без конфига и песочницы импортировать некуда.
|
||||
if set.INI == nil && set.Sandbox == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, *set)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, ErrNoServerFiles
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type kind int
|
||||
|
||||
const (
|
||||
kindOther kind = iota
|
||||
kindINI
|
||||
kindSandbox
|
||||
kindSpawnRegions
|
||||
)
|
||||
|
||||
// classify определяет по имени файла, часть какого набора перед нами.
|
||||
func classify(name string) (string, kind) {
|
||||
lower := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, "_sandboxvars.lua"):
|
||||
return name[:len(name)-len("_SandboxVars.lua")], kindSandbox
|
||||
case strings.HasSuffix(lower, "_spawnregions.lua"):
|
||||
return name[:len(name)-len("_spawnregions.lua")], kindSpawnRegions
|
||||
case strings.HasSuffix(lower, ".ini"):
|
||||
return name[:len(name)-len(".ini")], kindINI
|
||||
}
|
||||
return "", kindOther
|
||||
}
|
||||
|
||||
// looksLikeServerConfig отличает конфиг сервера от постороннего .ini.
|
||||
func looksLikeServerConfig(data []byte) bool {
|
||||
ini := pzconfig.ParseINI(data)
|
||||
found := 0
|
||||
for _, key := range markers {
|
||||
if ini.Has(key) {
|
||||
found++
|
||||
}
|
||||
}
|
||||
return found >= markersRequired
|
||||
}
|
||||
|
||||
func readEntry(entry *zip.File) ([]byte, error) {
|
||||
rc, err := entry.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("прочитать %s: %w", entry.Name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
// Заявленный размер может врать, поэтому читаем с запасом в один байт и
|
||||
// проверяем, что файл на нём кончился.
|
||||
content, err := io.ReadAll(io.LimitReader(rc, maxFileSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("прочитать %s: %w", entry.Name, err)
|
||||
}
|
||||
if len(content) > maxFileSize {
|
||||
return nil, fmt.Errorf("файл %s слишком велик", entry.Name)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// Describe собирает сводку по набору: сколько модов, карт и параметров.
|
||||
func (f Files) Describe() Summary {
|
||||
s := Summary{
|
||||
Name: f.Name,
|
||||
HasINI: f.INI != nil,
|
||||
HasSandbox: f.Sandbox != nil,
|
||||
HasSpawnRegions: f.SpawnRegions != nil,
|
||||
Maps: []string{},
|
||||
}
|
||||
if f.INI != nil {
|
||||
ini := pzconfig.ParseINI(f.INI)
|
||||
s.ServerName = ini.Get("PublicName")
|
||||
s.Mods = len(splitList(ini.Get("Mods")))
|
||||
s.WorkshopItems = len(splitList(ini.Get("WorkshopItems")))
|
||||
s.Maps = splitList(ini.Get("Map"))
|
||||
s.Settings = len(ini.Keys())
|
||||
}
|
||||
if f.Sandbox != nil {
|
||||
s.SandboxSettings = len(pzconfig.ParseSandbox(f.Sandbox).Entries())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// splitList разбирает перечисления PZ: значения через точку с запятой.
|
||||
func splitList(value string) []string {
|
||||
out := []string{}
|
||||
for _, part := range strings.Split(value, ";") {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package preset
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// serverINI — сокращённый конфиг сервера: маркеров хватает, чтобы отличить его
|
||||
// от постороннего .ini.
|
||||
const serverINI = `PublicName=Sophie Server
|
||||
PublicDescription=Vanilla++
|
||||
DefaultPort=16261
|
||||
MaxPlayers=32
|
||||
PVP=true
|
||||
Map=Brandenburg, KY;Muldraugh, KY
|
||||
Mods=SophieCore;SophieExtras
|
||||
WorkshopItems=3773856464;2392709985
|
||||
`
|
||||
|
||||
const sandboxLua = `SandboxVars = {
|
||||
VERSION = 5,
|
||||
Zombies = 3,
|
||||
ZombieLore = {
|
||||
Speed = 2,
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
const spawnRegions = `function SpawnRegions()
|
||||
return { { name = "Muldraugh,KY", file = "media/maps/Muldraugh, KY/spawnpoints.lua" } }
|
||||
end
|
||||
`
|
||||
|
||||
func buildZip(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := zip.NewWriter(&buf)
|
||||
for name, content := range files {
|
||||
f, err := w.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// Пакет сборки — это архив пользовательской папки Zomboid: серверные файлы в
|
||||
// нём лежат вперемешку с тем, что нужно только одиночной игре.
|
||||
func TestFromZipPack(t *testing.T) {
|
||||
data := buildZip(t, map[string]string{
|
||||
"readme.txt": "put these folders...",
|
||||
"changelog.txt": "v2",
|
||||
"THIS IS WHERE TO PUT THESE FOLDERS.png": "\x89PNG fake",
|
||||
"Lua/ModOptions.ini": "[SomeMod]\nenabled=true\n",
|
||||
"Lua/ModFolders.cfg": "folders",
|
||||
"Sandbox Presets/Sophie-42.cfg": "Version=6\nZombies=4\n",
|
||||
"Server/sophie-42.ini": serverINI,
|
||||
"Server/sophie-42_SandboxVars.lua": sandboxLua,
|
||||
"Server/sophie-42_spawnregions.lua": spawnRegions,
|
||||
})
|
||||
|
||||
sets, err := FromZip(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sets) != 1 {
|
||||
names := []string{}
|
||||
for _, s := range sets {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
t.Fatalf("наборов: %d (%s), ожидался один", len(sets), strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
set := sets[0]
|
||||
if set.Name != "sophie-42" {
|
||||
t.Errorf("имя набора = %q", set.Name)
|
||||
}
|
||||
if set.INI == nil || set.Sandbox == nil || set.SpawnRegions == nil {
|
||||
t.Fatalf("не все файлы найдены: ini=%v sandbox=%v spawn=%v",
|
||||
set.INI != nil, set.Sandbox != nil, set.SpawnRegions != nil)
|
||||
}
|
||||
|
||||
got := set.Describe()
|
||||
if got.ServerName != "Sophie Server" {
|
||||
t.Errorf("имя сервера = %q", got.ServerName)
|
||||
}
|
||||
if got.Mods != 2 || got.WorkshopItems != 2 {
|
||||
t.Errorf("модов %d, пакетов мастерской %d", got.Mods, got.WorkshopItems)
|
||||
}
|
||||
if len(got.Maps) != 2 || got.Maps[0] != "Brandenburg, KY" {
|
||||
t.Errorf("карты = %v", got.Maps)
|
||||
}
|
||||
if got.SandboxSettings == 0 {
|
||||
t.Error("параметры песочницы не посчитаны")
|
||||
}
|
||||
if !got.HasSpawnRegions {
|
||||
t.Error("точки появления не найдены")
|
||||
}
|
||||
}
|
||||
|
||||
// Настройки мода лежат в таком же .ini, и принимать их за конфиг сервера
|
||||
// нельзя: импорт затёр бы настоящий конфиг мусором.
|
||||
func TestFromZipIgnoresForeignINI(t *testing.T) {
|
||||
data := buildZip(t, map[string]string{
|
||||
"Lua/ModOptions.ini": "[SomeMod]\nenabled=true\nPVP=true\n",
|
||||
"notes.txt": "hi",
|
||||
})
|
||||
if _, err := FromZip(data); !errors.Is(err, ErrNoServerFiles) {
|
||||
t.Fatalf("ожидалась ErrNoServerFiles, получено %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Файлы могут лежать в корне архива или в папке с другим именем — важен
|
||||
// только их суффикс.
|
||||
func TestFromZipFlatLayout(t *testing.T) {
|
||||
data := buildZip(t, map[string]string{
|
||||
"my-server.ini": serverINI,
|
||||
"configs/my-server_SandboxVars.lua": sandboxLua,
|
||||
})
|
||||
sets, err := FromZip(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sets) != 1 || sets[0].Name != "my-server" {
|
||||
t.Fatalf("наборы = %+v", sets)
|
||||
}
|
||||
if sets[0].Sandbox == nil {
|
||||
t.Error("песочница из другой папки не подхвачена")
|
||||
}
|
||||
}
|
||||
|
||||
// Несколько вариантов конфигов в одном архиве — человек выбирает сам, поэтому
|
||||
// набора должно быть два, а не один слипшийся.
|
||||
func TestFromZipSeveralSets(t *testing.T) {
|
||||
data := buildZip(t, map[string]string{
|
||||
"Server/pvp.ini": serverINI,
|
||||
"Server/pvp_SandboxVars.lua": sandboxLua,
|
||||
"Server/pve.ini": serverINI,
|
||||
"Server/pve_SandboxVars.lua": sandboxLua,
|
||||
"Server/pve_spawnregions.lua": spawnRegions,
|
||||
})
|
||||
sets, err := FromZip(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sets) != 2 {
|
||||
t.Fatalf("наборов: %d", len(sets))
|
||||
}
|
||||
// Порядок задан именами: панель показывает их списком.
|
||||
if sets[0].Name != "pve" || sets[1].Name != "pvp" {
|
||||
t.Fatalf("порядок наборов: %q, %q", sets[0].Name, sets[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Одни точки появления импортировать некуда.
|
||||
func TestFromZipSpawnRegionsAlone(t *testing.T) {
|
||||
data := buildZip(t, map[string]string{"Server/x_spawnregions.lua": spawnRegions})
|
||||
if _, err := FromZip(data); !errors.Is(err, ErrNoServerFiles) {
|
||||
t.Fatalf("ожидалась ErrNoServerFiles, получено %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя файла из архива в путь не превращается: берём только последний сегмент,
|
||||
// поэтому «../» из имени записи никуда не ведёт.
|
||||
func TestFromZipIgnoresPaths(t *testing.T) {
|
||||
data := buildZip(t, map[string]string{
|
||||
"../../../etc/evil.ini": serverINI,
|
||||
"..\\..\\windows\\evil_SandboxVars.lua": sandboxLua,
|
||||
})
|
||||
sets, err := FromZip(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, set := range sets {
|
||||
if strings.ContainsAny(set.Name, `/\.`) {
|
||||
t.Errorf("в имени набора остался путь: %q", set.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromZipRejectsGarbage(t *testing.T) {
|
||||
if _, err := FromZip([]byte("это не архив")); err == nil {
|
||||
t.Fatal("мусор принят за архив")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user