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
../../<file>, 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>
This commit is contained in:
AyeZeeBBandJean-Luc T. committed 2026-10-16 20:16:33 -05:00
1 parent 3aebb38832
commit f6f1052237
2 files changed
+72 -3

No files matched your search

+47
View File
@@ -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");
+25 -3
View File
@@ -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 ../../<file>, 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<bool>& cancel, const InstallOptions& options) {
std::vector<std::string> files;
std::vector<fs::path> 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);