mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-11-14 07:47:13 +00:00
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:
@@ -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
91
dCommon/ZoneEditLock.h
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
386
dCommon/ZoneOverrideStore.cpp
Normal file
386
dCommon/ZoneOverrideStore.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
96
dCommon/ZoneOverrideStore.h
Normal file
96
dCommon/ZoneOverrideStore.h
Normal 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
394
dCommon/ZonePublish.cpp
Normal 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
108
dCommon/ZonePublish.h
Normal 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);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ set(DCOMMONTEST_SOURCES
|
||||
"WorldFileWatchTests.cpp"
|
||||
"ChatFilterCoreTests.cpp"
|
||||
"BrickBoundsTests.cpp"
|
||||
"ZonePublishTests.cpp"
|
||||
)
|
||||
|
||||
add_subdirectory(dEnumsTests)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
548
tests/dCommonTests/ZonePublishTests.cpp
Normal file
548
tests/dCommonTests/ZonePublishTests.cpp
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user