From f6f1052237df90b1290649fdf9a79c8552aa0e15 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Fri, 16 Oct 2026 20:15:52 -0500 Subject: [PATCH] Mods: a link inside a mod folder refuses its install instead of writing outside Mods Reported in #265 by Kayyo321; first fixed in #267 by Jean-LucTch, whose approach this keeps to the folder being installed. "Install folder" named each file with fs::relative, which follows links: a file link inside the folder to a file outside it came out as ../../, and the copy wrote over that file (ReSkate.dll among what it could reach). - Files are named by where they sit in the chosen folder (lexically_relative), never by where a link leads, and go through safe_entry like a zip's entries. - A symbolic link, junction or mount point inside the folder refuses the install, saying which. Links on the way to the folder or to the Mods folder (a library moved to another drive) stay fine, as do cloud placeholders such as OneDrive's, which name nothing else: #267 refused any reparse point on those paths, so a game reached through a junction could install nothing. - Tests: a junction inside a mod folder is refused and the file it leads to is untouched, with no staging left behind (this fails without the fix); a Mods folder and a picked folder reached through junctions still install; a file symbolic link is refused (skipped without Developer Mode, which creating one needs). Co-authored-by: Jean-Luc T. <88255067+Jean-LucTch@users.noreply.github.com> --- Launcher/Test/mod_manager_tests.cpp | 47 +++++++++++++++++++++++++++++ Launcher/mod_manager.cpp | 28 +++++++++++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/Launcher/Test/mod_manager_tests.cpp b/Launcher/Test/mod_manager_tests.cpp index 40fcf41..9352809 100644 --- a/Launcher/Test/mod_manager_tests.cpp +++ b/Launcher/Test/mod_manager_tests.cpp @@ -173,6 +173,53 @@ int main() { check(install(mods, sources / L"Loose") == "My Folder Mod", "Folders install like zips"); check(fs::exists(sources / L"Loose" / L"My Folder Mod" / L"layout.toc"), "Installing a folder copies it"); + // Links in a mod folder (issue #265): one inside it refuses the install and nothing outside is + // written; links on the way to the folder or to the Mods folder are fine. Junctions need no + // rights to make; a file symbolic link needs Developer Mode, so that case is skipped without it. + { + const auto place = root / L"links"; + const auto junction = [](const fs::path& link, const fs::path& target) { + fs::create_directories(target); + return _wsystem((L"mklink /J \"" + link.wstring() + L"\" \"" + target.wstring() + L"\" >nul").c_str()) == 0; + }; + const auto real_game = place / L"real game"; + fs::create_directories(real_game); + const bool made = junction(place / L"linked game", real_game); + const auto linked_mods = launcher_mods::mods_root(place / L"linked game"); + write(place / L"outside" / L"sentinel.txt", "untouched"); + + write(place / L"Junction Mod" / L"layout.toc", "toc"); + const bool inner = junction(place / L"Junction Mod" / L"stuff", place / L"outside"); + check(made && inner, "links: junctions could be made for the checks"); + const auto refused = error_of([&] { install(linked_mods, place / L"Junction Mod"); }); + check(refused.find("holds a link (stuff)") != std::string::npos && !fs::exists(linked_mods / L"Junction Mod") && + read(place / L"outside" / L"sentinel.txt") == "untouched", + ("A junction inside a mod folder refuses the install: " + refused).c_str()); + check(!fs::exists(linked_mods / L".reskate-install"), "A refused folder leaves no staging behind"); + + write(place / L"Plain Mod" / L"layout.toc", "toc"); + check(install(linked_mods, place / L"Plain Mod") == "Plain Mod" && read(real_game / L"Mods" / L"Plain Mod" / L"layout.toc") == "toc", + "A Mods folder reached through a junction still installs"); + junction(place / L"picked", place / L"Picked Mod"); + write(place / L"Picked Mod" / L"layout.toc", "toc"); + check(install(linked_mods, place / L"picked") == "picked", "A mod folder picked through a junction still installs"); + + write(place / L"File Link Mod" / L"layout.toc", "toc"); + const auto link = place / L"File Link Mod" / L"linked.txt"; + if (CreateSymbolicLinkW(link.c_str(), (place / L"outside" / L"sentinel.txt").c_str(), + SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE)) { + const auto file_refused = error_of([&] { install(linked_mods, place / L"File Link Mod"); }); + check(file_refused.find("holds a link (linked.txt)") != std::string::npos && + read(place / L"outside" / L"sentinel.txt") == "untouched" && !fs::exists(linked_mods / L"File Link Mod"), + ("A file link inside a mod folder refuses the install: " + file_refused).c_str()); + } else { + std::cout << "SKIP: file symbolic link (Windows error " << GetLastError() << "; needs Developer Mode)\n"; + } + // Junctions first, so nothing below follows one. + for (const auto* name : {L"linked game", L"picked", L"Junction Mod\\stuff"}) + RemoveDirectoryW((place / name).c_str()); + } + // The list: unlisted folders load after listed ones, by name. auto list = mods::scan_mods(game); check(list.present && list.issue.empty() && list.entries.size() == 4, "Every installed mod is listed"); diff --git a/Launcher/mod_manager.cpp b/Launcher/mod_manager.cpp index bac1888..cd44bb6 100644 --- a/Launcher/mod_manager.cpp +++ b/Launcher/mod_manager.cpp @@ -347,16 +347,38 @@ std::string install_zip(const fs::path& mods, const fs::path& source, bool repla return name; } +// A symbolic link, junction or mount point: an entry that names a path somewhere else. (Cloud +// placeholders such as OneDrive's are reparse points too, but name nothing else.) +bool links_elsewhere(const fs::path& path) { + WIN32_FIND_DATAW found{}; + const auto search = FindFirstFileW(path.c_str(), &found); + if (search == INVALID_HANDLE_VALUE) return false; + FindClose(search); + return (found.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) && IsReparseTagNameSurrogate(found.dwReserved0); +} + +// A folder's files are named by where they sit in it, never by where a link leads, and checked +// as a zip's entries are. fs::relative followed links: a file link inside the folder to one +// outside it came out as ../../, which was copied over that file (issue #265, first fixed +// in PR #267 by Jean-LucTch). So a link inside the folder refuses the install. Links on the way +// to the folder or to the Mods folder (a library moved to another drive) are the player's own +// and stay fine. std::string install_folder(const fs::path& mods, const fs::path& source, bool replace, const Progress& progress, const std::atomic& cancel, const InstallOptions& options) { std::vector files; std::vector paths; for (auto it = fs::recursive_directory_iterator(source); it != fs::recursive_directory_iterator(); ++it) { if (cancel) fail("Install cancelled."); - if (!it->is_regular_file()) continue; + const auto inside = it->path().lexically_relative(source); + if (links_elsewhere(it->path())) + fail(utf8(source.filename().wstring()) + " holds a link (" + utf8(inside.generic_wstring()) + + "), which a mod folder may not: copy the real files in and try again."); + if (!fs::is_regular_file(it->symlink_status())) continue; + auto relative = safe_entry(utf8(inside.generic_wstring())); + if (!relative) fail("The folder contains an unsafe path (" + utf8(inside.generic_wstring()) + "); it was not installed."); + if (relative->empty()) continue; paths.push_back(it->path()); - auto relative = utf8(fs::relative(it->path(), source).generic_wstring()); - files.push_back(std::move(relative)); + files.push_back(std::move(*relative)); } const auto root = mod_root(files); if (!root) fail(utf8(source.filename().wstring()) + no_mod);