diff --git a/Engine/Vfs/Test/mod_variants_tests.cpp b/Engine/Vfs/Test/mod_variants_tests.cpp index 2cae3a90..ebb320fb 100644 --- a/Engine/Vfs/Test/mod_variants_tests.cpp +++ b/Engine/Vfs/Test/mod_variants_tests.cpp @@ -93,6 +93,23 @@ int main() { "A disabled pack still decided what loads"); check(mods::variant_of("Pack#wide") == "wide" && mods::variant_owner("Pack#wide") == "Pack" && mods::variant_owner("Pack").empty(), "A variant's entry does not name its variant and mod"); + const auto cooperation = [&](const std::string& policy) { + write(mods_root / "Pack" / "reskate-plugin.json", + "{\"api\":1,\"variants\":{\"folder\":\"decks\",\"displaces\":[\"deck_bundle\"]},\"variant_additions\":" + policy + "}"); + std::vector notes; + return mods::describe_mod(mods_root / "Pack", "Pack", notes); + }; + auto permitted = cooperation(R"({"accept":["WIN32/TEST/DECK"],"contribute":["win32/test/other"]})"); + check(permitted.variant_additions.accept == std::vector{"win32/test/deck"} && + permitted.variant_additions.contribute == std::vector{"win32/test/other"}, "Both exact bundle permissions are parsed"); + for (const auto* invalid : {R"({"accept":true})", R"({"accept":["win32/*"]})", R"({"accept":["win32/../deck"]})", + R"({"accept":["win32/test","WIN32/TEST"]})", R"({"accept":["win32/test"],"contribute":[3]})"}) { + permitted = cooperation(invalid); + check(permitted.variants && permitted.variants->displaces == std::vector{"deck_bundle"}, + "Malformed cooperation retains the author's displacement rule"); + check(permitted.variant_additions.accept.empty() && permitted.variant_additions.contribute.empty(), + "Malformed cooperation grants neither side, even after a valid first field"); + } fs::remove_all(root); std::cout << "Mod variants: order, choice, fallback, set aside, disabled and malformed passed.\n"; } diff --git a/Engine/Vfs/mod_list.cpp b/Engine/Vfs/mod_list.cpp index 1c103ed9..3c0e3f80 100644 --- a/Engine/Vfs/mod_list.cpp +++ b/Engine/Vfs/mod_list.cpp @@ -232,6 +232,43 @@ void read_variants(Mod& mod, std::vector& notes) { } } +// Invalid cooperation never removes an existing displacement rule. Read this +// independently of variants, and publish neither side of a partial declaration. +void read_variant_additions(Mod& mod, std::vector& notes) { + const auto path = mod.directory / plugin_manifest_file; + std::error_code error; + if (!fs::is_regular_file(path, error) || error) return; + try { + const auto root = parse(path, maximum_plugin_bytes); + if (!root.is_object() || !root.contains("variant_additions")) return; + const auto& policy = root.at("variant_additions"); + if (!policy.is_object()) throw std::runtime_error("variant_additions is an object"); + const auto list = [&](const char* field) { + std::vector result; + if (!policy.contains(field)) return result; + const auto& values = policy.at(field); + if (!values.is_array() || values.size() > 16) throw std::runtime_error("at most 16 exact bundle names per side"); + for (const auto& value : values) { + if (!value.is_string() || value.string().size() > 260) throw std::runtime_error("bundle name must be a string of at most 260 characters"); + auto name = lower(value.string()); + if (!name.starts_with("win32/") || name.find("..") != std::string::npos || + name.find("//") != std::string::npos || name.back() == '/' || + std::ranges::any_of(name, [](unsigned char c) { return c <= 0x20 || c >= 0x7f || c == '\\' || c == '*' || c == '?'; })) + throw std::runtime_error("an exact win32 bundle name is required"); + if (std::ranges::find(result, name) != result.end()) throw std::runtime_error("repeated bundle permission"); + result.push_back(std::move(name)); + } + return result; + }; + VariantAdditions parsed; + parsed.accept = list("accept"); + parsed.contribute = list("contribute"); + mod.variant_additions = std::move(parsed); + } catch (const std::exception& failure) { + notes.push_back("Mod " + mod.name + ": private variant additions refused (" + failure.what() + ")"); + } +} + // Whether reskate-plugin.json names code to run. A file with only "resident" or "variants" serves // the mod's game data and is no plugin. The plugin host reports what else is wrong with it. void read_plugin_entry(Mod& mod) { @@ -498,6 +535,7 @@ Mod describe_mod(const std::filesystem::path& directory, std::string name, std:: read_levels(mod); read_resident(mod, notes); read_variants(mod, notes); + read_variant_additions(mod, notes); read_plugin_entry(mod); read_studio_marker(mod); return mod; diff --git a/Engine/Vfs/mod_list.h b/Engine/Vfs/mod_list.h index 449e02bd..402c5bdd 100644 --- a/Engine/Vfs/mod_list.h +++ b/Engine/Vfs/mod_list.h @@ -71,6 +71,12 @@ struct Variants { std::vector displaces; }; +// Separate from displacement: both authors must opt into a bounded native +// compatibility check. Exact bundle names are used, never prefixes or globs. +struct VariantAdditions { + std::vector accept, contribute; +}; + struct Mod { std::string name; // folder name directly under Mods/ std::filesystem::path directory; @@ -96,6 +102,7 @@ struct Mod { std::vector resident; // From reskate-plugin.json: the variants it holds (Variants), if it declares any. std::optional variants; + VariantAdditions variant_additions; // From .reskate-studio-patch: whether the mod has one, and the Skate.exe SHA-256 it records. bool studio_marker{}; std::string skate_sha256;