diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index d7e7b857..9fad8eca 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -873,9 +873,13 @@ A `file`, `directory` or `folder` resource may name a package-relative `default` (for example `default = "pack"`): a file or folder the package ships beside its manifest. It is used while the player has selected nothing, so a required resource with a default resolves out of the box; a selected path -always wins. The default must be a safe relative path inside the package -(no absolute paths or `..`) and cannot be combined with verified media -(`size`/`sha256`), `input_only` or `shared_source`. +always wins, and clearing the selection restores the default. The default +must be a safe relative path inside the package (no absolute paths or `..`) +and cannot be combined with verified media (`size`/`sha256`), `input_only` +or `shared_source`. At resolve time it counts only when it exists with the +resource's kind (folder or file) and its real path, symlinks resolved, stays +inside the package; otherwise the resource is unset (a required one blocks +launch). Format 8 adds engine-verified donor media to the same picker and state format: diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index c7374f1c..35979a73 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -861,6 +861,26 @@ std::string effective_option_value(const ModPackage& package, return option ? option->default_value : std::string(); } +/* A resource's package-relative default, only when it exists with the + * resource's kind (folder or file) and its real path (symlinks resolved) + * stays inside the package: the manifest check is text-only, and a symlink + * the package ships could point anywhere. {} otherwise (the resource is then + * unset, so a required one blocks launch as before). */ +static fs::path package_default_path(const ModPackage& package, const ModResource& resource) { + std::error_code ec; + const fs::path candidate = package.root / fs::u8path(resource.default_path); + const bool folder = resource.format == "directory" || resource.format == "folder"; + if (folder ? !fs::is_directory(candidate, ec) : !fs::is_regular_file(candidate, ec)) + return {}; + const fs::path real = fs::canonical(candidate, ec); + if (ec) return {}; + const fs::path base = fs::canonical(package.root, ec); + if (ec) return {}; + const auto rel = real.lexically_relative(base); + if (rel.empty() || rel.is_absolute() || *rel.begin() == "..") return {}; + return candidate; +} + fs::path effective_resource_path(const ModPackage& package, const ModSelection& selection, const std::string& feature_id, @@ -876,7 +896,7 @@ fs::path effective_resource_path(const ModPackage& package, } const ModResource* declared = find_resource(package, feature_id, id); if (declared && !declared->default_path.empty() && !package.root.empty()) - return package.root / fs::u8path(declared->default_path); + return package_default_path(package, *declared); return {}; } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index c14c462d..06d71860 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -706,6 +706,46 @@ int main() { check(default_resource.feature_resource_path( "default-resource.mod", "pack", "pack") == root / "selected/pack", "a selected resource path must override the package default"); + { + /* Clearing the selection (state.toml holding an empty path, or no + * entry at all) restores the package default. */ + auto sel = default_resource.selections(); + sel["default-resource.mod"].features["pack"].resources["pack"] = ""; + auto kept = default_resource.exchange_selections(sel); + check(default_resource.feature_resource_path( + "default-resource.mod", "pack", "pack") == default_pack, + "a cleared selection must restore the package default"); + sel["default-resource.mod"].features["pack"].resources.erase("pack"); + default_resource.exchange_selections(sel); + check(default_resource.feature_resource_path( + "default-resource.mod", "pack", "pack") == default_pack, + "a removed selection must restore the package default"); + default_resource.exchange_selections(kept); + } + { + /* The default must exist, and its real path must stay inside the + * package: a shipped symlink pointing outside is not followed. */ + ModPackageManager probe(root); + fs::remove_all(default_pack, ec); + check(probe.scan(&error), error.c_str()); + check(probe.feature_resource_path("default-resource.mod", "pack", "pack").empty(), + "a missing default folder must not count as resolved"); + check(!probe.resolve("SLUS-TEST").ok, + "a required resource whose default is missing must block launch"); + const fs::path outside = root / "outside-pack"; + fs::create_directories(outside, ec); + fs::create_directory_symlink(outside, default_pack, ec); + if (!ec) { + ModPackageManager linked(root); + check(linked.scan(&error), error.c_str()); + check(linked.feature_resource_path("default-resource.mod", "pack", "pack").empty(), + "a default that is a symlink out of the package must be rejected"); + check(!linked.resolve("SLUS-TEST").ok, + "a default escaping the package must not resolve"); + fs::remove(default_pack, ec); + } + fs::remove_all(outside, ec); + } fs::remove_all(root / "installed/default-resource.mod", ec); { const fs::path unsafe_root = root / "unsafe-default";