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);