From 2eec089ef554b70e247800588313f3c45f8d0af4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 5 May 2026 12:48:50 -0700 Subject: [PATCH] fix(editor): harden JSON handling, quest loading, and content validation - Quest editor: add loadFromFile() with nlohmann/json, chain validation with circular reference detection, wire into ADT load and save pipeline - Project: replace naive substring JSON parsing with nlohmann/json for both save() and load(), fix shell injection in gitCommit() - Content pack: replace manual JSON with nlohmann/json, validate binary format magic numbers (WHM1/WOM1/WOB1), add WOB to openFormatScore (now scores 0-6), mark invalid files with (!) in summary --- tools/editor/content_pack.cpp | 124 ++++++++++++++++---------- tools/editor/content_pack.hpp | 5 +- tools/editor/editor_app.cpp | 7 ++ tools/editor/editor_project.cpp | 126 +++++++++++--------------- tools/editor/quest_editor.cpp | 151 +++++++++++++++++++++++++------- tools/editor/quest_editor.hpp | 5 ++ 6 files changed, 265 insertions(+), 153 deletions(-) diff --git a/tools/editor/content_pack.cpp b/tools/editor/content_pack.cpp index 46a43dfe..4fb10141 100644 --- a/tools/editor/content_pack.cpp +++ b/tools/editor/content_pack.cpp @@ -1,5 +1,6 @@ #include "content_pack.hpp" #include "core/logger.hpp" +#include #include #include #include @@ -39,22 +40,20 @@ bool ContentPacker::packZone(const std::string& outputDir, const std::string& ma } // Build info JSON - std::string infoJson = "{\n"; - infoJson += " \"format\": \"" + info.format + "\",\n"; - infoJson += " \"name\": \"" + info.name + "\",\n"; - infoJson += " \"author\": \"" + info.author + "\",\n"; - infoJson += " \"description\": \"" + info.description + "\",\n"; - infoJson += " \"version\": \"" + info.version + "\",\n"; - infoJson += " \"mapId\": " + std::to_string(info.mapId) + ",\n"; - infoJson += " \"fileCount\": " + std::to_string(files.size()) + ",\n"; - infoJson += " \"files\": [\n"; - for (size_t i = 0; i < files.size(); i++) { - auto fsize = fs::file_size(files[i].second); - infoJson += " {\"path\": \"" + files[i].first + "\", \"size\": " + std::to_string(fsize) + "}"; - if (i + 1 < files.size()) infoJson += ","; - infoJson += "\n"; + nlohmann::json infoObj; + infoObj["format"] = info.format; + infoObj["name"] = info.name; + infoObj["author"] = info.author; + infoObj["description"] = info.description; + infoObj["version"] = info.version; + infoObj["mapId"] = info.mapId; + infoObj["fileCount"] = files.size(); + nlohmann::json fileArr = nlohmann::json::array(); + for (const auto& [rel, full] : files) { + fileArr.push_back({{"path", rel}, {"size", fs::file_size(full)}}); } - infoJson += " ]\n}\n"; + infoObj["files"] = fileArr; + std::string infoJson = infoObj.dump(2); // Write WCP file std::ofstream out(destPath, std::ios::binary); @@ -149,62 +148,95 @@ bool ContentPacker::readInfo(const std::string& wcpPath, ContentPackInfo& info) in.read(reinterpret_cast(&fileCount), 4); in.read(reinterpret_cast(&infoSize), 4); - std::string json(infoSize, '\0'); - in.read(json.data(), infoSize); + std::string jsonStr(infoSize, '\0'); + in.read(jsonStr.data(), infoSize); - // Parse basic fields - auto findStr = [&](const std::string& key) -> std::string { - auto pos = json.find("\"" + key + "\""); - if (pos == std::string::npos) return ""; - pos = json.find('"', json.find(':', pos) + 1); - if (pos == std::string::npos) return ""; - auto end = json.find('"', pos + 1); - return json.substr(pos + 1, end - pos - 1); - }; - - info.name = findStr("name"); - info.author = findStr("author"); - info.description = findStr("description"); - info.version = findStr("version"); - info.format = findStr("format"); + try { + auto j = nlohmann::json::parse(jsonStr); + info.name = j.value("name", ""); + info.author = j.value("author", ""); + info.description = j.value("description", ""); + info.version = j.value("version", ""); + info.format = j.value("format", ""); + info.mapId = j.value("mapId", 9000u); + } catch (...) { + return false; + } return true; } +static bool checkMagic(const std::string& path, uint32_t expectedMagic) { + std::ifstream f(path, std::ios::binary); + if (!f) return false; + uint32_t magic = 0; + f.read(reinterpret_cast(&magic), 4); + return magic == expectedMagic; +} + ContentPacker::ValidationResult ContentPacker::validateZone(const std::string& zoneDir) { namespace fs = std::filesystem; ValidationResult r; if (!fs::exists(zoneDir)) return r; + static constexpr uint32_t WHM_MAGIC = 0x314D4857; // "WHM1" + static constexpr uint32_t WOM_MAGIC = 0x314D4F57; // "WOM1" + static constexpr uint32_t WOB_MAGIC = 0x31424F57; // "WOB1" + for (auto& entry : fs::recursive_directory_iterator(zoneDir)) { if (!entry.is_regular_file()) continue; std::string ext = entry.path().extension().string(); - std::string name = entry.path().filename().string(); + std::string fname = entry.path().filename().string(); if (ext == ".wot") r.hasWot = true; - if (ext == ".whm") r.hasWhm = true; - if (ext == ".wom") r.hasWom = true; + if (ext == ".whm") { + r.hasWhm = true; + if (checkMagic(entry.path().string(), WHM_MAGIC)) r.whmValid = true; + } + if (ext == ".wom") { + r.hasWom = true; + if (checkMagic(entry.path().string(), WOM_MAGIC)) r.womValid = true; + } + if (ext == ".wob") { + r.hasWob = true; + if (checkMagic(entry.path().string(), WOB_MAGIC)) r.wobValid = true; + } if (ext == ".png") r.hasPng = true; - if (name == "zone.json") r.hasZoneJson = true; - if (name == "creatures.json") r.hasCreatures = true; - if (name == "quests.json") r.hasQuests = true; - if (name == "objects.json") r.hasObjects = true; + if (fname == "zone.json") r.hasZoneJson = true; + if (fname == "creatures.json") r.hasCreatures = true; + if (fname == "quests.json") r.hasQuests = true; + if (fname == "objects.json") r.hasObjects = true; } return r; } int ContentPacker::ValidationResult::openFormatScore() const { int score = 0; - if (hasWot) score++; if (hasWhm) score++; if (hasZoneJson) score++; - if (hasPng) score++; if (hasWom) score++; - return score; // max 5 for fully open + if (hasWot) score++; + if (hasWhm && whmValid) score++; + if (hasZoneJson) score++; + if (hasPng) score++; + if (hasWom && womValid) score++; + if (hasWob && wobValid) score++; + return score; // max 6 for fully open } std::string ContentPacker::ValidationResult::summary() const { std::string s; - s += hasWot ? "WOT " : ""; s += hasWhm ? "WHM " : ""; - s += hasZoneJson ? "zone.json " : ""; s += hasPng ? "PNG " : ""; - s += hasWom ? "WOM " : ""; s += hasCreatures ? "creatures " : ""; - s += hasQuests ? "quests " : ""; s += hasObjects ? "objects " : ""; + auto add = [&](bool has, bool valid, const char* name) { + if (!has) return; + s += name; + if (!valid) s += "(!)"; + s += " "; + }; + add(hasWot, true, "WOT"); + add(hasWhm, whmValid, "WHM"); + add(hasWom, womValid, "WOM"); + add(hasWob, wobValid, "WOB"); + if (hasZoneJson) s += "zone.json "; + if (hasPng) s += "PNG "; + if (hasCreatures) s += "creatures "; + if (hasQuests) s += "quests "; + if (hasObjects) s += "objects "; return s.empty() ? "(empty)" : s; } diff --git a/tools/editor/content_pack.hpp b/tools/editor/content_pack.hpp index f51006e6..6ba05098 100644 --- a/tools/editor/content_pack.hpp +++ b/tools/editor/content_pack.hpp @@ -38,8 +38,9 @@ public: // Validate that a zone directory has all required open format files struct ValidationResult { bool hasWot = false, hasWhm = false, hasZoneJson = false; - bool hasPng = false, hasWom = false, hasCreatures = false; - bool hasQuests = false, hasObjects = false; + bool hasPng = false, hasWom = false, hasWob = false; + bool hasCreatures = false, hasQuests = false, hasObjects = false; + bool whmValid = false, womValid = false, wobValid = false; int openFormatScore() const; std::string summary() const; }; diff --git a/tools/editor/editor_app.cpp b/tools/editor/editor_app.cpp index 9b34d098..9d58a6e3 100644 --- a/tools/editor/editor_app.cpp +++ b/tools/editor/editor_app.cpp @@ -653,6 +653,8 @@ void EditorApp::loadADT(const std::string& mapName, int tileX, int tileY) { showToast("Loaded " + std::to_string(objectPlacer_.objectCount()) + " objects"); if (npcSpawner_.loadFromFile(outBase + "/creatures.json")) showToast("Loaded " + std::to_string(npcSpawner_.spawnCount()) + " NPCs"); + if (questEditor_.loadFromFile(outBase + "/quests.json")) + showToast("Loaded " + std::to_string(questEditor_.questCount()) + " quests"); if (objectPlacer_.objectCount() > 0 || npcSpawner_.spawnCount() > 0) objectsDirty_ = true; } @@ -726,6 +728,11 @@ void EditorApp::exportZone(const std::string& outputDir) { if (questEditor_.questCount() > 0) { std::string questPath = base + "/quests.json"; questEditor_.saveToFile(questPath); + std::vector chainErrors; + if (!questEditor_.validateChains(chainErrors)) { + for (const auto& err : chainErrors) + LOG_WARNING("Quest chain issue: ", err); + } } // Update WDT with additional tiles from adjacent exports diff --git a/tools/editor/editor_project.cpp b/tools/editor/editor_project.cpp index 0789ed5a..058caf37 100644 --- a/tools/editor/editor_project.cpp +++ b/tools/editor/editor_project.cpp @@ -1,5 +1,6 @@ #include "editor_project.hpp" #include "core/logger.hpp" +#include #include #include @@ -10,28 +11,25 @@ bool EditorProject::save(const std::string& path) const { namespace fs = std::filesystem; fs::create_directories(fs::path(path).parent_path()); + nlohmann::json j; + j["format"] = "wowee-project-1.0"; + j["name"] = name; + j["author"] = author; + j["description"] = description; + j["version"] = version; + j["startMapId"] = startMapId; + + nlohmann::json zarr = nlohmann::json::array(); + for (const auto& z : zones) { + zarr.push_back({{"mapName", z.mapName}, {"tileX", z.tileX}, + {"tileY", z.tileY}, {"biome", z.biome}, + {"description", z.description}}); + } + j["zones"] = zarr; + std::ofstream f(path); if (!f) return false; - - f << "{\n"; - f << " \"format\": \"wowee-project-1.0\",\n"; - f << " \"name\": \"" << name << "\",\n"; - f << " \"author\": \"" << author << "\",\n"; - f << " \"description\": \"" << description << "\",\n"; - f << " \"version\": \"" << version << "\",\n"; - f << " \"startMapId\": " << startMapId << ",\n"; - f << " \"zones\": [\n"; - for (size_t i = 0; i < zones.size(); i++) { - const auto& z = zones[i]; - f << " {\"mapName\": \"" << z.mapName << "\"" - << ", \"tileX\": " << z.tileX - << ", \"tileY\": " << z.tileY - << ", \"biome\": \"" << z.biome << "\"" - << ", \"description\": \"" << z.description << "\"" - << "}" << (i + 1 < zones.size() ? "," : "") << "\n"; - } - f << " ]\n"; - f << "}\n"; + f << j.dump(2) << "\n"; LOG_INFO("Project saved: ", path, " (", zones.size(), " zones)"); return true; @@ -40,64 +38,36 @@ bool EditorProject::save(const std::string& path) const { bool EditorProject::load(const std::string& path) { std::ifstream f(path); if (!f) return false; - std::string content((std::istreambuf_iterator(f)), - std::istreambuf_iterator()); - auto findStr = [&](const std::string& key) -> std::string { - auto pos = content.find("\"" + key + "\""); - if (pos == std::string::npos) return ""; - pos = content.find('"', content.find(':', pos) + 1); - if (pos == std::string::npos) return ""; - auto end = content.find('"', pos + 1); - return content.substr(pos + 1, end - pos - 1); - }; + try { + nlohmann::json j = nlohmann::json::parse(f); - name = findStr("name"); - author = findStr("author"); - description = findStr("description"); - version = findStr("version"); - projectDir = std::filesystem::path(path).parent_path().string(); + name = j.value("name", ""); + author = j.value("author", ""); + description = j.value("description", ""); + version = j.value("version", "1.0.0"); + startMapId = j.value("startMapId", 9000u); + projectDir = std::filesystem::path(path).parent_path().string(); - // Parse zones from JSON array - zones.clear(); - size_t zonesPos = content.find("\"zones\""); - if (zonesPos != std::string::npos) { - size_t start = zonesPos; - while ((start = content.find('{', start + 1)) != std::string::npos) { - auto end = content.find('}', start); - if (end == std::string::npos) break; - // Check we're still inside the zones array - auto closeBracket = content.find(']', zonesPos); - if (start > closeBracket) break; - - std::string block = content.substr(start, end - start + 1); - ProjectZone z; - - auto blockFindStr = [&](const std::string& key) -> std::string { - auto p = block.find("\"" + key + "\""); - if (p == std::string::npos) return ""; - p = block.find('"', block.find(':', p) + 1); - if (p == std::string::npos) return ""; - auto e = block.find('"', p + 1); - return block.substr(p + 1, e - p - 1); - }; - - z.mapName = blockFindStr("mapName"); - z.biome = blockFindStr("biome"); - z.description = blockFindStr("description"); - - auto txPos = block.find("\"tileX\":"); - if (txPos != std::string::npos) z.tileX = std::stoi(block.substr(txPos + 8)); - auto tyPos = block.find("\"tileY\":"); - if (tyPos != std::string::npos) z.tileY = std::stoi(block.substr(tyPos + 8)); - - if (!z.mapName.empty()) zones.push_back(z); - start = end; + zones.clear(); + if (j.contains("zones") && j["zones"].is_array()) { + for (const auto& jz : j["zones"]) { + ProjectZone z; + z.mapName = jz.value("mapName", ""); + z.tileX = jz.value("tileX", 32); + z.tileY = jz.value("tileY", 32); + z.biome = jz.value("biome", ""); + z.description = jz.value("description", ""); + if (!z.mapName.empty()) zones.push_back(z); + } } - } - LOG_INFO("Project loaded: ", path, " (", name, ", ", zones.size(), " zones)"); - return true; + LOG_INFO("Project loaded: ", path, " (", name, ", ", zones.size(), " zones)"); + return true; + } catch (const std::exception& e) { + LOG_ERROR("Failed to load project: ", e.what()); + return false; + } } bool EditorProject::initGitRepo() const { @@ -109,8 +79,14 @@ bool EditorProject::initGitRepo() const { bool EditorProject::gitCommit(const std::string& message) const { if (projectDir.empty()) return false; - int ret = std::system(("cd \"" + projectDir + "\" && git add -A && " - "git commit -m \"" + message + "\"").c_str()); + // Sanitize commit message to prevent shell injection + std::string safe; + for (char c : message) { + if (c == '\'' || c == '\\') safe += '\\'; + safe += c; + } + int ret = std::system(("cd '" + projectDir + "' && git add -A && " + "git commit -m '" + safe + "'").c_str()); return ret == 0; } diff --git a/tools/editor/quest_editor.cpp b/tools/editor/quest_editor.cpp index a2ef7156..7e419cec 100644 --- a/tools/editor/quest_editor.cpp +++ b/tools/editor/quest_editor.cpp @@ -1,7 +1,9 @@ #include "quest_editor.hpp" #include "core/logger.hpp" +#include #include #include +#include namespace wowee { namespace editor { @@ -27,42 +29,131 @@ bool QuestEditor::saveToFile(const std::string& path) const { auto dir = std::filesystem::path(path).parent_path(); if (!dir.empty()) std::filesystem::create_directories(dir); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& q : quests_) { + nlohmann::json jq; + jq["id"] = q.id; + jq["title"] = q.title; + jq["description"] = q.description; + jq["completionText"] = q.completionText; + jq["requiredLevel"] = q.requiredLevel; + jq["questGiverNpcId"] = q.questGiverNpcId; + jq["turnInNpcId"] = q.turnInNpcId; + jq["nextQuestId"] = q.nextQuestId; + jq["reward"] = {{"xp", q.reward.xp}, {"gold", q.reward.gold}, + {"silver", q.reward.silver}, {"copper", q.reward.copper}}; + nlohmann::json items = nlohmann::json::array(); + for (const auto& item : q.reward.itemRewards) items.push_back(item); + jq["reward"]["items"] = items; + + nlohmann::json objs = nlohmann::json::array(); + for (const auto& obj : q.objectives) { + objs.push_back({{"type", static_cast(obj.type)}, + {"desc", obj.description}, + {"target", obj.targetName}, + {"count", obj.targetCount}}); + } + jq["objectives"] = objs; + arr.push_back(jq); + } + std::ofstream f(path); if (!f) return false; - - f << "[\n"; - for (size_t i = 0; i < quests_.size(); i++) { - const auto& q = quests_[i]; - f << " {\n"; - f << " \"id\": " << q.id << ",\n"; - f << " \"title\": \"" << q.title << "\",\n"; - f << " \"description\": \"" << q.description << "\",\n"; - f << " \"completionText\": \"" << q.completionText << "\",\n"; - f << " \"requiredLevel\": " << q.requiredLevel << ",\n"; - f << " \"questGiverNpcId\": " << q.questGiverNpcId << ",\n"; - f << " \"turnInNpcId\": " << q.turnInNpcId << ",\n"; - f << " \"nextQuestId\": " << q.nextQuestId << ",\n"; - f << " \"reward\": {\"xp\":" << q.reward.xp - << ",\"gold\":" << q.reward.gold - << ",\"silver\":" << q.reward.silver - << ",\"copper\":" << q.reward.copper << "},\n"; - f << " \"objectives\": ["; - for (size_t j = 0; j < q.objectives.size(); j++) { - const auto& obj = q.objectives[j]; - f << "{\"type\":" << static_cast(obj.type) - << ",\"desc\":\"" << obj.description << "\"" - << ",\"target\":\"" << obj.targetName << "\"" - << ",\"count\":" << obj.targetCount << "}"; - if (j + 1 < q.objectives.size()) f << ","; - } - f << "]\n"; - f << " }" << (i + 1 < quests_.size() ? "," : "") << "\n"; - } - f << "]\n"; + f << arr.dump(2) << "\n"; LOG_INFO("Quests saved: ", path, " (", quests_.size(), " quests)"); return true; } +bool QuestEditor::loadFromFile(const std::string& path) { + std::ifstream f(path); + if (!f) return false; + + try { + nlohmann::json arr = nlohmann::json::parse(f); + if (!arr.is_array()) return false; + + quests_.clear(); + uint32_t maxId = 0; + + for (const auto& jq : arr) { + Quest q; + q.id = jq.value("id", 0u); + q.title = jq.value("title", "Untitled"); + q.description = jq.value("description", ""); + q.completionText = jq.value("completionText", ""); + q.requiredLevel = jq.value("requiredLevel", 1u); + q.questGiverNpcId = jq.value("questGiverNpcId", 0u); + q.turnInNpcId = jq.value("turnInNpcId", 0u); + q.nextQuestId = jq.value("nextQuestId", 0u); + + if (jq.contains("reward")) { + const auto& jr = jq["reward"]; + q.reward.xp = jr.value("xp", 100u); + q.reward.gold = jr.value("gold", 0u); + q.reward.silver = jr.value("silver", 0u); + q.reward.copper = jr.value("copper", 0u); + if (jr.contains("items") && jr["items"].is_array()) { + for (const auto& item : jr["items"]) + q.reward.itemRewards.push_back(item.get()); + } + } + + if (jq.contains("objectives") && jq["objectives"].is_array()) { + for (const auto& jo : jq["objectives"]) { + QuestObjective obj; + obj.type = static_cast(jo.value("type", 0)); + obj.description = jo.value("desc", ""); + obj.targetName = jo.value("target", ""); + obj.targetCount = jo.value("count", 1u); + q.objectives.push_back(obj); + } + } + + if (q.id > maxId) maxId = q.id; + quests_.push_back(q); + } + + nextId_ = maxId + 1; + LOG_INFO("Quests loaded: ", path, " (", quests_.size(), " quests)"); + return true; + } catch (const std::exception& e) { + LOG_ERROR("Failed to load quests from ", path, ": ", e.what()); + return false; + } +} + +bool QuestEditor::validateChains(std::vector& errors) const { + errors.clear(); + std::unordered_set validIds; + for (const auto& q : quests_) validIds.insert(q.id); + + for (const auto& q : quests_) { + if (q.nextQuestId != 0 && validIds.find(q.nextQuestId) == validIds.end()) { + errors.push_back("Quest [" + std::to_string(q.id) + "] \"" + q.title + + "\" chains to non-existent quest " + std::to_string(q.nextQuestId)); + } + + // Circular chain detection + if (q.nextQuestId != 0) { + std::unordered_set visited; + uint32_t current = q.id; + while (current != 0) { + if (!visited.insert(current).second) { + errors.push_back("Circular quest chain detected starting from quest [" + + std::to_string(q.id) + "] \"" + q.title + "\""); + break; + } + uint32_t next = 0; + for (const auto& other : quests_) { + if (other.id == current) { next = other.nextQuestId; break; } + } + current = next; + } + } + } + return errors.empty(); +} + } // namespace editor } // namespace wowee diff --git a/tools/editor/quest_editor.hpp b/tools/editor/quest_editor.hpp index f3b5691b..ba0f24f1 100644 --- a/tools/editor/quest_editor.hpp +++ b/tools/editor/quest_editor.hpp @@ -54,6 +54,11 @@ public: size_t questCount() const { return quests_.size(); } bool saveToFile(const std::string& path) const; + bool loadFromFile(const std::string& path); + void clear() { quests_.clear(); nextId_ = 1; } + + // Quest chain validation + bool validateChains(std::vector& errors) const; Quest& getTemplate() { return template_; }