feat(zone): checking, keeping and locking a zone's edited files

Three pieces that know nothing of how a set of files arrives:
- ZonePublish reads every file of a set with the server's own readers and says, file
  by file, what is wrong; a set is good only when every file is.
- ZoneOverrideStore keeps a good set in the override folder: built in a staging
  folder, flushed, swapped in with a rename, the set before it kept as a backup.
  Restoring a backup and clearing a zone's set are the same swap.
- ZoneEditLock has the rules for a zone's editors and its one lock holder, and for
  holding a reload while players are in the zone.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-11-10 18:25:44 -06:00
parent 0e57b95229
commit f34577b5a2
11 changed files with 1657 additions and 1 deletions

View File

@@ -37,6 +37,8 @@ set(DCOMMON_SOURCES
"ZoneFile.cpp"
"LevelFile.cpp"
"ZoneScenes.cpp"
"ZonePublish.cpp"
"ZoneOverrideStore.cpp"
"Process.cpp"
"Permissions.cpp"
"PermissionGrants.cpp"
@@ -119,7 +121,7 @@ add_dependencies(dBuildInfo BuildInfoGenerate)
target_link_libraries(dCommon
PUBLIC glm::glm dBuildInfo
PRIVATE ZLIB::ZLIB bcrypt tinyxml2
PRIVATE ZLIB::ZLIB bcrypt tinyxml2 OpenSSL::Crypto
INTERFACE dDatabase)
# Profiler.h's frames and scopes also go to Tracy (thirdparty/CMakeLists.txt, DLU_TRACY)

91
dCommon/ZoneEditLock.h Normal file
View File

@@ -0,0 +1,91 @@
#pragma once
#include <cstdint>
#include <optional>
/**
* Who may edit a zone's files (docs/ZonePublish.md). A zone has up to MAX_EDITORS assigned editors and at most one
* lock holder at a time; a set can only be sent, restored or cleared by the account holding the zone's lock. A lock
* never runs out by itself: its holder gives it up, or an admin takes it away.
*
* Pure rules, the same whichever way a request arrives; the caller looks up the account's GM level (now, never from
* anything the requester says), the zone's holder and whether the account is assigned.
*/
namespace ZoneEditLock {
constexpr uint32_t MAX_EDITORS = 10;
constexpr uint8_t DEFAULT_PUBLISH_LEVEL = 9;
constexpr uint8_t DEFAULT_ADMIN_LEVEL = 9;
// The numbers are part of what requesters are told; never change them
enum class eResult : uint8_t {
OK = 0,
LEVEL_TOO_LOW = 1, // the account's level is below the level editing needs
NOT_ASSIGNED = 2, // not one of the zone's editors (and not an admin)
LOCKED_BY_OTHER = 3, // another account holds the zone's lock
NO_CHANGE = 4, // nothing to do: already the holder, or nothing was locked
};
struct Levels {
uint8_t publish = DEFAULT_PUBLISH_LEVEL; // zone_publish_gm_level
uint8_t admin = DEFAULT_ADMIN_LEVEL; // zone_lock_admin_gm_level
};
struct Actor {
uint32_t accountId{};
uint8_t gmLevel{};
bool assigned{}; // one of the zone's editors
};
inline bool IsAdmin(const Actor& actor, const Levels& levels) {
return actor.gmLevel >= levels.admin;
}
// Taking the zone's lock; holder: the account holding it now
inline eResult Take(const Actor& actor, std::optional<uint32_t> holder, const Levels& levels) {
if (actor.accountId == 0 || actor.gmLevel < levels.publish) return eResult::LEVEL_TOO_LOW;
if (!actor.assigned && !IsAdmin(actor, levels)) return eResult::NOT_ASSIGNED;
if (holder && *holder == actor.accountId) return eResult::NO_CHANGE;
if (holder) return eResult::LOCKED_BY_OTHER;
return eResult::OK;
}
// Giving the lock up: its holder, or an admin taking it from whoever holds it
inline eResult Release(const Actor& actor, std::optional<uint32_t> holder, const Levels& levels) {
if (actor.accountId == 0 || actor.gmLevel < levels.publish) return eResult::LEVEL_TOO_LOW;
if (!holder) return eResult::NO_CHANGE;
if (*holder == actor.accountId || IsAdmin(actor, levels)) return eResult::OK;
return eResult::LOCKED_BY_OTHER;
}
/**
* Changing the zone's files (a new set, a restore, a clear): only the lock's holder. OK, LEVEL_TOO_LOW,
* LOCKED_BY_OTHER, or NOT_ASSIGNED when nobody holds the lock (it has to be taken first).
*/
inline eResult Change(const Actor& actor, std::optional<uint32_t> holder, const Levels& levels) {
if (actor.accountId == 0 || actor.gmLevel < levels.publish) return eResult::LEVEL_TOO_LOW;
if (!holder) return eResult::NOT_ASSIGNED;
if (*holder != actor.accountId) return eResult::LOCKED_BY_OTHER;
return eResult::OK;
}
// What happens to the zone's running instances after its files changed
enum class eReload : uint8_t {
SKIPPED, // not asked for: the files are read when an instance next starts
STARTED, // the zone's instances are being replaced now
HELD, // players are there and they were not to be moved: waits until someone says to
};
inline eReload PlanReload(bool reload, uint32_t players, bool movePlayers) {
if (!reload) return eReload::SKIPPED;
return players > 0 && !movePlayers ? eReload::HELD : eReload::STARTED;
}
inline const char* ReloadName(eReload reload) {
switch (reload) {
case eReload::SKIPPED: return "skipped";
case eReload::STARTED: return "started";
case eReload::HELD: return "held";
}
return "skipped";
}
}

View File

@@ -72,6 +72,7 @@ void ZoneFile::Read(std::istream& file) {
if (!ReadPaths(pathChunk)) {
LOG("The zone file's paths are not ones the client reads; the zone has no paths, as in the client");
paths.clear();
pathsRefused = true;
}
}
}

View File

@@ -233,6 +233,8 @@ struct ZoneFile {
uint32_t pathDataLength{};
uint32_t pathChunkVersion{};
std::vector<Path> paths;
// Set when the path chunk was not one the client reads (the zone then has no paths, as in the client)
bool pathsRefused = false;
/**
* Reads the whole file. Throws std::runtime_error (BinaryIO) if it ends early; the last value read may still come up

View File

@@ -0,0 +1,386 @@
#include "ZoneOverrideStore.h"
#include <algorithm>
#include <cstdio>
#include <fstream>
#include <set>
#include "BinaryPathFinder.h"
#include "ZonePublish.h"
#include "json.hpp"
#ifdef _WIN32
#include <io.h>
#else
#include <fcntl.h>
#include <unistd.h>
#endif
#ifdef __linux__
#include <sys/syscall.h>
#include <linux/fs.h>
#endif
namespace fs = std::filesystem;
using json = nlohmann::json;
namespace {
using namespace ZoneOverrideStore;
[[noreturn]] void Fail(const std::string& what) {
throw StoreError(what);
}
// Flush a file's (or, where the system lets a folder be opened, a folder's) contents to disk
void Sync(const fs::path& path) {
#ifdef _WIN32
FILE* file = nullptr;
if (fopen_s(&file, path.string().c_str(), "rb+") != 0 || !file) return; // folders can't be opened this way
fflush(file);
_commit(_fileno(file));
fclose(file);
#else
const int fd = ::open(path.c_str(), O_RDONLY);
if (fd < 0) Fail("Could not open " + path.string() + " to flush it");
const int result = ::fsync(fd);
::close(fd);
if (result != 0) Fail("Could not flush " + path.string());
#endif
}
void SyncTree(const fs::path& folder) {
std::error_code code;
for (fs::recursive_directory_iterator it(folder, code), end; !code && it != end; it.increment(code)) {
if (it->is_regular_file(code) || it->is_directory(code)) Sync(it->path());
}
if (code) Fail("Could not read " + folder.string());
Sync(folder);
}
void MakeDirs(const fs::path& folder) {
std::error_code code;
fs::create_directories(folder, code);
if (code) Fail("Could not make " + folder.string());
}
// The same file under another name where the file system can (sets are never changed in place), else a copy
void LinkOrCopy(const fs::path& from, const fs::path& to) {
MakeDirs(to.parent_path());
std::error_code code;
fs::create_hard_link(from, to, code);
if (!code) return;
fs::copy_file(from, to, fs::copy_options::overwrite_existing, code);
if (code) Fail("Could not copy " + from.string());
}
void MoveOrCopy(const fs::path& from, const fs::path& to) {
MakeDirs(to.parent_path());
std::error_code code;
fs::rename(from, to, code);
if (!code) return;
fs::copy_file(from, to, fs::copy_options::overwrite_existing, code);
if (code) Fail("Could not copy " + from.string());
}
void WriteText(const fs::path& path, const std::string& text) {
MakeDirs(path.parent_path());
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out << text;
out.flush();
if (!out) Fail("Could not write " + path.string());
}
std::optional<json> ReadJson(const fs::path& path) {
std::ifstream in(path, std::ios::binary);
if (!in) return std::nullopt;
auto parsed = json::parse(in, nullptr, false);
if (parsed.is_discarded() || !parsed.is_object()) return std::nullopt;
return parsed;
}
json ToJson(const Entry& entry) {
return { {"path", entry.path}, {"bytes", entry.bytes}, {"sha256", entry.sha256}, {"version", entry.version}, {"who", entry.who}, {"when", entry.when} };
}
std::vector<Entry> EntriesOf(const json& manifest) {
std::vector<Entry> entries;
const auto files = manifest.find("files");
if (files == manifest.end() || !files->is_array()) return entries;
for (const auto& file : *files) {
if (!file.is_object()) continue;
Entry entry;
entry.path = file.value("path", std::string());
entry.bytes = file.value("bytes", uint64_t{});
entry.sha256 = file.value("sha256", std::string());
entry.version = file.value("version", uint32_t{});
entry.who = file.value("who", std::string());
entry.when = file.value("when", int64_t{});
// A manifest is only ever written here, but it is read back from disk: its paths are checked again
if (ZonePublish::NameProblem(entry.path) || ZonePublish::Canonical(entry.path) != entry.path) continue;
entries.push_back(std::move(entry));
}
return entries;
}
std::string ManifestName(uint32_t zoneId) {
return ".zone-" + std::to_string(zoneId) + ".json";
}
struct Folders {
fs::path root; // resolved
fs::path maps; // <root>/maps
fs::path live; // <root>/maps/<zone folder>
fs::path staging; // <root>/staging/<id>
fs::path backups; // <root>/backups/<zone id>
std::string folder; // the zone folder, no trailing '/'
};
Folders Resolve(const fs::path& root, uint32_t zoneId, const std::string& zoneFolder, const std::string& id) {
if (root.empty()) Fail("No override folder is set (zone_override_dir)");
Folders folders;
std::error_code code;
fs::create_directories(root / "maps", code);
folders.root = fs::weakly_canonical(root, code);
if (code) Fail("The override folder can't be used: " + root.string());
folders.maps = folders.root / "maps";
folders.folder = zoneFolder;
while (!folders.folder.empty() && folders.folder.back() == '/') folders.folder.pop_back();
if (folders.folder.empty()) Fail("The zone's files are not in a folder of their own");
const auto live = ZonePublish::ResolveInside(folders.maps, folders.folder);
if (!live) Fail("The zone's folder is not inside the override folder");
folders.live = *live;
if (!id.empty()) {
if (!ValidId(id)) Fail("Not a usable id");
folders.staging = folders.root / "staging" / id;
}
folders.backups = folders.root / "backups" / std::to_string(zoneId);
return folders;
}
// Swap two folders that both exist, in one step where the system has one
void SwapFolders(const fs::path& incoming, const fs::path& live, const fs::path& aside) {
#ifdef __linux__
if (::syscall(SYS_renameat2, AT_FDCWD, incoming.c_str(), AT_FDCWD, live.c_str(), RENAME_EXCHANGE) == 0) return;
#endif
std::error_code code;
fs::rename(live, aside, code);
if (code) Fail("Could not move the zone's folder aside");
fs::rename(incoming, live, code);
if (code) {
std::error_code undo;
fs::rename(aside, live, undo);
Fail("Could not move the new files into place");
}
}
std::vector<Entry> CurrentOf(const Folders& folders, uint32_t zoneId, json* manifestOut = nullptr) {
const auto manifest = ReadJson(folders.live / ManifestName(zoneId));
if (!manifest) return {};
if (manifestOut) *manifestOut = *manifest;
auto entries = EntriesOf(*manifest);
const auto prefix = folders.folder + "/";
std::erase_if(entries, [&](const Entry& entry) { return !entry.path.starts_with(prefix); });
return entries;
}
void PruneBackups(const fs::path& backups, uint32_t keep) {
std::vector<std::string> ids;
std::error_code code;
for (fs::directory_iterator it(backups, code), end; !code && it != end; it.increment(code)) {
if (it->is_directory(code) && ValidId(it->path().filename().string())) ids.push_back(it->path().filename().string());
}
// "<unix time>-<id>": newest first by time, then by name
const auto timeOf = [](const std::string& id) { return std::strtoll(id.c_str(), nullptr, 10); };
std::ranges::sort(ids, [&](const std::string& a, const std::string& b) {
return timeOf(a) != timeOf(b) ? timeOf(a) > timeOf(b) : a > b;
});
for (size_t i = keep; i < ids.size(); ++i) fs::remove_all(backups / ids[i], code);
}
/**
* The one way a zone's set changes. `place` puts the new set's files under the folder it is given (laid out as
* under res/maps/) and returns their entries; everything else is the same for a new set, a restore and a clear.
*/
template<typename Place>
Applied Replace(const fs::path& root, const Change& change, Place place) {
const auto folders = Resolve(root, change.zoneId, change.zoneFolder, change.id);
const auto incomingMaps = folders.staging / "incoming";
const auto incoming = incomingMaps / folders.folder;
std::error_code code;
fs::remove_all(incomingMaps, code);
MakeDirs(incoming);
json oldManifest;
const auto previous = CurrentOf(folders, change.zoneId, &oldManifest);
std::set<std::string> previousPaths;
for (const auto& entry : previous) previousPaths.insert(entry.path);
// Everything in the folder that is not this zone's set stays (zones can share a folder)
const bool hadFolder = fs::is_directory(folders.live, code);
if (hadFolder) {
for (fs::recursive_directory_iterator it(folders.live, code), end; !code && it != end; it.increment(code)) {
if (!it->is_regular_file(code)) continue;
const auto relative = folders.folder + "/" + it->path().lexically_relative(folders.live).generic_string();
if (previousPaths.contains(relative) || it->path().filename() == ManifestName(change.zoneId)) continue;
LinkOrCopy(it->path(), incomingMaps / relative);
}
if (code) Fail("Could not read the zone's folder");
}
auto entries = place(incomingMaps, folders);
const auto prefix = folders.folder + "/";
for (auto& entry : entries) {
if (!entry.path.starts_with(prefix)) Fail("A file is not in the zone's folder: " + entry.path);
const auto target = ZonePublish::ResolveInside(incomingMaps, entry.path);
if (!target || !fs::is_regular_file(*target, code)) Fail("A file did not arrive where it belongs: " + entry.path);
}
if (!entries.empty()) {
json files = json::array();
for (const auto& entry : entries) files.push_back(ToJson(entry));
WriteText(incoming / ManifestName(change.zoneId), json{ {"zoneId", change.zoneId}, {"id", change.id}, {"who", change.who},
{"note", change.note}, {"time", change.now}, {"files", files} }.dump(1));
}
SyncTree(incomingMaps);
// The set being replaced is kept first
Applied applied;
if (!previous.empty()) {
const auto backupId = std::to_string(change.now) + "-" + change.id;
const auto backup = folders.backups / backupId;
fs::remove_all(backup, code);
for (const auto& entry : previous) {
const auto from = ZonePublish::ResolveInside(folders.maps, entry.path);
if (!from || !fs::is_regular_file(*from, code)) continue;
LinkOrCopy(*from, backup / "files" / entry.path);
}
oldManifest["replacedAt"] = change.now;
WriteText(backup / "manifest.json", oldManifest.dump(1));
SyncTree(backup);
Sync(folders.backups);
applied.backupId = backupId;
}
// The swap
if (hadFolder) {
SwapFolders(incoming, folders.live, folders.staging / "replaced");
} else {
MakeDirs(folders.live.parent_path());
fs::rename(incoming, folders.live, code);
if (code) Fail("Could not move the new files into place");
}
Sync(folders.live.parent_path());
fs::remove_all(folders.staging, code);
PruneBackups(folders.backups, change.keepBackups);
applied.files = std::move(entries);
return applied;
}
}
namespace ZoneOverrideStore {
std::filesystem::path RootFromSetting(const std::string& value) {
if (value.empty()) return {};
fs::path root(value);
if (root.is_relative()) root = BinaryPathFinder::GetBinaryDir() / root;
return root.lexically_normal();
}
bool ValidId(std::string_view text) {
if (text.empty() || text.size() > 64) return false;
return std::ranges::all_of(text, [](char c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'; });
}
std::filesystem::path MakeUploadDir(const fs::path& root, const std::string& id) {
if (root.empty() || !ValidId(id)) Fail("No override folder is set (zone_override_dir)");
std::error_code code;
fs::create_directories(root, code);
const auto resolved = fs::weakly_canonical(root, code);
if (code) Fail("The override folder can't be used: " + root.string());
const auto folder = resolved / "staging" / id / "upload";
fs::remove_all(folder, code);
MakeDirs(folder);
return folder;
}
void RemoveStaging(const fs::path& root, const std::string& id) {
if (root.empty() || !ValidId(id)) return;
std::error_code code;
fs::remove_all(root / "staging" / id, code);
}
void RemoveAllStaging(const fs::path& root) {
if (root.empty()) return;
std::error_code code;
fs::remove_all(root / "staging", code);
}
Applied Apply(const fs::path& root, const Change& change, const std::vector<NewFile>& files) {
if (files.empty()) Fail("A set has at least the zone's .luz");
return Replace(root, change, [&](const fs::path& incomingMaps, const Folders&) {
std::vector<Entry> entries;
std::set<std::string> seen;
for (const auto& file : files) {
if (ZonePublish::Canonical(file.path) != file.path || !seen.insert(file.path).second) Fail("Not a usable file name: " + file.path);
const auto target = ZonePublish::ResolveInside(incomingMaps, file.path);
if (!target) Fail("Not a usable file name: " + file.path);
MoveOrCopy(file.source, *target);
entries.push_back({ file.path, file.bytes, file.sha256, file.version, change.who, change.now });
}
return entries;
});
}
Applied Restore(const fs::path& root, const Change& change, const std::string& backupId) {
if (!ValidId(backupId)) Fail("No such backup");
return Replace(root, change, [&](const fs::path& incomingMaps, const Folders& folders) {
const auto backup = folders.backups / backupId;
const auto manifest = ReadJson(backup / "manifest.json");
if (!manifest) Fail("No such backup");
auto entries = EntriesOf(*manifest);
if (entries.empty()) Fail("The backup holds no files");
for (const auto& entry : entries) {
const auto from = ZonePublish::ResolveInside(backup / "files", entry.path);
const auto to = ZonePublish::ResolveInside(incomingMaps, entry.path);
std::error_code code;
if (!from || !to || !fs::is_regular_file(*from, code)) Fail("The backup is missing " + entry.path);
if (ZonePublish::Sha256File(*from) != std::optional<std::string>(entry.sha256)) Fail("The backup's " + entry.path + " is not the file that was kept");
LinkOrCopy(*from, *to);
}
return entries;
});
}
Applied Clear(const fs::path& root, const Change& change) {
return Replace(root, change, [](const fs::path&, const Folders&) { return std::vector<Entry>{}; });
}
std::vector<Entry> Current(const fs::path& root, uint32_t zoneId, const std::string& zoneFolder) {
if (root.empty()) return {};
try {
return CurrentOf(Resolve(root, zoneId, zoneFolder, ""), zoneId);
} catch (const StoreError&) {
return {};
}
}
std::vector<Backup> Backups(const fs::path& root, uint32_t zoneId) {
std::vector<Backup> backups;
if (root.empty()) return backups;
const auto folder = root / "backups" / std::to_string(zoneId);
std::error_code code;
for (fs::directory_iterator it(folder, code), end; !code && it != end; it.increment(code)) {
const auto id = it->path().filename().string();
if (!it->is_directory(code) || !ValidId(id)) continue;
const auto manifest = ReadJson(it->path() / "manifest.json");
if (!manifest) continue;
Backup backup;
backup.id = id;
backup.time = manifest->value("replacedAt", int64_t{});
backup.who = manifest->value("who", std::string());
backup.note = manifest->value("note", std::string());
backup.files = EntriesOf(*manifest);
backups.push_back(std::move(backup));
}
std::ranges::sort(backups, [](const Backup& a, const Backup& b) { return a.time != b.time ? a.time > b.time : a.id > b.id; });
return backups;
}
}

View File

@@ -0,0 +1,96 @@
#pragma once
#include <cstdint>
#include <filesystem>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>
/**
* The override folder (zone_override_dir, docs/ZonePublish.md): the edited zone files the servers read in place of
* the client's (AssetManager::SetOverrideDir), kept apart from the client's files, which are never written.
*
* <root>/maps/<zone folder>/... the files in use, where the asset manager looks
* <root>/maps/<zone folder>/.zone-<id>.json which of them are zone <id>'s set, who sent it and when
* <root>/staging/<id>/ a set while it is checked and built
* <root>/backups/<zone id>/<time>-<id>/ the sets a zone had before, newest kept
*
* A zone's set is replaced as a whole: the new contents of its folder are built in staging, flushed to disk, and
* swapped in with a rename; the set it replaces goes to backups first. Restoring a backup and clearing a zone's set
* are the same swap. Knows nothing of who may do this (ZoneEditLock) or how files arrive.
*
* Only reads and writes files under root, and takes no lock of its own: callers apply one set at a time. No
* settings, database or logging, so it can run on a worker thread.
*/
namespace ZoneOverrideStore {
struct StoreError : std::runtime_error {
using std::runtime_error::runtime_error;
};
struct Entry {
std::string path; // relative to res/maps/
uint64_t bytes{};
std::string sha256;
uint32_t version{};
std::string who;
int64_t when{};
};
struct Backup {
std::string id; // "<unix time>-<set id>"
int64_t time{}; // when the set was replaced
std::string who; // who sent the set it holds
std::string note;
std::vector<Entry> files;
};
// A file of a new set: where its bytes are now (moved or copied into the store)
struct NewFile {
std::string path;
std::filesystem::path source;
uint64_t bytes{};
std::string sha256;
uint32_t version{};
};
struct Applied {
std::optional<std::string> backupId; // the set that was replaced, when there was one
std::vector<Entry> files; // the zone's set now
};
struct Change {
uint32_t zoneId{};
std::string zoneFolder; // the zone's .luz folder relative to res/maps/, with its trailing '/'
std::string id; // unique per change: letters, digits and '-' only
std::string who;
std::string note;
int64_t now{}; // unix seconds
uint32_t keepBackups = 20;
};
// The folder a zone_override_dir value names: as written when absolute, else under the server's own folder.
// Empty for an empty value (the feature is off).
std::filesystem::path RootFromSetting(const std::string& value);
// Whether text can be a set or backup id: 1 to 64 of a-z, 0-9 and '-'
bool ValidId(std::string_view text);
// Where a transport may put a set's files while it is checked: <root>/staging/<id>/upload (made empty)
std::filesystem::path MakeUploadDir(const std::filesystem::path& root, const std::string& id);
// Removes everything of <root>/staging/<id>
void RemoveStaging(const std::filesystem::path& root, const std::string& id);
// Removes every staging folder (left behind when a server stopped mid-way); call before any set is taken
void RemoveAllStaging(const std::filesystem::path& root);
// Replace the zone's set with these files. Throws StoreError with nothing changed when it can't.
Applied Apply(const std::filesystem::path& root, const Change& change, const std::vector<NewFile>& files);
// Replace the zone's set with a backup's. Throws StoreError (also for an unknown backup).
Applied Restore(const std::filesystem::path& root, const Change& change, const std::string& backupId);
// Take the zone's set away, so the client's files are read again
Applied Clear(const std::filesystem::path& root, const Change& change);
std::vector<Entry> Current(const std::filesystem::path& root, uint32_t zoneId, const std::string& zoneFolder);
// Newest first
std::vector<Backup> Backups(const std::filesystem::path& root, uint32_t zoneId);
}

394
dCommon/ZonePublish.cpp Normal file
View File

@@ -0,0 +1,394 @@
#include "ZonePublish.h"
#include <algorithm>
#include <array>
#include <fstream>
#include <map>
#include <sstream>
#include <openssl/evp.h>
#include "LevelFile.h"
#include "Raw.h"
#include "ZoneFile.h"
#include "eTriggerCommandType.h"
#include "eTriggerEventType.h"
#include "tinyxml2.h"
namespace {
using namespace ZonePublish;
std::string Hex(const unsigned char* bytes, size_t size) {
static constexpr char digits[] = "0123456789abcdef";
std::string out;
out.reserve(size * 2);
for (size_t i = 0; i < size; ++i) {
out += digits[bytes[i] >> 4];
out += digits[bytes[i] & 15];
}
return out;
}
void Add(File& file, std::string code, std::string message, std::optional<uint64_t> offset = std::nullopt) {
file.ok = false;
if (file.errors.size() >= MAX_ERRORS_PER_FILE) return;
file.errors.push_back({ std::move(code), std::move(message), offset });
}
std::optional<std::string> ReadWhole(const std::filesystem::path& path, uint64_t maxBytes) {
std::ifstream in(path, std::ios::binary);
if (!in) return std::nullopt;
in.seekg(0, std::ios::end);
const auto size = in.tellg();
if (size < 0 || static_cast<uint64_t>(size) > maxBytes) return std::nullopt;
in.seekg(0);
std::string data(static_cast<size_t>(size), '\0');
in.read(data.data(), static_cast<std::streamsize>(data.size()));
if (in.gcount() != static_cast<std::streamsize>(data.size())) return std::nullopt;
return data;
}
// Where a reader stopped, for the error
std::optional<uint64_t> Position(std::istream& stream) {
stream.clear();
const auto position = stream.tellg();
if (position < 0) return std::nullopt;
return static_cast<uint64_t>(position);
}
std::string Stem(const std::string& path) {
const auto dot = path.find_last_of('.');
const auto slash = path.find_last_of('/');
if (dot == std::string::npos || (slash != std::string::npos && dot < slash)) return path;
return path.substr(0, dot);
}
// The .luz by itself: read to its end, in a version the server takes. false when its names can't be looked at.
bool ReadZoneFile(File& file, const std::string& data, ZoneFile& luz) {
std::istringstream stream(data);
try {
luz.Read(stream);
} catch (const std::exception&) {
Add(file, "parse_failed", "The zone file ends before everything in it was read", Position(stream));
return false;
}
file.version = static_cast<uint32_t>(luz.fileFormatVersion);
if (stream.fail()) {
Add(file, "parse_failed", "The zone file ends before everything in it was read", Position(stream));
return false;
}
if (luz.fileFormatVersion < ZoneFile::FileFormatVersion::PrePreAlpha || luz.fileFormatVersion > ZoneFile::FileFormatVersion::Latest) {
Add(file, "unsupported_version", "Zone file version " + std::to_string(file.version) + " is not one the server takes (30 to 41)");
return false;
}
if (luz.pathsRefused) Add(file, "parse_failed", "The zone file's paths can't be read");
if (stream.peek() != std::char_traits<char>::eof()) {
Add(file, "parse_failed", "The zone file has more in it after its last part", Position(stream));
}
return true;
}
/**
* The .luz: read to its end, a version the server reads, every scene one the zone has in the client files, and a
* terrain file in the zone's folder. Fills the names the rest of the set may have.
*/
void CheckZoneFile(File& file, const std::string& data, const Zone& zone, const std::string& folder,
std::set<std::string>& sceneFiles, std::string& terrain) {
ZoneFile luz;
if (!ReadZoneFile(file, data, luz)) return;
if (luz.scenes.empty()) Add(file, "scene_mismatch", "The zone file lists no scenes");
for (const auto& scene : luz.scenes) {
if (NameProblem(scene.filename) || !Canonical(scene.filename).ends_with(".lvl")) {
Add(file, "scene_mismatch", "Scene " + std::to_string(scene.id) + " names a file that is not a scene file in the zone's folder");
continue;
}
const auto path = folder + Canonical(scene.filename);
if (!zone.clientScenes.contains(path)) {
Add(file, "scene_mismatch", "Scene file " + path + " is not one of this zone's scenes");
continue;
}
sceneFiles.insert(path);
}
if (NameProblem(luz.zoneRawPath) || !Canonical(luz.zoneRawPath).ends_with(".raw")) {
Add(file, "terrain_mismatch", "The zone file names a terrain file that is not a .raw in the zone's folder");
} else {
terrain = folder + Canonical(luz.zoneRawPath);
}
}
void CheckLevelFile(File& file, const std::string& data, uint32_t& objects, uint32_t maxObjects) {
std::istringstream stream(data);
LevelFile level;
try {
level.Read(stream);
} catch (const std::exception&) {
file.version = level.version;
return Add(file, "parse_failed", "The scene file ends before everything in it was read", Position(stream));
}
file.version = level.version;
if (stream.fail()) return Add(file, "parse_failed", "The scene file ends before everything in it was read", Position(stream));
if (level.version > 48) return Add(file, "unsupported_version", "Scene file version " + std::to_string(level.version) + " is newer than the server reads");
for (const auto& object : level.objects) {
for (const auto& [key, value] : object.settings) {
if (!key.starts_with(u"FAILED_TO_PARSE_")) continue;
Add(file, "bad_object_config", "Object " + std::to_string(object.id) + " (LOT " + std::to_string(object.lot) + ") has a setting that can't be read");
break;
}
}
objects += static_cast<uint32_t>(std::min<size_t>(level.objects.size(), UINT32_MAX - objects));
if (objects > maxObjects) {
Add(file, "too_many_objects", "The set has more than " + std::to_string(maxObjects) + " objects in its scenes");
}
}
void CheckTerrainFile(File& file, const std::string& data) {
std::istringstream stream(data);
Raw::Raw raw;
const bool read = Raw::ReadRaw(stream, raw);
file.version = raw.version;
if (!read || stream.fail()) return Add(file, "parse_failed", "The terrain file can't be read to its end", Position(stream));
// A terrain file the game uses has its chunks in it, and nothing after them
if (raw.dev != 0 || raw.chunks.empty()) return Add(file, "parse_failed", "The terrain file has no terrain in it");
if (stream.peek() != std::char_traits<char>::eof()) Add(file, "parse_failed", "The terrain file has more in it after its last chunk", Position(stream));
}
}
namespace ZonePublish {
std::string FolderOf(std::string_view luzPath) {
const auto slash = luzPath.find_last_of('/');
return slash == std::string_view::npos ? std::string() : std::string(luzPath.substr(0, slash + 1));
}
std::string Canonical(std::string_view name) {
std::string out(name);
for (auto& c : out) {
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
}
return out;
}
std::optional<std::string> NameProblem(std::string_view name) {
if (name.empty()) return "The name is empty";
if (name.size() > MAX_NAME_LENGTH) return "The name is longer than " + std::to_string(MAX_NAME_LENGTH) + " characters";
if (name.find('\0') != std::string_view::npos) return "The name has a NUL in it";
if (name.find('\\') != std::string_view::npos) return "The name has a backslash in it; use forward slashes";
if (name.front() == '/') return "The name is an absolute path";
for (const char c : name) {
const auto byte = static_cast<unsigned char>(c);
if (byte < 0x20 || byte >= 0x7F) return "The name has a character in it that is not printable ASCII";
if (std::string_view(":*?\"<>|").find(c) != std::string_view::npos) return "The name has a character in it that file names can't have";
}
size_t start = 0;
while (start <= name.size()) {
const auto end = std::min(name.find('/', start), name.size());
const auto part = name.substr(start, end - start);
if (part.empty()) return "The name has an empty part";
if (part == "." || part == "..") return "The name has a \".\" or \"..\" part";
if (part.front() == ' ' || part.back() == ' ' || part.back() == '.') return "A part of the name starts or ends with a space, or ends with a dot";
start = end + 1;
}
return std::nullopt;
}
std::optional<std::filesystem::path> ResolveInside(const std::filesystem::path& root, const std::string& relative) {
if (root.empty() || NameProblem(relative)) return std::nullopt;
std::error_code code;
const auto base = std::filesystem::weakly_canonical(root, code);
if (code) return std::nullopt;
const auto target = std::filesystem::weakly_canonical(base / relative, code);
if (code) return std::nullopt;
const auto inside = target.lexically_relative(base);
if (inside.empty() || inside.native().starts_with("..") || inside.is_absolute()) return std::nullopt;
return target;
}
std::string Sha256(std::string_view bytes) {
std::array<unsigned char, EVP_MAX_MD_SIZE> digest{};
unsigned int size = 0;
EVP_Digest(bytes.data(), bytes.size(), digest.data(), &size, EVP_sha256(), nullptr);
return Hex(digest.data(), size);
}
std::optional<std::string> Sha256File(const std::filesystem::path& file) {
std::ifstream in(file, std::ios::binary);
if (!in) return std::nullopt;
auto* context = EVP_MD_CTX_new();
if (!context) return std::nullopt;
EVP_DigestInit_ex(context, EVP_sha256(), nullptr);
std::array<char, 65536> buffer{};
while (in) {
in.read(buffer.data(), static_cast<std::streamsize>(buffer.size()));
if (in.gcount() > 0) EVP_DigestUpdate(context, buffer.data(), static_cast<size_t>(in.gcount()));
}
std::array<unsigned char, EVP_MAX_MD_SIZE> digest{};
unsigned int size = 0;
EVP_DigestFinal_ex(context, digest.data(), &size);
EVP_MD_CTX_free(context);
if (in.bad()) return std::nullopt;
return Hex(digest.data(), size);
}
std::vector<Error> CheckTriggers(std::string_view xml) {
std::vector<Error> errors;
const auto add = [&errors](std::string code, std::string message) {
if (errors.size() < MAX_ERRORS_PER_FILE) errors.push_back({ std::move(code), std::move(message), std::nullopt });
};
// An empty file has no triggers (the world reads it as none)
if (xml.empty()) return errors;
tinyxml2::XMLDocument doc;
if (doc.Parse(xml.data(), xml.size()) != tinyxml2::XML_SUCCESS) {
add("parse_failed", "The triggers file is not well formed XML (line " + std::to_string(doc.ErrorLineNum()) + ")");
return errors;
}
const auto* triggers = doc.FirstChildElement("triggers");
if (!triggers) {
add("parse_failed", "The triggers file has no <triggers> element");
return errors;
}
for (const auto* trigger = triggers->FirstChildElement("trigger"); trigger; trigger = trigger->NextSiblingElement("trigger")) {
uint32_t id = 0;
if (trigger->QueryUnsignedAttribute("id", &id) != tinyxml2::XML_SUCCESS) add("parse_failed", "A trigger has no numeric id");
const auto where = "Trigger " + std::to_string(id);
for (const auto* event = trigger->FirstChildElement("event"); event; event = event->NextSiblingElement("event")) {
const char* eventId = event->Attribute("id");
if (!eventId || TriggerEventType::StringToTriggerEventType(eventId) == eTriggerEventType::INVALID) {
add("unknown_trigger_event", where + " has an event the server doesn't know: " + std::string(eventId ? eventId : "(no id)"));
}
for (const auto* command = event->FirstChildElement("command"); command; command = command->NextSiblingElement("command")) {
const char* commandId = command->Attribute("id");
if (!commandId || TriggerCommandType::StringToTriggerCommandType(commandId) == eTriggerCommandType::INVALID) {
add("unknown_trigger_command", where + " has a command the server doesn't know: " + std::string(commandId ? commandId : "(no id)"));
}
const char* target = command->Attribute("target");
if (!target || !*target) {
add("trigger_missing_target", where + " has a " + std::string(commandId ? commandId : "command") + " command without a target");
}
}
}
}
return errors;
}
std::vector<Error> CheckContents(std::string_view name, const std::string& data, uint32_t* version) {
File file;
uint32_t objects = 0;
const auto lower = Canonical(name);
if (lower.ends_with(".luz")) {
ZoneFile luz;
ReadZoneFile(file, data, luz);
} else if (lower.ends_with(".lvl")) CheckLevelFile(file, data, objects, UINT32_MAX);
else if (lower.ends_with(".raw")) CheckTerrainFile(file, data);
else if (lower.ends_with(".lutriggers")) file.errors = CheckTriggers(data);
if (version) *version = file.version;
return file.errors;
}
Result Validate(const Zone& zone, const std::vector<Upload>& uploads, const Limits& limits) {
Result result;
const auto luzPath = Canonical(zone.luzPath);
const auto folder = FolderOf(luzPath);
result.files.reserve(uploads.size() + 1);
// Names first: what each file is called, twice the same, and whether the .luz is there
std::map<std::string, size_t> byPath;
std::optional<size_t> luzIndex;
uint64_t total = 0;
for (size_t i = 0; i < uploads.size(); ++i) {
const auto& upload = uploads[i];
File file;
file.path = upload.name;
file.bytes = upload.bytes;
file.source = upload.file;
total += upload.bytes;
if (i >= limits.maxFiles) {
Add(file, "too_large", "The set has more than " + std::to_string(limits.maxFiles) + " files");
} else if (const auto problem = NameProblem(upload.name)) {
Add(file, "name_not_allowed", *problem);
} else {
file.path = Canonical(upload.name);
if (!file.path.starts_with(folder) || file.path.size() == folder.size()) {
Add(file, "name_not_allowed", "The file is not in the zone's folder (" + folder + ")");
} else if (!byPath.try_emplace(file.path, i).second) {
Add(file, "duplicate", "The set has this file more than once");
} else if (file.path == luzPath) {
luzIndex = i;
}
if (upload.bytes > limits.maxFileBytes) {
Add(file, "too_large", "The file is larger than " + std::to_string(limits.maxFileBytes) + " bytes");
} else if (file.ok && total > limits.maxTotalBytes) {
Add(file, "too_large", "The set is larger than " + std::to_string(limits.maxTotalBytes) + " bytes");
}
}
result.files.push_back(std::move(file));
}
// The .luz says which other names the set may have
std::set<std::string> sceneFiles;
std::string terrain;
bool luzRead = false;
if (!luzIndex) {
File missing;
missing.path = luzPath;
Add(missing, "missing_luz", "The set has no " + luzPath + "; every set has the zone's .luz");
result.files.push_back(std::move(missing));
} else if (auto& file = result.files[*luzIndex]; file.ok) {
const auto data = ReadWhole(file.source, limits.maxFileBytes);
if (!data) {
Add(file, "parse_failed", "The file could not be read");
} else {
file.sha256 = Sha256(*data);
CheckZoneFile(file, *data, zone, folder, sceneFiles, terrain);
luzRead = file.ok;
}
}
std::set<std::string> allowed{ luzPath };
if (!terrain.empty()) allowed.insert(terrain);
for (const auto& scene : sceneFiles) {
allowed.insert(scene);
allowed.insert(Stem(scene) + ".lutriggers");
}
for (size_t i = 0; i < result.files.size(); ++i) {
auto& file = result.files[i];
if (!file.ok || (luzIndex && i == *luzIndex) || file.source.empty()) continue;
if (!luzRead) {
// Without a good .luz nothing says which files belong; only the plainly wrong ones are named
const bool known = file.path.ends_with(".lvl") || file.path.ends_with(".lutriggers") || file.path.ends_with(".raw");
if (!known) Add(file, "name_not_allowed", "Only the zone's .luz, its scenes' .lvl and .lutriggers files and its .raw can be sent");
continue;
}
if (!allowed.contains(file.path)) {
if (file.path.ends_with(".raw")) Add(file, "terrain_mismatch", "The zone file names another terrain file (" + terrain + ")");
else Add(file, "name_not_allowed", "The zone file doesn't list this file: only its scenes' .lvl and .lutriggers files and its .raw can be sent");
continue;
}
const auto data = ReadWhole(file.source, limits.maxFileBytes);
if (!data) {
Add(file, "parse_failed", "The file could not be read");
continue;
}
file.sha256 = Sha256(*data);
if (file.path.ends_with(".lvl")) CheckLevelFile(file, *data, result.objects, limits.maxObjects);
else if (file.path.ends_with(".lutriggers")) {
for (auto& error : CheckTriggers(*data)) Add(file, std::move(error.code), std::move(error.message), error.offset);
} else CheckTerrainFile(file, *data);
}
// What the .luz names and the set leaves out has to be there already
if (luzRead) {
auto& luz = result.files[*luzIndex];
const auto there = [&](const std::string& path) { return byPath.contains(path) || (zone.hasFile && zone.hasFile(path)); };
for (const auto& scene : sceneFiles) {
if (!there(scene)) Add(luz, "scene_mismatch", "Scene file " + scene + " is not in the set and the zone doesn't have it");
}
if (!terrain.empty() && !there(terrain)) Add(luz, "terrain_mismatch", "Terrain file " + terrain + " is not in the set and the zone doesn't have it");
}
for (const auto& file : result.files) result.ok = result.ok && file.ok;
return result;
}
}

108
dCommon/ZonePublish.h Normal file
View File

@@ -0,0 +1,108 @@
#pragma once
#include <cstdint>
#include <filesystem>
#include <functional>
#include <optional>
#include <set>
#include <string>
#include <string_view>
#include <vector>
/**
* Publishing edited zone files (docs/ZonePublish.md): checking a set of files an external level editor sent for one
* zone before any of it is kept. Nothing here knows how the files arrived (the dashboard's API, or any other way in)
* or who sent them; it reads the files it is given and says, file by file, what is wrong with them. Applying a set
* that passed is ZoneOverrideStore's, who may send one is ZoneEditLock's.
*
* Reads files and nothing else (no settings, no database, no logging of its own), so it can run on a worker thread.
*/
namespace ZonePublish {
constexpr size_t MAX_NAME_LENGTH = 200;
// Errors listed per file; a file with more is cut off there
constexpr size_t MAX_ERRORS_PER_FILE = 20;
struct Limits {
uint64_t maxFileBytes = 64ULL * 1024 * 1024;
uint64_t maxTotalBytes = 256ULL * 1024 * 1024;
uint32_t maxFiles = 128;
uint32_t maxObjects = 100000; // in every scene of the set together
};
// The zone a set is for, as the server knows it
struct Zone {
uint32_t zoneId{};
// The zone's .luz from the ZoneTable, relative to res/maps/, lowercase with forward slashes
std::string luzPath;
// The scene files the zone has in the server's client files, as luzPath is written; a set can't add scenes
std::set<std::string> clientScenes;
// Whether a file the set leaves out is there for the zone already (an earlier set's, or the client's)
std::function<bool(const std::string& path)> hasFile;
};
// One file of a set: its name as sent, and where its bytes are
struct Upload {
std::string name;
std::filesystem::path file;
uint64_t bytes{};
};
struct Error {
std::string code;
std::string message;
std::optional<uint64_t> offset;
};
struct File {
std::string path; // as sent when it was refused by name, else relative to res/maps/ as it is kept
bool ok = true;
std::vector<Error> errors;
uint64_t bytes{};
std::string sha256;
uint32_t version{}; // the file's own format version (0 for triggers)
std::filesystem::path source; // the upload it came from
};
struct Result {
bool ok = true;
std::vector<File> files; // every file sent, in the order sent; then a missing .luz
uint32_t objects{};
};
// A zone's folder: its .luz path up to and including the last '/'
std::string FolderOf(std::string_view luzPath);
// Lowercase with forward slashes kept: how a path is compared and kept. ASCII only (other bytes never pass NameProblem).
std::string Canonical(std::string_view name);
/**
* What is wrong with a file name as sent, by its spelling alone; nullopt when nothing is. A name is a path relative
* to res/maps/: no empty, "." or ".." part, no leading '/', no backslash, drive or NUL, printable ASCII only, and
* no part that starts or ends with a space or ends with a dot.
*/
std::optional<std::string> NameProblem(std::string_view name);
/**
* The path a file of this zone is written to under root, when that path is inside root once every link and
* ".." on the way is resolved (std::filesystem::weakly_canonical). nullopt otherwise.
*/
std::optional<std::filesystem::path> ResolveInside(const std::filesystem::path& root, const std::string& relative);
/**
* Checks a whole set. Every file is read with the server's own readers; a set is good only when every file is
* (Result::ok), and nothing is written here either way.
*/
Result Validate(const Zone& zone, const std::vector<Upload>& uploads, const Limits& limits);
// What a .lutriggers file has wrong with it (parse_failed, unknown_trigger_event, unknown_trigger_command,
// trigger_missing_target)
std::vector<Error> CheckTriggers(std::string_view xml);
// One file's own checks, picked by what its name ends in (.luz, .lvl, .lutriggers, .raw): whether it reads to its
// end in a version the server takes. What a .luz names is Validate's to check.
std::vector<Error> CheckContents(std::string_view name, const std::string& data, uint32_t* version = nullptr);
// SHA-256 of a file as lowercase hex; nullopt when it can't be read
std::optional<std::string> Sha256File(const std::filesystem::path& file);
std::string Sha256(std::string_view bytes);
}

View File

@@ -42,6 +42,7 @@ set(DCOMMONTEST_SOURCES
"WorldFileWatchTests.cpp"
"ChatFilterCoreTests.cpp"
"BrickBoundsTests.cpp"
"ZonePublishTests.cpp"
)
add_subdirectory(dEnumsTests)

View File

@@ -17,6 +17,7 @@
#include "Raw.h"
#include "Sd0.h"
#include "ZoneFile.h"
#include "ZonePublish.h"
/**
* Reads every zone (.luz), scene (.lvl) and terrain (.raw) file of the clients on disk: each must read in full, as it
@@ -158,3 +159,29 @@ TEST_F(ClientZoneFilesTests, EverySceneFileReads) {
EXPECT_TRUE(!threw || IsDamaged(path)) << path;
}
}
// The checks a set of edited zone files goes through (ZonePublish) must take the files the game itself ships:
// nothing the clients have reads as damaged, apart from the files that are
TEST_F(ClientZoneFilesTests, PublishChecksTakeEveryClientFile) {
std::set<std::tuple<std::string, uintmax_t>> seen;
size_t checked = 0;
for (const auto* extension : { ".luz", ".lvl", ".raw", ".lutriggers" }) {
for (const auto& path : FilesWithExtension(extension)) {
std::error_code error;
auto name = path.filename().string();
std::ranges::transform(name, name.begin(), ::tolower);
if (!seen.insert({ name, std::filesystem::file_size(path, error) }).second || IsDamaged(path)) continue;
checked++;
uint32_t version = 0;
for (const auto& problem : ZonePublish::CheckContents(name, Contents(path), &version)) {
// What must never happen is a file the game ships reading as damaged. Triggers and object settings
// the server doesn't know are in shipped files (a misspelt command in one live zone, more in test
// maps), and so are test maps' zone files from before launch with bytes after their last part.
if (problem.code != "parse_failed") continue;
if (name.ends_with(".luz") && version < static_cast<uint32_t>(ZoneFile::FileFormatVersion::Launch)) continue;
ADD_FAILURE() << path << ": " << problem.code << ": " << problem.message << (problem.offset ? " at " + std::to_string(*problem.offset) : "");
}
}
}
if (checked == 0) GTEST_SKIP() << "No client zone files under " << ClientsDir();
}

View File

@@ -0,0 +1,548 @@
#include <gtest/gtest.h>
#include <cstring>
#include <filesystem>
#include <fstream>
#include "AssetManager.h"
#include "dCommonDependencies.h"
#include "LevelFile.h"
#include "ZoneEditLock.h"
#include "ZoneOverrideStore.h"
#include "ZonePublish.h"
namespace fs = std::filesystem;
namespace {
struct Bytes {
std::string data;
template<typename T> Bytes& Put(T value) { data.append(reinterpret_cast<const char*>(&value), sizeof(T)); return *this; }
template<typename T> void At(size_t pos, T value) { std::memcpy(data.data() + pos, &value, sizeof(T)); }
Bytes& Text(const std::string& text) { Put<uint8_t>(static_cast<uint8_t>(text.size())); data += text; return *this; }
Bytes& Point(float x, float y, float z) { return Put(x).Put(y).Put(z); }
};
const std::string FOLDER = "01_live_maps/test_zone/";
const std::string LUZ = FOLDER + "test_zone.luz";
// A version 41 zone file with these scenes and terrain file
std::string ZoneBytes(const std::vector<std::string>& scenes, const std::string& terrain = "test_zone.raw") {
Bytes w;
w.Put<uint32_t>(41).Put<uint32_t>(3).Put<uint32_t>(1900);
w.Point(1, 2, 3).Put(1.0f).Put(0.0f).Put(0.0f).Put(0.0f);
w.Put<uint32_t>(static_cast<uint32_t>(scenes.size()));
uint32_t id = 0;
for (const auto& scene : scenes) w.Text(scene).Put<uint32_t>(id++).Put<uint32_t>(0).Text("Scene").Put<uint8_t>(1).Put<uint8_t>(2).Put<uint8_t>(3);
w.Put<uint8_t>(0).Text(terrain).Text("Name").Text("Description");
w.Put<uint32_t>(0); // scene transitions
w.Put<uint32_t>(8).Put<uint32_t>(1).Put<uint32_t>(0); // the path chunk: version 1, no paths
return w.data;
}
// A scene file from before chunks (version 30) with these objects' settings
std::string LevelBytes(const std::vector<std::string>& objects) {
Bytes w;
w.Put<uint16_t>(30).Put<uint16_t>(0);
w.data.append(48 + 12, '\0');
w.Put<uint32_t>(0).Put<uint32_t>(static_cast<uint32_t>(objects.size()));
int32_t lot = 1;
for (const auto& settings : objects) {
w.Put<int64_t>(lot * 10).Put<int32_t>(lot);
lot++;
w.Point(0, 0, 0).Put(1.0f).Put(0.0f).Put(0.0f).Put(0.0f).Put(1.0f);
w.Put<uint32_t>(static_cast<uint32_t>(settings.size()));
for (char c : settings) w.Put<uint16_t>(static_cast<uint16_t>(c));
w.Put<uint32_t>(0);
}
return w.data;
}
const char* GOOD_TRIGGERS = "<triggers nextID=\"2\"><trigger id=\"1\" enabled=\"1\">"
"<event id=\"OnEnter\"><command id=\"fireEvent\" target=\"self\" args=\"go\"/></event></trigger></triggers>";
// The readers log what they refuse
struct Fixture : dCommonDependenciesTest {
fs::path dir;
fs::path root;
ZonePublish::Zone zone;
ZonePublish::Limits limits;
std::vector<ZonePublish::Upload> uploads;
uint32_t counter = 0;
void SetUp() override {
SetUpDependencies();
dir = fs::temp_directory_path() / ("dlu-zone-publish-" + std::to_string(::testing::UnitTest::GetInstance()->random_seed()) + "-" +
::testing::UnitTest::GetInstance()->current_test_info()->name());
fs::remove_all(dir);
fs::create_directories(dir / "in");
root = dir / "override";
zone.zoneId = 1900;
zone.luzPath = LUZ;
zone.clientScenes = { FOLDER + "a.lvl", FOLDER + "b.lvl" };
zone.hasFile = [](const std::string&) { return true; };
}
void TearDown() override {
fs::remove_all(dir);
TearDownDependencies();
Game::logger = nullptr;
}
void Add(const std::string& name, const std::string& data) {
const auto file = dir / "in" / std::to_string(counter++);
std::ofstream(file, std::ios::binary) << data;
uploads.push_back({ name, file, data.size() });
}
void AddGoodSet() {
Add(LUZ, ZoneBytes({ "a.lvl", "b.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ "a=0:b" }));
Add(FOLDER + "a.lutriggers", GOOD_TRIGGERS);
}
ZonePublish::Result Run() { return ZonePublish::Validate(zone, uploads, limits); }
static bool Has(const ZonePublish::Result& result, const std::string& path, const std::string& code) {
for (const auto& file : result.files) {
if (file.path != path) continue;
for (const auto& error : file.errors) if (error.code == code) return true;
}
return false;
}
// Apply what Validate passed, as a transport would
ZoneOverrideStore::Applied Apply(const ZonePublish::Result& result, const std::string& id, int64_t now, uint32_t keep = 20, const std::string& note = "") {
std::vector<ZoneOverrideStore::NewFile> files;
for (const auto& file : result.files) files.push_back({ file.path, file.source, file.bytes, file.sha256, file.version });
return ZoneOverrideStore::Apply(root, { zone.zoneId, FOLDER, id, "editor", note, now, keep }, files);
}
static std::string Slurp(const fs::path& path) {
std::ifstream in(path, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(in), {});
}
// Every file under the override folder's maps/, for "nothing was written"
std::vector<std::string> Written() const {
std::vector<std::string> files;
std::error_code code;
for (fs::recursive_directory_iterator it(root, code), end; !code && it != end; it.increment(code)) {
if (it->is_regular_file()) files.push_back(it->path().lexically_relative(root).generic_string());
}
return files;
}
};
using ZonePublishTests = Fixture;
using ZoneOverrideStoreTests = Fixture;
}
TEST(ZonePublishNameTests, RefusesTraversalAbsoluteBackslashAndNul) {
EXPECT_FALSE(ZonePublish::NameProblem("01_live_maps/test_zone/a.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem(""));
EXPECT_TRUE(ZonePublish::NameProblem("../a.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("01_live_maps/../a.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("01_live_maps/test_zone/.."));
EXPECT_TRUE(ZonePublish::NameProblem("./a.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("/etc/passwd"));
EXPECT_TRUE(ZonePublish::NameProblem("C:/windows/a.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("01_live_maps\\test_zone\\a.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem(std::string("a.lvl\0.luz", 10)));
EXPECT_TRUE(ZonePublish::NameProblem("a//b.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("a/b.lvl/"));
EXPECT_TRUE(ZonePublish::NameProblem("a/b.lvl."));
EXPECT_TRUE(ZonePublish::NameProblem("a/ b.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("a/b\n.lvl"));
EXPECT_TRUE(ZonePublish::NameProblem("a/b\xC3\xA9.lvl")); // no bytes outside ASCII, so no look-alike letters
EXPECT_TRUE(ZonePublish::NameProblem(std::string(ZonePublish::MAX_NAME_LENGTH + 1, 'a')));
EXPECT_EQ(ZonePublish::Canonical("01_Live_Maps/Test_Zone/A.LVL"), "01_live_maps/test_zone/a.lvl");
}
TEST_F(ZonePublishTests, ResolveInsideFollowsLinksOutOfTheRoot) {
fs::create_directories(root / "maps");
fs::create_directories(dir / "elsewhere");
EXPECT_TRUE(ZonePublish::ResolveInside(root / "maps", "zone/a.lvl"));
EXPECT_FALSE(ZonePublish::ResolveInside(root / "maps", "../a.lvl"));
EXPECT_FALSE(ZonePublish::ResolveInside(root / "maps", "/a.lvl"));
// A link inside the root that points out of it is followed before the check
std::error_code code;
fs::create_directory_symlink(dir / "elsewhere", root / "maps" / "link", code);
if (!code) EXPECT_FALSE(ZonePublish::ResolveInside(root / "maps", "link/a.lvl"));
}
TEST_F(ZonePublishTests, GoodSetPasses) {
AddGoodSet();
const auto result = Run();
ASSERT_TRUE(result.ok);
ASSERT_EQ(result.files.size(), 3u);
EXPECT_EQ(result.files[0].path, LUZ);
EXPECT_EQ(result.files[0].version, 41u);
EXPECT_EQ(result.files[1].version, 30u);
EXPECT_EQ(result.files[0].sha256.size(), 64u);
EXPECT_EQ(result.files[0].sha256, ZonePublish::Sha256(ZoneBytes({ "a.lvl", "b.lvl" })));
EXPECT_EQ(result.objects, 1u);
}
TEST(ZonePublishHashTests, Sha256MatchesKnownValue) {
EXPECT_EQ(ZonePublish::Sha256("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
}
TEST_F(ZonePublishTests, NamesAreCheckedBeforeAnythingIsRead) {
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add("../../etc/passwd", "x");
Add("/etc/passwd", "x");
Add("01_live_maps\\test_zone\\a.lvl", LevelBytes({}));
Add(std::string("01_live_maps/test_zone/a.lvl\0x", 30), LevelBytes({}));
Add("01_live_maps/other_zone/a.lvl", LevelBytes({})); // another zone's folder
Add(FOLDER + "c.lvl", LevelBytes({})); // not a scene the .luz lists
Add(FOLDER + "notes.txt", "x");
const auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(result.files[0].ok);
for (size_t i = 1; i < result.files.size(); ++i) {
ASSERT_FALSE(result.files[i].ok) << result.files[i].path;
EXPECT_EQ(result.files[i].errors[0].code, "name_not_allowed") << result.files[i].path;
}
}
TEST_F(ZonePublishTests, CaseTricksAreDuplicates) {
AddGoodSet();
Add("01_Live_Maps/Test_Zone/A.LVL", LevelBytes({}));
const auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_EQ(result.files[3].errors[0].code, "duplicate");
}
TEST_F(ZonePublishTests, MissingLuz) {
Add(FOLDER + "a.lvl", LevelBytes({}));
const auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, LUZ, "missing_luz"));
}
TEST_F(ZonePublishTests, UnknownSceneIsASceneMismatch) {
// The .luz lists a scene the zone doesn't have in the client's files
Add(LUZ, ZoneBytes({ "a.lvl", "new_scene.lvl" }));
Add(FOLDER + "new_scene.lvl", LevelBytes({}));
const auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, LUZ, "scene_mismatch"));
}
TEST_F(ZonePublishTests, SceneNamedOutsideTheFolderIsASceneMismatch) {
Add(LUZ, ZoneBytes({ "../other_zone/a.lvl" }));
const auto result = Run();
EXPECT_TRUE(Has(result, LUZ, "scene_mismatch"));
}
TEST_F(ZonePublishTests, SceneTheZoneLacksIsASceneMismatch) {
// Listed, a scene of the zone, but neither in the set nor on the server
zone.hasFile = [](const std::string& path) { return !path.ends_with("b.lvl"); };
AddGoodSet();
const auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, LUZ, "scene_mismatch"));
}
TEST_F(ZonePublishTests, TerrainMismatch) {
Add(LUZ, ZoneBytes({ "a.lvl" }, "test_zone.raw"));
Add(FOLDER + "other.raw", "x");
auto result = Run();
EXPECT_TRUE(Has(result, FOLDER + "other.raw", "terrain_mismatch"));
uploads.clear();
Add(LUZ, ZoneBytes({ "a.lvl" }, "../elsewhere.raw"));
result = Run();
EXPECT_TRUE(Has(result, LUZ, "terrain_mismatch"));
uploads.clear();
zone.hasFile = [](const std::string& path) { return !path.ends_with(".raw"); };
Add(LUZ, ZoneBytes({ "a.lvl" }));
result = Run();
EXPECT_TRUE(Has(result, LUZ, "terrain_mismatch"));
}
TEST_F(ZonePublishTests, TruncatedFilesFailToParse) {
auto luz = ZoneBytes({ "a.lvl" });
Add(LUZ, luz.substr(0, luz.size() - 3));
auto result = Run();
EXPECT_TRUE(Has(result, LUZ, "parse_failed"));
uploads.clear();
auto level = LevelBytes({ "a=0:b", "c=0:d" });
Add(LUZ, luz);
Add(FOLDER + "a.lvl", level.substr(0, level.size() - 2));
result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, FOLDER + "a.lvl", "parse_failed"));
EXPECT_TRUE(result.files[1].errors[0].offset.has_value());
uploads.clear();
Add(LUZ, luz + "extra");
result = Run();
EXPECT_TRUE(Has(result, LUZ, "parse_failed"));
uploads.clear();
Add(LUZ, luz);
Add(FOLDER + "test_zone.raw", "not a terrain file");
result = Run();
EXPECT_TRUE(Has(result, FOLDER + "test_zone.raw", "parse_failed"));
}
TEST_F(ZonePublishTests, UnsupportedVersions) {
auto luz = ZoneBytes({ "a.lvl" });
const uint32_t version = 99;
std::memcpy(luz.data(), &version, 4);
Add(LUZ, luz);
auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, LUZ, "unsupported_version") || Has(result, LUZ, "parse_failed"));
uploads.clear();
Bytes level;
level.Put<uint16_t>(60).Put<uint16_t>(0).Put<uint8_t>(0).Put<uint32_t>(1);
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add(FOLDER + "a.lvl", level.data);
result = Run();
EXPECT_TRUE(Has(result, FOLDER + "a.lvl", "unsupported_version"));
}
TEST_F(ZonePublishTests, TriggerChecks) {
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add(FOLDER + "a.lutriggers", "<triggers><trigger id=\"1\" enabled=\"1\">"
"<event id=\"OnEnter\"><command id=\"fireEvent\" args=\"go\"/></event>"
"<event id=\"OnMadeUp\"><command id=\"madeUpCommand\" target=\"self\"/></event></trigger></triggers>");
auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, FOLDER + "a.lutriggers", "trigger_missing_target"));
EXPECT_TRUE(Has(result, FOLDER + "a.lutriggers", "unknown_trigger_event"));
EXPECT_TRUE(Has(result, FOLDER + "a.lutriggers", "unknown_trigger_command"));
EXPECT_EQ(ZonePublish::CheckTriggers("<triggers><trigger id=\"1\"").at(0).code, "parse_failed");
EXPECT_EQ(ZonePublish::CheckTriggers("<other/>").at(0).code, "parse_failed");
EXPECT_TRUE(ZonePublish::CheckTriggers("").empty());
EXPECT_TRUE(ZonePublish::CheckTriggers(GOOD_TRIGGERS).empty());
}
TEST_F(ZonePublishTests, BadObjectConfigAndObjectCap) {
Add(LUZ, ZoneBytes({ "a.lvl", "b.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ "good=0:yes", "broken=:" }));
auto result = Run();
EXPECT_TRUE(Has(result, FOLDER + "a.lvl", "bad_object_config"));
uploads.clear();
limits.maxObjects = 3;
Add(LUZ, ZoneBytes({ "a.lvl", "b.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ "a=0:b", "a=0:b" }));
Add(FOLDER + "b.lvl", LevelBytes({ "a=0:b", "a=0:b" }));
result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(Has(result, FOLDER + "b.lvl", "too_many_objects"));
}
TEST_F(ZonePublishTests, SizeLimits) {
limits.maxFileBytes = 64;
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ std::string(200, 'x') + "=0:b" }));
auto result = Run();
EXPECT_TRUE(Has(result, FOLDER + "a.lvl", "too_large"));
limits = {};
limits.maxFiles = 1;
result = Run();
EXPECT_TRUE(Has(result, FOLDER + "a.lvl", "too_large"));
limits = {};
limits.maxTotalBytes = uploads[0].bytes + 1;
result = Run();
EXPECT_TRUE(Has(result, FOLDER + "a.lvl", "too_large"));
}
// One bad file refuses the whole set, and nothing of it is written
TEST_F(ZonePublishTests, AllOrNothing) {
AddGoodSet();
Add(FOLDER + "b.lvl", "garbage");
const auto result = Run();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(result.files[0].ok);
EXPECT_TRUE(result.files[1].ok);
EXPECT_FALSE(result.files[3].ok);
EXPECT_TRUE(Written().empty());
EXPECT_TRUE(ZoneOverrideStore::Current(root, zone.zoneId, FOLDER).empty());
}
TEST_F(ZoneOverrideStoreTests, ApplyPutsTheSetWhereTheAssetManagerReads) {
AddGoodSet();
const auto level = Slurp(uploads[1].file);
const auto applied = Apply(Run(), "p1", 1000);
EXPECT_FALSE(applied.backupId);
ASSERT_EQ(applied.files.size(), 3u);
EXPECT_EQ(Slurp(root / "maps" / FOLDER / "a.lvl"), level);
EXPECT_FALSE(fs::exists(root / "staging" / "p1"));
const auto current = ZoneOverrideStore::Current(root, zone.zoneId, FOLDER);
ASSERT_EQ(current.size(), 3u);
EXPECT_EQ(current[1].path, FOLDER + "a.lvl");
EXPECT_EQ(current[1].bytes, level.size());
EXPECT_EQ(current[1].sha256, ZonePublish::Sha256(level));
EXPECT_EQ(current[1].who, "editor");
EXPECT_EQ(current[1].when, 1000);
// The asset manager serves it for a name under maps/, whatever the name's case, and nothing else
const auto name = AssetManager::OverrideName("maps\\01_Live_Maps\\Test_Zone\\A.LVL");
ASSERT_TRUE(name);
EXPECT_EQ(Slurp(root / *name), level);
EXPECT_FALSE(AssetManager::OverrideName("macros/a.scm"));
EXPECT_FALSE(AssetManager::OverrideName("maps/../cdclient.fdb"));
EXPECT_FALSE(AssetManager::OverrideName("maps//a.lvl"));
}
TEST_F(ZoneOverrideStoreTests, ASecondSetReplacesTheWholeFirstAndBacksItUp) {
AddGoodSet();
Apply(Run(), "p1", 1000, 20, "first");
uploads.clear();
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ "second=0:set" }));
const auto second = Slurp(uploads[1].file);
const auto applied = Apply(Run(), "p2", 2000, 20, "second");
ASSERT_TRUE(applied.backupId);
EXPECT_EQ(*applied.backupId, "2000-p2");
EXPECT_EQ(Slurp(root / "maps" / FOLDER / "a.lvl"), second);
// The first set's triggers are not part of the second: gone from what is served
EXPECT_FALSE(fs::exists(root / "maps" / FOLDER / "a.lutriggers"));
const auto backups = ZoneOverrideStore::Backups(root, zone.zoneId);
ASSERT_EQ(backups.size(), 1u);
EXPECT_EQ(backups[0].id, "2000-p2");
EXPECT_EQ(backups[0].time, 2000);
EXPECT_EQ(backups[0].who, "editor");
EXPECT_EQ(backups[0].note, "first");
EXPECT_EQ(backups[0].files.size(), 3u);
}
TEST_F(ZoneOverrideStoreTests, RestoreAndClearAreOneCallEach) {
AddGoodSet();
const auto first = Slurp(uploads[1].file);
Apply(Run(), "p1", 1000);
uploads.clear();
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ "second=0:set" }));
const auto backupId = *Apply(Run(), "p2", 2000).backupId;
const auto restored = ZoneOverrideStore::Restore(root, { zone.zoneId, FOLDER, "r1", "admin", "", 3000, 20 }, backupId);
EXPECT_EQ(restored.files.size(), 3u);
EXPECT_EQ(Slurp(root / "maps" / FOLDER / "a.lvl"), first);
EXPECT_TRUE(fs::exists(root / "maps" / FOLDER / "a.lutriggers"));
// What the restore replaced is itself kept
ASSERT_TRUE(restored.backupId);
EXPECT_EQ(ZoneOverrideStore::Backups(root, zone.zoneId).size(), 2u);
EXPECT_THROW(ZoneOverrideStore::Restore(root, { zone.zoneId, FOLDER, "r2", "admin", "", 3500, 20 }, "9999-nothere"), ZoneOverrideStore::StoreError);
EXPECT_THROW(ZoneOverrideStore::Restore(root, { zone.zoneId, FOLDER, "r3", "admin", "", 3500, 20 }, "../../maps"), ZoneOverrideStore::StoreError);
EXPECT_EQ(Slurp(root / "maps" / FOLDER / "a.lvl"), first);
const auto cleared = ZoneOverrideStore::Clear(root, { zone.zoneId, FOLDER, "c1", "admin", "", 4000, 20 });
EXPECT_TRUE(cleared.files.empty());
EXPECT_TRUE(cleared.backupId);
EXPECT_FALSE(fs::exists(root / "maps" / FOLDER / "a.lvl"));
EXPECT_TRUE(ZoneOverrideStore::Current(root, zone.zoneId, FOLDER).empty());
EXPECT_EQ(ZoneOverrideStore::Backups(root, zone.zoneId).size(), 3u);
}
TEST_F(ZoneOverrideStoreTests, RestoreRefusesABackupThatWasChanged) {
AddGoodSet();
Apply(Run(), "p1", 1000);
const auto backupId = *ZoneOverrideStore::Clear(root, { zone.zoneId, FOLDER, "c1", "admin", "", 2000, 20 }).backupId;
const auto kept = root / "backups" / "1900" / backupId / "files" / FOLDER / "a.lvl";
fs::remove(kept);
std::ofstream(kept, std::ios::binary) << "changed";
EXPECT_THROW(ZoneOverrideStore::Restore(root, { zone.zoneId, FOLDER, "r1", "admin", "", 3000, 20 }, backupId), ZoneOverrideStore::StoreError);
EXPECT_TRUE(ZoneOverrideStore::Current(root, zone.zoneId, FOLDER).empty());
}
TEST_F(ZoneOverrideStoreTests, KeepsTheNewestBackups) {
for (int i = 0; i < 6; ++i) {
uploads.clear();
Add(LUZ, ZoneBytes({ "a.lvl" }));
Add(FOLDER + "a.lvl", LevelBytes({ "set=1:" + std::to_string(i) }));
Apply(Run(), "p" + std::to_string(i), 1000 + i, 3);
}
const auto backups = ZoneOverrideStore::Backups(root, zone.zoneId);
ASSERT_EQ(backups.size(), 3u);
EXPECT_EQ(backups[0].id, "1005-p5");
EXPECT_EQ(backups[2].id, "1003-p3");
}
TEST_F(ZoneOverrideStoreTests, AnotherZoneInTheSameFolderKeepsItsSet) {
AddGoodSet();
Apply(Run(), "p1", 1000);
// Zone 1901 shares the folder
const auto other = FOLDER + "other_zone.luz";
const auto file = dir / "in" / "other";
std::ofstream(file, std::ios::binary) << "other zone";
ZoneOverrideStore::Apply(root, { 1901, FOLDER, "o1", "editor", "", 1500, 20 }, { { other, file, 10, ZonePublish::Sha256("other zone"), 41 } });
EXPECT_EQ(ZoneOverrideStore::Current(root, 1900, FOLDER).size(), 3u);
ZoneOverrideStore::Clear(root, { 1900, FOLDER, "c1", "admin", "", 2000, 20 });
EXPECT_EQ(Slurp(root / "maps" / other), "other zone");
EXPECT_EQ(ZoneOverrideStore::Current(root, 1901, FOLDER).size(), 1u);
EXPECT_FALSE(fs::exists(root / "maps" / FOLDER / "a.lvl"));
}
TEST_F(ZoneOverrideStoreTests, RefusesNamesOutsideTheZoneFolderAndBadIds) {
const auto file = dir / "in" / "x";
std::ofstream(file) << "x";
const ZoneOverrideStore::Change change{ zone.zoneId, FOLDER, "p1", "editor", "", 1000, 20 };
EXPECT_THROW(ZoneOverrideStore::Apply(root, change, { { "01_live_maps/other_zone/a.lvl", file, 1, "", 0 } }), ZoneOverrideStore::StoreError);
EXPECT_THROW(ZoneOverrideStore::Apply(root, change, { { FOLDER + "../../../escape", file, 1, "", 0 } }), ZoneOverrideStore::StoreError);
EXPECT_THROW(ZoneOverrideStore::Apply(root, change, { { FOLDER + "A.lvl", file, 1, "", 0 } }), ZoneOverrideStore::StoreError);
auto bad = change;
bad.id = "../x";
EXPECT_THROW(ZoneOverrideStore::Apply(root, bad, { { FOLDER + "a.lvl", file, 1, "", 0 } }), ZoneOverrideStore::StoreError);
bad = change;
bad.zoneFolder = "../";
EXPECT_THROW(ZoneOverrideStore::Apply(root, bad, { { "../a.lvl", file, 1, "", 0 } }), ZoneOverrideStore::StoreError);
EXPECT_FALSE(fs::exists(dir / "escape"));
EXPECT_FALSE(ZoneOverrideStore::ValidId(""));
EXPECT_FALSE(ZoneOverrideStore::ValidId("a/b"));
EXPECT_FALSE(ZoneOverrideStore::ValidId("A"));
EXPECT_TRUE(ZoneOverrideStore::ValidId("1000-ab12"));
}
TEST(ZoneEditLockTests, TakeReleaseAndChange) {
using namespace ZoneEditLock;
const Levels levels{ 6, 9 };
const Actor editor{ 10, 6, true }, other{ 11, 6, true }, stranger{ 12, 6, false }, low{ 13, 5, true }, admin{ 14, 9, false };
// Taking: an assigned editor at the level, or an admin
EXPECT_EQ(Take(editor, std::nullopt, levels), eResult::OK);
EXPECT_EQ(Take(admin, std::nullopt, levels), eResult::OK);
EXPECT_EQ(Take(stranger, std::nullopt, levels), eResult::NOT_ASSIGNED);
EXPECT_EQ(Take(low, std::nullopt, levels), eResult::LEVEL_TOO_LOW);
EXPECT_EQ(Take(other, 10u, levels), eResult::LOCKED_BY_OTHER);
EXPECT_EQ(Take(admin, 10u, levels), eResult::LOCKED_BY_OTHER); // an admin releases it first
EXPECT_EQ(Take(editor, 10u, levels), eResult::NO_CHANGE);
EXPECT_EQ(Take(Actor{ 0, 9, true }, std::nullopt, levels), eResult::LEVEL_TOO_LOW);
// Releasing: the holder, or an admin forcing it
EXPECT_EQ(Release(editor, 10u, levels), eResult::OK);
EXPECT_EQ(Release(other, 10u, levels), eResult::LOCKED_BY_OTHER);
EXPECT_EQ(Release(admin, 10u, levels), eResult::OK);
EXPECT_EQ(Release(editor, std::nullopt, levels), eResult::NO_CHANGE);
EXPECT_EQ(Release(low, 13u, levels), eResult::LEVEL_TOO_LOW);
// Changing the files: the holder only, admin or not
EXPECT_EQ(Change(editor, 10u, levels), eResult::OK);
EXPECT_EQ(Change(other, 10u, levels), eResult::LOCKED_BY_OTHER);
EXPECT_EQ(Change(admin, 10u, levels), eResult::LOCKED_BY_OTHER);
EXPECT_EQ(Change(editor, std::nullopt, levels), eResult::NOT_ASSIGNED);
EXPECT_EQ(Change(low, 13u, levels), eResult::LEVEL_TOO_LOW);
// The numbers are told to requesters
EXPECT_EQ(static_cast<uint8_t>(eResult::OK), 0);
EXPECT_EQ(static_cast<uint8_t>(eResult::LEVEL_TOO_LOW), 1);
EXPECT_EQ(static_cast<uint8_t>(eResult::NOT_ASSIGNED), 2);
EXPECT_EQ(static_cast<uint8_t>(eResult::LOCKED_BY_OTHER), 3);
EXPECT_EQ(static_cast<uint8_t>(eResult::NO_CHANGE), 4);
}
TEST(ZoneEditLockTests, ReloadHoldsWhilePlayersAreThere) {
using namespace ZoneEditLock;
EXPECT_EQ(PlanReload(false, 5, true), eReload::SKIPPED);
EXPECT_EQ(PlanReload(true, 0, false), eReload::STARTED);
EXPECT_EQ(PlanReload(true, 3, false), eReload::HELD);
EXPECT_EQ(PlanReload(true, 3, true), eReload::STARTED);
}