Импорт пресетов сборок из ZIP-пакета
Release / release (push) Successful in 30s
CI / test (push) Successful in 23s

Крупные сборки раздают готовый комплект настроек архивом. Панель разбирает
его сама: находит серверный .ini, песочницу и точки появления, показывает,
что нашла (сколько модов, карт, параметров), и только по подтверждению
раскладывает файлы в новый или существующий профиль.

Порты, пароли и название сервера при этом можно оставить свои — они относятся
к машине, а не к сборке; у нового профиля они берутся из активного. Прежние
конфиги остаются рядом как .bak, мир не трогается, а импорт в работающий
профиль отклоняется: PZ перезаписал бы файлы своими при остановке.

Посторонние .ini из архива (настройки модов) за конфиг сервера не
принимаются: он опознаётся по знакомым параметрам.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 17:18:44 +03:00
co-authored by Claude Opus 5
parent 1e659678b9
commit aec932f68f
12 changed files with 1022 additions and 7 deletions
+237
View File
@@ -0,0 +1,237 @@
package api
import (
"errors"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/preset"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/pzserver"
)
// keepGroups — то, что при импорте разумно оставить своим. Пресет собирали не
// под эту машину: его порты, пароли и название сервера к ней отношения не
// имеют, и молча их подменять — верный способ потерять доступ к серверу.
var keepGroups = map[string][]string{
"ports": {"DefaultPort", "UDPPort", "RCONPort", "RCONPassword"},
"access": {"Password", "server_browser_announced_ip", "PublicServerIP", "ServerBrowserAnnouncedIP"},
"identity": {"PublicName", "PublicDescription"},
}
// handlePreviewPreset разбирает загруженный архив и рассказывает, что в нём
// нашлось. Ничего не пишет: решение принимает человек.
func (s *Server) handlePreviewPreset(w http.ResponseWriter, r *http.Request) {
sets, ok := s.readPresetUpload(w, r)
if !ok {
return
}
summaries := make([]preset.Summary, 0, len(sets))
for _, set := range sets {
summaries = append(summaries, set.Describe())
}
writeJSON(w, http.StatusOK, map[string]any{"presets": summaries})
}
// handleImportPreset записывает выбранный набор в профиль — новый или уже
// существующий.
func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
sets, ok := s.readPresetUpload(w, r)
if !ok {
return
}
files, err := pickPreset(sets, r.FormValue("preset"))
if err != nil {
writeError(w, r, http.StatusBadRequest, err.Error())
return
}
createNew := r.FormValue("target") != "existing"
id := strings.TrimSpace(r.FormValue("profile"))
if id == "" && createNew {
id = config.SanitizeProfileID(files.Name)
}
_, exists := s.cfg.Profile(id)
switch {
case createNew && exists:
writeError(w, r, http.StatusConflict, fmt.Sprintf("профиль %q уже существует", id))
return
case !createNew && !exists:
writeError(w, r, http.StatusNotFound, "профиль не найден")
return
}
// Живой сервер держит мир и конфиг в памяти и переписывает файлы при
// остановке — импорт под ним просто пропал бы.
if id == s.cfg.Active && s.sup.State() != pzserver.StateStopped {
writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
return
}
if createNew {
profile := config.Profile{ID: id, Title: strings.TrimSpace(r.FormValue("title"))}
if profile.Title == "" {
profile.Title = files.Name
}
if err := s.cfg.AddProfile(profile); err != nil {
writeError(w, r, http.StatusBadRequest, err.Error())
return
}
}
result, err := s.applyPreset(files, id, parseKeep(r.FormValue("keep")))
if err != nil {
if createNew {
// Профиль без файлов настроек только мешал бы: откатываем.
_ = s.cfg.DeleteProfile(id)
}
writeError(w, r, http.StatusInternalServerError, err.Error())
return
}
if createNew {
if err := s.cfg.Save(); err != nil {
writeError(w, r, http.StatusInternalServerError, err.Error())
return
}
}
summary := files.Describe()
s.sup.AppendLog("manager", fmt.Sprintf("Импортирован пресет %q в профиль %q (модов: %d)",
files.Name, id, summary.Mods))
result["profile"] = id
result["created"] = createNew
result["summary"] = summary
writeJSON(w, http.StatusOK, result)
}
// applyPreset раскладывает файлы набора под именем профиля. Прежние версии
// сохраняются рядом с расширением .bak — так же, как при правке в редакторе.
func (s *Server) applyPreset(files preset.Files, id string, keep []string) (map[string]any, error) {
written := []string{}
kept := []string{}
keptFrom := ""
if files.INI != nil {
ini := pzconfig.ParseINI(files.INI)
// Свои значения берём из конфига самого профиля, а у нового профиля
// их ещё нет — тогда из активного: порты и пароли на машине общие.
source, from := s.keepSource(id)
if source != nil {
for _, key := range keep {
if source.Has(key) {
ini.Set(key, source.Get(key))
kept = append(kept, key)
}
}
if len(kept) > 0 {
keptFrom = from
}
}
path := s.cfg.ServerConfigPathFor(id)
if err := pzconfig.SaveINI(path, ini); err != nil {
return nil, err
}
written = append(written, filepath.Base(path))
}
raw := [][2]any{
{s.cfg.SandboxConfigPathFor(id), files.Sandbox},
{spawnRegionsPath(s.cfg, id), files.SpawnRegions},
}
for _, pair := range raw {
data, _ := pair[1].([]byte)
if data == nil {
continue
}
path := pair[0].(string)
if err := pzconfig.SaveRaw(path, data); err != nil {
return nil, err
}
written = append(written, filepath.Base(path))
}
return map[string]any{"files": written, "kept": kept, "kept_from": keptFrom}, nil
}
// keepSource выбирает конфиг, из которого берутся сохраняемые значения.
func (s *Server) keepSource(id string) (*pzconfig.INI, string) {
candidates := []string{id}
if id != s.cfg.Active {
candidates = append(candidates, s.cfg.Active)
}
for _, candidate := range candidates {
ini, err := pzconfig.LoadINI(s.cfg.ServerConfigPathFor(candidate))
if err == nil {
return ini, candidate
}
}
return nil, ""
}
// spawnRegionsPath — <профиль>_spawnregions.lua рядом с остальными конфигами.
func spawnRegionsPath(cfg *config.Config, id string) string {
return filepath.Join(filepath.Dir(cfg.ServerConfigPathFor(id)), id+"_spawnregions.lua")
}
// parseKeep разбирает список групп, которые человек отметил в панели.
func parseKeep(value string) []string {
keys := []string{}
for _, group := range strings.Split(value, ",") {
keys = append(keys, keepGroups[strings.TrimSpace(group)]...)
}
return keys
}
// pickPreset выбирает набор по имени; когда набор один, имя необязательно.
func pickPreset(sets []preset.Files, name string) (preset.Files, error) {
if name == "" {
if len(sets) == 1 {
return sets[0], nil
}
return preset.Files{}, errors.New("в архиве несколько наборов настроек — выберите один")
}
for _, set := range sets {
if set.Name == name {
return set, nil
}
}
return preset.Files{}, fmt.Errorf("набор %q не найден в архиве", name)
}
// readPresetUpload принимает архив из формы и разбирает его.
func (s *Server) readPresetUpload(w http.ResponseWriter, r *http.Request) ([]preset.Files, bool) {
r.Body = http.MaxBytesReader(w, r.Body, preset.MaxArchiveSize)
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, r, http.StatusBadRequest, "файл не принят: "+err.Error())
return nil, false
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, r, http.StatusBadRequest, "файл не выбран")
return nil, false
}
defer file.Close()
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
writeError(w, r, http.StatusBadRequest, "ожидается ZIP-архив пакета настроек")
return nil, false
}
data, err := io.ReadAll(io.LimitReader(file, preset.MaxArchiveSize))
if err != nil {
writeError(w, r, http.StatusBadRequest, "файл не прочитан: "+err.Error())
return nil, false
}
sets, err := preset.FromZip(data)
if err != nil {
writeError(w, r, http.StatusBadRequest, err.Error())
return nil, false
}
return sets, true
}
+2
View File
@@ -117,6 +117,8 @@ func (s *Server) Handler() http.Handler {
"PUT /api/profiles/{id}": s.handleUpdateProfile,
"DELETE /api/profiles/{id}": s.handleDeleteProfile,
"POST /api/profiles/{id}/activate": s.handleActivateProfile,
"POST /api/presets/preview": s.handlePreviewPreset,
"POST /api/presets/import": s.handleImportPreset,
"GET /api/mods": s.handleGetMods,
"PUT /api/mods": s.handlePutMods,
"POST /api/mods/lookup": s.handleLookupMods,
+19
View File
@@ -59,6 +59,25 @@ func ValidateProfileID(id string) error {
return nil
}
// SanitizeProfileID превращает имя файла из чужого пакета настроек в годный
// идентификатор профиля: пробелы и точки в именах пресетов не редкость, а
// идентификатор становится именем конфига и папки мира.
func SanitizeProfileID(name string) string {
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('-')
}
if b.Len() >= 32 {
break
}
}
return strings.Trim(b.String(), "-")
}
// Config — всё, что менеджеру нужно знать о хосте и о серверах PZ.
type Config struct {
// Listen — адрес веб-панели. Для доступа только через SSH-туннель
+22 -7
View File
@@ -71,13 +71,18 @@ var messages = map[string]string{
"Сервер не ответил на quit — отправляю SIGTERM": "The server did not answer quit — sending SIGTERM",
"Сервер завис — отправляю SIGKILL": "The server hung — sending SIGKILL",
"Сервер не сообщил о готовности — считаю его запущенным": "The server did not report that it is ready — treating it as running",
"Автоперезапуск отменён": "Automatic restart cancelled",
"Посмотрите ошибку выше и запустите сервер вручную.": "Check the error above and start the server manually.",
"Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели": "Starting the update — it runs as a separate service and survives the panel restart",
"Подробности: journalctl -u pzmanager-update -f": "Details: journalctl -u pzmanager-update -f",
"Перезапускаю панель": "Restarting the panel",
"обновлением": "the update",
"перезапуском": "the restart",
"в архиве нет серверных файлов Project Zomboid": "the archive has no Project Zomboid server files",
"распакованный архив слишком велик": "the unpacked archive is too large",
"файл не выбран": "no file selected",
"ожидается ZIP-архив пакета настроек": "a ZIP package of settings is expected",
"в архиве несколько наборов настроек — выберите один": "the archive holds several sets of settings — pick one",
"Автоперезапуск отменён": "Automatic restart cancelled",
"Посмотрите ошибку выше и запустите сервер вручную.": "Check the error above and start the server manually.",
"Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели": "Starting the update — it runs as a separate service and survives the panel restart",
"Подробности: journalctl -u pzmanager-update -f": "Details: journalctl -u pzmanager-update -f",
"Перезапускаю панель": "Restarting the panel",
"обновлением": "the update",
"перезапуском": "the restart",
}
var formats = [][2]string{
@@ -174,6 +179,16 @@ var formats = [][2]string{
{"Останавливаю игровой сервер перед %s", "Stopping the game server before %s"},
{"Плановый бэкап не запущен: %s", "The scheduled backup did not start: %s"},
// --- Импорт пресетов ---
{"архив не читается: %s", "the archive cannot be read: %s"},
{"в архиве слишком много файлов: %s", "the archive has too many files: %s"},
{"файл %s слишком велик", "the file %s is too large"},
{"файл не принят: %s", "the file was not accepted: %s"},
{"файл не прочитан: %s", "the file was not read: %s"},
{"набор %s не найден в архиве", "the set %s was not found in the archive"},
{"Импортирован пресет %s в профиль %s (модов: %s)",
"Preset %s imported into the profile %s (mods: %s)"},
// --- Бэкапы: ход работы ---
{"Создаю архив %s (профиль %s)", "Creating the archive %s (profile %s)"},
{"Архив готов: %s (%s, файлов: %s)", "Archive ready: %s (%s, files: %s)"},
+225
View File
@@ -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
}
+194
View File
@@ -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("мусор принят за архив")
}
}
+10
View File
@@ -172,6 +172,16 @@ func SaveINI(path string, ini *INI) error {
return writeAtomic(path, ini.Render(), 0o644)
}
// SaveRaw кладёт на место файл, который менеджер не разбирает (настройки
// песочницы и точки появления из пакета сборки), с той же страховкой: копия
// прежней версии рядом и атомарная замена.
func SaveRaw(path string, data []byte) error {
if err := backupFile(path); err != nil {
return err
}
return writeAtomic(path, data, 0o644)
}
func backupFile(path string) error {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {