Mods: a resource default must exist and stay inside its package

The manifest check on `default` is text-only; a symlink the package ships
could still point outside it. The default now resolves only when it exists
with the resource's kind (folder / file) and its canonical path stays
under the package's canonical root; otherwise the resource is unset and a
required one blocks launch. mod_packages_test covers a cleared or removed
selection restoring the default, a missing default folder, and a default
that is a symlink out of the package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-10-07 22:47:55 -07:00
parent 3b7f8ca195
commit 2f536d114b
3 changed files with 68 additions and 4 deletions
+7 -3
View File
@@ -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:
+21 -1
View File
@@ -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 {};
}
+40
View File
@@ -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";