diff options
| -rw-r--r-- | CMakeLists.txt | 5 | ||||
| -rw-r--r-- | src/src/fuseconnector.cpp | 56 | ||||
| -rw-r--r-- | src/src/installationmanager.cpp | 5 | ||||
| -rw-r--r-- | src/src/metainiutils.cpp | 176 | ||||
| -rw-r--r-- | src/src/metainiutils.h | 24 | ||||
| -rw-r--r-- | src/src/modinforegular.cpp | 9 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 5 | ||||
| -rw-r--r-- | src/tests/CMakeLists.txt | 53 | ||||
| -rw-r--r-- | src/tests/test_external_dir_mapping.cpp | 277 | ||||
| -rw-r--r-- | src/tests/test_metainiutils.cpp | 190 |
10 files changed, 793 insertions, 7 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index b4b1758..71de209 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -285,3 +285,8 @@ endif() # Main organizer executable add_subdirectory(src/src) + +if (BUILD_TESTING) + enable_testing() + add_subdirectory(src/tests) +endif() diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index 3685560..6495223 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -812,14 +812,64 @@ void FuseConnector::deployExternalMappings(const MappingType& mapping, if (map.isDirectory) { const fs::path srcPath(src.toStdString()); + const fs::path dstPath(dst.toStdString()); + + // For createTarget directory mappings (e.g. SKSE Log Redirector + // pointing My Games/Skyrim → Skyrim Special Edition), publish a single + // directory symlink so any new file the game writes under the dest + // path is also redirected — not just the files that exist in source + // at deploy time. Falls back to per-file symlinks when the dest dir + // already contains real content we mustn't clobber. + if (map.createTarget) { + if (!fs::exists(srcPath, ec)) { + fs::create_directories(srcPath, ec); + if (ec) { + ec.clear(); + } + } + const bool dstExists = fs::exists(dstPath, ec); + ec.clear(); + const bool dstIsLink = dstExists && fs::is_symlink(dstPath, ec); + ec.clear(); + const bool dstIsEmpty = dstExists && fs::is_directory(dstPath, ec) && + fs::is_empty(dstPath, ec); + ec.clear(); + + if (!dstExists || dstIsLink || dstIsEmpty) { + createTrackedDirs(dstPath.parent_path()); + if (dstIsLink) { + fs::remove(dstPath, ec); + ec.clear(); + } else if (dstIsEmpty) { + fs::remove(dstPath, ec); + ec.clear(); + } + fs::create_directory_symlink(srcPath, dstPath, ec); + if (!ec) { + m_externalSymlinks.push_back(dstPath.string()); + log::debug("Deployed directory symlink {} -> {}", dst, src); + continue; + } + log::warn("Failed to symlink directory {} -> {}: {}", dst, src, + QString::fromStdString(ec.message())); + ec.clear(); + } else { + log::warn( + "Mapped folder {} contains real files; falling back to per-file " + "symlinks. Move existing contents into {} and restart to fully " + "redirect new writes.", + dst, src); + } + } + if (!fs::exists(srcPath, ec)) { continue; } - // Pre-create the createTarget root so an empty source still leaves - // the target tracked for removal. if (map.createTarget) { - createTrackedDirs(fs::path(dst.toStdString())); + // Pre-create the dst root so an empty source still leaves it tracked + // for removal on cleanup. + createTrackedDirs(dstPath); } for (auto it = fs::recursive_directory_iterator( diff --git a/src/src/installationmanager.cpp b/src/src/installationmanager.cpp index 83e78bb..3e58a2a 100644 --- a/src/src/installationmanager.cpp +++ b/src/src/installationmanager.cpp @@ -26,6 +26,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include "iplugininstallercustom.h" #include "iplugininstallersimple.h" #include "messagedialog.h" +#include "metainiutils.h" #include "modinfo.h" #include "nexusinterface.h" #include "queryoverwritedialog.h" @@ -542,7 +543,9 @@ InstallationResult InstallationManager::doInstall(GuessedValue<QString>& modName QFile::copy(p.second, destPath); } - QSettings settingsFile(targetDirectory + "/meta.ini", QSettings::IniFormat); + const QString metaPath = targetDirectory + "/meta.ini"; + MetaIniUtils::normalizeMetaIniCase(metaPath); + QSettings settingsFile(metaPath, QSettings::IniFormat); // overwrite settings only if they are actually are available or haven't been set // before diff --git a/src/src/metainiutils.cpp b/src/src/metainiutils.cpp new file mode 100644 index 0000000..7af170f --- /dev/null +++ b/src/src/metainiutils.cpp @@ -0,0 +1,176 @@ +#include "metainiutils.h" + +#include <QByteArray> +#include <QFile> +#include <QHash> +#include <QList> +#include <QSaveFile> + +namespace MetaIniUtils +{ + +namespace +{ + +// QSettings IniFormat continues a value when the line ends with an *odd* +// number of trailing backslashes; an even count is just an escaped backslash +// in the value. +bool endsInOddBackslashes(const QByteArray& line) +{ + int n = 0; + for (qsizetype i = line.size() - 1; i >= 0 && line[i] == '\\'; --i) { + ++n; + } + return (n % 2) == 1; +} + +} // namespace + +bool normalizeMetaIniCase(const QString& path) +{ + QFile in(path); + if (!in.exists() || !in.open(QIODevice::ReadOnly)) { + return false; + } + const QByteArray raw = in.readAll(); + in.close(); + + if (raw.isEmpty()) { + return false; + } + + // Preserve trailing-newline status to write back faithfully. + const bool hadTrailingNewline = raw.endsWith('\n'); + + // QByteArray::split keeps an empty trailing element if the data ends in + // the separator; drop it so we don't synthesize a phantom blank line. + QList<QByteArray> rawLines = raw.split('\n'); + if (hadTrailingNewline && !rawLines.isEmpty() && rawLines.back().isEmpty()) { + rawLines.removeLast(); + } + + struct Entry + { + bool isKey = false; + QByteArray key; // lowercased + QByteArray rawLine; // for non-key lines + QByteArray fullKeyLine; // for key lines, possibly multi-line via \\\n + }; + + struct Section + { + QByteArray header; // [Name] line, empty for the implicit pre-header section + QList<Entry> entries; + QHash<QByteArray, qsizetype> indexByKey; + }; + + QList<Section> sections; + Section cur; // implicit pre-header / "General" section + bool changed = false; + + for (qsizetype i = 0; i < rawLines.size(); ++i) { + const QByteArray& line = rawLines[i]; + const QByteArray trimmed = line.trimmed(); + + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + sections.push_back(std::move(cur)); + cur = Section{}; + cur.header = line; + continue; + } + + if (trimmed.isEmpty() || trimmed.startsWith(';') || trimmed.startsWith('#')) { + Entry e; + e.isKey = false; + e.rawLine = line; + cur.entries.push_back(std::move(e)); + continue; + } + + const int eq = line.indexOf('='); + if (eq < 0) { + Entry e; + e.isKey = false; + e.rawLine = line; + cur.entries.push_back(std::move(e)); + continue; + } + + // Collect continuation lines (odd trailing backslashes). + QByteArray full = line; + while (endsInOddBackslashes(full) && i + 1 < rawLines.size()) { + ++i; + full.append('\n'); + full.append(rawLines[i]); + } + + const int fullEq = full.indexOf('='); + const QByteArray rawKey = full.left(fullEq).trimmed(); + const QByteArray valuePart = full.mid(fullEq); // includes '=' + const QByteArray lowerKey = rawKey.toLower(); + if (rawKey != lowerKey) { + changed = true; + } + + Entry e; + e.isKey = true; + e.key = lowerKey; + e.fullKeyLine = lowerKey + valuePart; + + auto it = cur.indexByKey.find(lowerKey); + if (it != cur.indexByKey.end()) { + Entry& prev = cur.entries[it.value()]; + const QByteArray newVal = + valuePart.size() > 1 ? valuePart.mid(1).trimmed() : QByteArray(); + const int prevEq = prev.fullKeyLine.indexOf('='); + const QByteArray prevVal = + (prevEq >= 0 && prevEq + 1 < prev.fullKeyLine.size()) + ? prev.fullKeyLine.mid(prevEq + 1).trimmed() + : QByteArray(); + if (newVal.isEmpty() && !prevVal.isEmpty()) { + // Keep the existing non-empty value; drop the empty duplicate. + } else { + prev = std::move(e); + } + changed = true; + } else { + cur.entries.push_back(std::move(e)); + cur.indexByKey.insert(lowerKey, cur.entries.size() - 1); + } + } + sections.push_back(std::move(cur)); + + if (!changed) { + return false; + } + + QByteArray out; + out.reserve(raw.size()); + for (qsizetype si = 0; si < sections.size(); ++si) { + const Section& s = sections[si]; + if (!s.header.isEmpty()) { + out.append(s.header); + out.append('\n'); + } + for (const Entry& e : s.entries) { + if (e.isKey) { + out.append(e.fullKeyLine); + } else { + out.append(e.rawLine); + } + out.append('\n'); + } + } + if (!hadTrailingNewline && out.endsWith('\n')) { + out.chop(1); + } + + QSaveFile saveFile(path); + if (!saveFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + saveFile.write(out); + return saveFile.commit(); +} + +} // namespace MetaIniUtils diff --git a/src/src/metainiutils.h b/src/src/metainiutils.h new file mode 100644 index 0000000..c043742 --- /dev/null +++ b/src/src/metainiutils.h @@ -0,0 +1,24 @@ +#ifndef METAINIUTILS_H +#define METAINIUTILS_H + +#include <QString> + +namespace MetaIniUtils +{ + +// Pre-normalize a Qt INI file so QSettings (case-sensitive in IniFormat on +// Linux/Qt6) does not produce duplicate-cased keys when `setValue("foo", x)` +// is invoked on a file already containing `Foo=...`. +// +// Lowercases all keys, deduplicates per-section (keeping the latest +// non-empty value), and preserves comments, blank lines, and ordering of +// surviving keys. Multi-line values (trailing `\` continuations) are +// preserved as a single logical entry. +// +// No-op if the file does not exist or has no case mismatches / duplicates. +// Returns true if the file was modified. +bool normalizeMetaIniCase(const QString& path); + +} // namespace MetaIniUtils + +#endif // METAINIUTILS_H diff --git a/src/src/modinforegular.cpp b/src/src/modinforegular.cpp index 81653b3..c59706d 100644 --- a/src/src/modinforegular.cpp +++ b/src/src/modinforegular.cpp @@ -2,6 +2,7 @@ #include "categories.h" #include "messagedialog.h" +#include "metainiutils.h" #include "moddatacontent.h" #include "organizercore.h" #include "plugincontainer.h" @@ -107,7 +108,9 @@ bool ModInfoRegular::isEmpty() const void ModInfoRegular::readMeta() { - QSettings metaFile(m_Path + "/meta.ini", QSettings::IniFormat); + const QString metaPath = m_Path + "/meta.ini"; + MetaIniUtils::normalizeMetaIniCase(metaPath); + QSettings metaFile(metaPath, QSettings::IniFormat); m_Comments = metaFile.value("comments", "").toString(); m_Notes = metaFile.value("notes", "").toString(); QString const tempGameName = metaFile.value("gameName", m_GameName).toString(); @@ -274,7 +277,9 @@ void ModInfoRegular::saveMeta() { // only write meta data if the mod directory exists if (m_MetaInfoChanged && QFile::exists(absolutePath())) { - QSettings metaFile(absolutePath().append("/meta.ini"), QSettings::IniFormat); + const QString metaPath = absolutePath().append("/meta.ini"); + MetaIniUtils::normalizeMetaIniCase(metaPath); + QSettings metaFile(metaPath, QSettings::IniFormat); if (metaFile.status() == QSettings::NoError) { std::set<int> temp = m_Categories; temp.erase(m_PrimaryCategory); diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index ff4d6af..8b3171e 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -15,6 +15,7 @@ #include "iplugingame.h" #include "iuserinterface.h" #include "messagedialog.h" +#include "metainiutils.h" #include "modlistsortproxy.h" #include "modrepositoryfileinfo.h" #include "nexusinterface.h" @@ -956,7 +957,9 @@ MOBase::IModInterface* OrganizerCore::createMod(GuessedValue<QString>& name) QString const targetDirectory = QDir::fromNativeSeparators(m_Settings.paths().mods()).append("/").append(name); - QSettings settingsFile(targetDirectory + "/meta.ini", QSettings::IniFormat); + const QString metaPath = targetDirectory + "/meta.ini"; + MetaIniUtils::normalizeMetaIniCase(metaPath); + QSettings settingsFile(metaPath, QSettings::IniFormat); if (!result.merged()) { settingsFile.setValue("modid", 0); diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt new file mode 100644 index 0000000..8e71366 --- /dev/null +++ b/src/tests/CMakeLists.txt @@ -0,0 +1,53 @@ +cmake_minimum_required(VERSION 3.16) + +find_package(Qt6 REQUIRED COMPONENTS Core) +find_package(GTest REQUIRED) + +# --------------------------------------------------------------------------- +# meta.ini case-normalization helper tests +# --------------------------------------------------------------------------- +add_executable(test_metainiutils EXCLUDE_FROM_ALL + test_metainiutils.cpp + ${CMAKE_SOURCE_DIR}/src/src/metainiutils.cpp +) +set_target_properties(test_metainiutils PROPERTIES + AUTOMOC OFF + CXX_STANDARD 23 + CXX_STANDARD_REQUIRED ON +) +target_include_directories(test_metainiutils PRIVATE + ${CMAKE_SOURCE_DIR}/src/src +) +target_link_libraries(test_metainiutils PRIVATE + Qt6::Core + GTest::gtest + GTest::gtest_main +) +add_test(NAME test_metainiutils COMMAND test_metainiutils) + +# --------------------------------------------------------------------------- +# External directory mapping (SKSE Log Redirector style) tests +# +# Exercises the deploy/cleanup logic for `isDirectory && createTarget` +# mappings — directory-symlink path plus the per-file fallback when the +# destination already has real content. The behavior is mirrored from +# FuseConnector::deployExternalMappings so the test can run without +# bringing in libfuse or the rest of the organizer dependency tree. +# --------------------------------------------------------------------------- +add_executable(test_external_dir_mapping EXCLUDE_FROM_ALL + test_external_dir_mapping.cpp +) +set_target_properties(test_external_dir_mapping PROPERTIES + AUTOMOC OFF + CXX_STANDARD 23 + CXX_STANDARD_REQUIRED ON +) +target_link_libraries(test_external_dir_mapping PRIVATE + GTest::gtest + GTest::gtest_main +) +add_test(NAME test_external_dir_mapping COMMAND test_external_dir_mapping) + +# Register a "fluorine-tests" umbrella target that builds every test exe. +add_custom_target(fluorine-tests + DEPENDS test_metainiutils test_external_dir_mapping) diff --git a/src/tests/test_external_dir_mapping.cpp b/src/tests/test_external_dir_mapping.cpp new file mode 100644 index 0000000..439fbfc --- /dev/null +++ b/src/tests/test_external_dir_mapping.cpp @@ -0,0 +1,277 @@ +// Tests the createTarget directory-mapping contract used by +// FuseConnector::deployExternalMappings (src/src/fuseconnector.cpp:758). +// +// The SKSE Log Redirector plugin family publishes a mapping with +// `isDirectory=true, createTarget=true` so that any file the game writes +// under the redirected destination — even files that did not exist at deploy +// time — flows through to the source. Per-file symlinks miss new writes; +// a single directory symlink does not. This test mirrors the production +// algorithm and verifies its contract on both happy paths and edge cases. +// +// The mirrored algorithm (kept intentionally close to the production code): +// +// if isDirectory && createTarget: +// ensure src exists +// if dst missing OR dst is a symlink OR dst is empty dir: +// remove dst (if symlink/empty dir), create dir symlink dst -> src +// else (dst has real content): +// fall back to per-file symlinks (current behavior, preserves data) + +#include <gtest/gtest.h> + +#include <cerrno> +#include <filesystem> +#include <fstream> +#include <string> +#include <system_error> + +namespace fs = std::filesystem; + +namespace +{ + +struct DeployResult +{ + bool dirSymlinkCreated = false; + bool fellBackToPerFile = false; + std::vector<fs::path> createdLinks; +}; + +DeployResult deployDirectoryMapping(const fs::path& src, const fs::path& dst, + bool createTarget) +{ + DeployResult result; + std::error_code ec; + + if (createTarget) { + if (!fs::exists(src, ec)) { + fs::create_directories(src, ec); + if (ec) { + ec.clear(); + } + } + const bool dstExists = fs::exists(dst, ec); + ec.clear(); + const bool dstIsLink = dstExists && fs::is_symlink(dst, ec); + ec.clear(); + const bool dstIsEmpty = dstExists && fs::is_directory(dst, ec) && + fs::is_empty(dst, ec); + ec.clear(); + + if (!dstExists || dstIsLink || dstIsEmpty) { + if (dstIsLink || dstIsEmpty) { + fs::remove(dst, ec); + ec.clear(); + } + fs::create_directory_symlink(src, dst, ec); + if (!ec) { + result.dirSymlinkCreated = true; + result.createdLinks.push_back(dst); + return result; + } + } + } + + // Fall back: per-file symlinks under src. + result.fellBackToPerFile = true; + if (!fs::exists(src, ec)) { + return result; + } + for (auto it = fs::recursive_directory_iterator( + src, fs::directory_options::skip_permission_denied); + it != fs::recursive_directory_iterator(); ++it) { + const auto& entry = *it; + const auto rel = fs::relative(entry.path(), src, ec); + if (ec || rel.empty()) { + continue; + } + const fs::path destPath = dst / rel; + if (entry.is_directory(ec)) { + fs::create_directories(destPath, ec); + } else { + fs::create_directories(destPath.parent_path(), ec); + if (fs::exists(destPath, ec) && !fs::is_symlink(destPath, ec)) { + continue; // never clobber real files + } + if (fs::is_symlink(destPath, ec)) { + fs::remove(destPath, ec); + } + fs::create_symlink(entry.path(), destPath, ec); + if (!ec) { + result.createdLinks.push_back(destPath); + } + } + } + return result; +} + +class TempRoot +{ +public: + TempRoot() + { + char tmpl[] = "/tmp/fluorine-extmap-XXXXXX"; + const char* d = mkdtemp(tmpl); + if (d != nullptr) { + m_path = d; + } + } + ~TempRoot() + { + if (!m_path.empty()) { + std::error_code ec; + fs::remove_all(m_path, ec); + } + } + const fs::path& path() const { return m_path; } + +private: + fs::path m_path; +}; + +void writeFile(const fs::path& p, const std::string& contents) +{ + fs::create_directories(p.parent_path()); + std::ofstream out(p); + out << contents; +} + +} // namespace + +// Happy path: a fresh enable of the SKSE Log Redirector. Source (Skyrim +// Special Edition) has logs, destination (Skyrim/) doesn't exist yet — we +// publish a single directory symlink. +TEST(ExternalDirMapping, DeploysDirSymlinkForFreshDestination) +{ + TempRoot tmp; + ASSERT_FALSE(tmp.path().empty()); + + const fs::path src = tmp.path() / "Skyrim Special Edition"; + const fs::path dst = tmp.path() / "Skyrim"; + writeFile(src / "skse64.log", "log A\n"); + writeFile(src / "papyrus.0.log", "log B\n"); + + auto result = deployDirectoryMapping(src, dst, /*createTarget=*/true); + EXPECT_TRUE(result.dirSymlinkCreated); + EXPECT_FALSE(result.fellBackToPerFile); + EXPECT_TRUE(fs::is_symlink(dst)); + EXPECT_EQ(src, fs::read_symlink(dst)); +} + +// Game writes a NEW log file at the destination AFTER deploy. With a +// directory symlink the write transparently goes through to source — the +// per-file approach would have left this as a real file in dst. +TEST(ExternalDirMapping, NewWritesFlowThroughDirSymlink) +{ + TempRoot tmp; + ASSERT_FALSE(tmp.path().empty()); + const fs::path src = tmp.path() / "src"; + const fs::path dst = tmp.path() / "dst"; + fs::create_directories(src); + + auto result = deployDirectoryMapping(src, dst, /*createTarget=*/true); + ASSERT_TRUE(result.dirSymlinkCreated); + + // Game emits a new log AFTER deploy, into the *destination*. + writeFile(dst / "new.log", "fresh data"); + + // The file must exist physically under src, not as a real file at dst. + EXPECT_TRUE(fs::exists(src / "new.log")); + EXPECT_FALSE(fs::is_symlink(src / "new.log")); + // Reading through dst returns the same bytes. + std::ifstream f(dst / "new.log"); + std::string buf((std::istreambuf_iterator<char>(f)), + std::istreambuf_iterator<char>()); + EXPECT_EQ("fresh data", buf); +} + +// Idempotency: re-running deploy on an existing symlink is a no-op rebuild, +// not a corruption — the destination still resolves to source afterwards. +TEST(ExternalDirMapping, IsIdempotentOnExistingSymlink) +{ + TempRoot tmp; + ASSERT_FALSE(tmp.path().empty()); + const fs::path src = tmp.path() / "src"; + const fs::path dst = tmp.path() / "dst"; + fs::create_directories(src); + + ASSERT_TRUE(deployDirectoryMapping(src, dst, true).dirSymlinkCreated); + ASSERT_TRUE(deployDirectoryMapping(src, dst, true).dirSymlinkCreated); + EXPECT_TRUE(fs::is_symlink(dst)); + EXPECT_EQ(src, fs::read_symlink(dst)); +} + +// Empty pre-existing destination dir is safe to replace with a symlink — +// happens when an earlier MO2 run created tracked dirs but no files. +TEST(ExternalDirMapping, ReplacesEmptyDestinationDir) +{ + TempRoot tmp; + ASSERT_FALSE(tmp.path().empty()); + const fs::path src = tmp.path() / "src"; + const fs::path dst = tmp.path() / "dst"; + fs::create_directories(src); + fs::create_directories(dst); + ASSERT_TRUE(fs::is_directory(dst)); + ASSERT_FALSE(fs::is_symlink(dst)); + + auto result = deployDirectoryMapping(src, dst, true); + EXPECT_TRUE(result.dirSymlinkCreated); + EXPECT_TRUE(fs::is_symlink(dst)); +} + +// Safety contract: when the destination dir already has *real* files +// (from the user's existing Skyrim install or an unrelated tool), we must +// NEVER replace it with a symlink — that would orphan the user's data. +// Fall back to per-file symlinks of source contents. +TEST(ExternalDirMapping, FallsBackWhenDestinationHasRealFiles) +{ + TempRoot tmp; + ASSERT_FALSE(tmp.path().empty()); + const fs::path src = tmp.path() / "src"; + const fs::path dst = tmp.path() / "dst"; + writeFile(src / "skse64.log", "from src"); + writeFile(dst / "user-data.txt", "USER MUST KEEP"); + + auto result = deployDirectoryMapping(src, dst, /*createTarget=*/true); + EXPECT_FALSE(result.dirSymlinkCreated); + EXPECT_TRUE(result.fellBackToPerFile); + EXPECT_FALSE(fs::is_symlink(dst)); + EXPECT_TRUE(fs::exists(dst / "user-data.txt")); + + // Source files surfaced as symlinks under dst. + EXPECT_TRUE(fs::is_symlink(dst / "skse64.log")); + // User's own file stays put as a real file, not a symlink. + EXPECT_FALSE(fs::is_symlink(dst / "user-data.txt")); +} + +// Cleanup contract: removing the recorded symlink (and its parent dir if +// empty) leaves no leftover real files. This is what +// FuseConnector::cleanupExternalMappings does — `fs::is_symlink` / +// `fs::remove` work for directory symlinks too. +TEST(ExternalDirMapping, CleanupRemovesDirSymlinkAndLeavesNoFiles) +{ + TempRoot tmp; + ASSERT_FALSE(tmp.path().empty()); + const fs::path src = tmp.path() / "src"; + const fs::path dst = tmp.path() / "dst"; + writeFile(src / "skse64.log", "x"); + + auto result = deployDirectoryMapping(src, dst, true); + ASSERT_TRUE(result.dirSymlinkCreated); + + // Game writes a brand new file post-deploy. + writeFile(dst / "new.log", "y"); + EXPECT_TRUE(fs::exists(src / "new.log")); + + // Simulate cleanup: cleanup loop calls is_symlink + remove. + std::error_code ec; + for (const auto& p : result.createdLinks) { + if (fs::is_symlink(p, ec)) { + fs::remove(p, ec); + } + } + EXPECT_FALSE(fs::exists(dst)); + // Source survives — user's actual data is preserved on plugin disable. + EXPECT_TRUE(fs::exists(src / "skse64.log")); + EXPECT_TRUE(fs::exists(src / "new.log")); +} diff --git a/src/tests/test_metainiutils.cpp b/src/tests/test_metainiutils.cpp new file mode 100644 index 0000000..aef5998 --- /dev/null +++ b/src/tests/test_metainiutils.cpp @@ -0,0 +1,190 @@ +#include <gtest/gtest.h> + +#include <QByteArray> +#include <QDir> +#include <QFile> +#include <QSettings> +#include <QString> +#include <QTemporaryDir> + +#include "metainiutils.h" + +namespace +{ + +QString writeIni(const QTemporaryDir& dir, const char* contents) +{ + const QString path = QDir(dir.path()).filePath("meta.ini"); + QFile f(path); + EXPECT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Truncate)); + f.write(contents); + f.close(); + return path; +} + +QByteArray readAll(const QString& path) +{ + QFile f(path); + EXPECT_TRUE(f.open(QIODevice::ReadOnly)); + return f.readAll(); +} + +} // namespace + +// Sanity: empty / non-existent file is a no-op. +TEST(MetaIniUtils, NoOpOnMissingFile) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = QDir(dir.path()).filePath("nope.ini"); + EXPECT_FALSE(MetaIniUtils::normalizeMetaIniCase(path)); +} + +// Sanity: file with only lowercase keys is left untouched. +TEST(MetaIniUtils, NoOpOnAlreadyLowercase) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const auto* contents = "[General]\n" + "author=Aether\n" + "category=\"11,\"\n"; + const QString path = writeIni(dir, contents); + EXPECT_FALSE(MetaIniUtils::normalizeMetaIniCase(path)); + EXPECT_EQ(QByteArray(contents), readAll(path)); +} + +// The reported bug: pre-existing CamelCase keys + lowercase setValue() leaves +// duplicates on Linux/Qt6. Normalize must fold case-only duplicates. +TEST(MetaIniUtils, DedupesCaseOnlyDuplicates) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + // Mirrors what ModInfoRegular::saveMeta() leaves behind when meta.ini + // already had CamelCase keys: both casings coexist, and the lowercase + // copy may even be empty because readMeta() couldn't see the original. + const auto* contents = + "[General]\n" + "Author=Aether\n" + "Category=11\n" + "Color=@Variant(\\0\\0\\0\\x43\\x1\\xff\\xff\\x43\\x43\\xff\\xff\\xff\\xff\\0\\0)\n" + "Comments=keep me\n" + "Converted=false\n" + "author=\n" + "category=\"11,\"\n" + "comments=\n" + "converted=true\n"; + const QString path = writeIni(dir, contents); + EXPECT_TRUE(MetaIniUtils::normalizeMetaIniCase(path)); + + // Open via QSettings and verify exactly one canonical lowercase key per + // logical setting. + QSettings s(path, QSettings::IniFormat); + EXPECT_EQ(QStringList({"author", "category", "color", "comments", "converted"}), + s.allKeys()); + + // Empty lowercase duplicate should NOT clobber the non-empty CamelCase + // value — `Comments=keep me` survives over `comments=`. + EXPECT_EQ("Aether", s.value("author").toString()); + EXPECT_EQ("keep me", s.value("comments").toString()); + + // For non-empty duplicates the latest write wins, mirroring last-writer + // semantics so QSettings doesn't reintroduce stale data. + EXPECT_EQ("11,", s.value("category").toString()); + EXPECT_TRUE(s.value("converted").toBool()); + + // Color value (containing escaped binary) round-trips intact. + const QByteArray after = readAll(path); + EXPECT_TRUE(after.contains( + "color=@Variant(\\0\\0\\0\\x43\\x1\\xff\\xff\\x43\\x43\\xff\\xff\\xff\\xff\\0\\0)")) + << after.toStdString(); + EXPECT_FALSE(after.contains("Author=")); + EXPECT_FALSE(after.contains("Color=")); +} + +// QSettings IniFormat supports multi-line values via trailing-`\` line +// continuation. Continuation lines must travel with their key when the key is +// renamed to lowercase, otherwise the value gets corrupted. +TEST(MetaIniUtils, PreservesLineContinuations) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const auto* contents = "[General]\n" + "NotesField=line one\\\n" + " continued line two\n" + "Author=Aether\n"; + const QString path = writeIni(dir, contents); + EXPECT_TRUE(MetaIniUtils::normalizeMetaIniCase(path)); + + const QByteArray after = readAll(path); + EXPECT_TRUE(after.contains("notesfield=line one\\\n")); + EXPECT_TRUE(after.contains(" continued line two")); + EXPECT_FALSE(after.contains("NotesField=")); +} + +// Plugin settings live in nested `[Plugins\<name>]` sections. The dedupe +// logic must scope per-section — `Foo=` under [General] must not collide +// with `foo=` under [Plugins\Whatever]. +TEST(MetaIniUtils, DedupesPerSection) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const auto* contents = "[General]\n" + "Foo=top\n" + "[Plugins\\Sample]\n" + "Foo=plugin\n" + "foo=plugin-dup\n"; + const QString path = writeIni(dir, contents); + EXPECT_TRUE(MetaIniUtils::normalizeMetaIniCase(path)); + + QSettings s(path, QSettings::IniFormat); + EXPECT_EQ("top", s.value("foo").toString()); + s.beginGroup("Plugins/Sample"); + EXPECT_EQ("plugin-dup", s.value("foo").toString()); + s.endGroup(); +} + +// End-to-end: reproduce the exact symptom from the bug report — install +// path opens QSettings, writes lowercase keys; without normalization the +// resulting file ends up with CamelCase + lowercase duplicates. +TEST(MetaIniUtils, FixesQSettingsDuplicationOnLinux) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + // Pre-existing meta.ini in CamelCase (e.g. shipped inside the mod + // archive, or migrated from MO2 Windows). + const auto* preexisting = "[General]\n" + "Author=Aether\n" + "Category=11\n" + "Comments=keep me\n"; + const QString path = writeIni(dir, preexisting); + + // Without normalization, lowercase setValue adds NEW keys alongside the + // CamelCase ones — confirm the bug exists in the bare QSettings flow. + { + QSettings s(path, QSettings::IniFormat); + s.setValue("author", "Aether"); + s.setValue("category", "11,"); + s.setValue("comments", ""); + } + const QByteArray dirty = readAll(path); + EXPECT_TRUE(dirty.contains("Author=Aether")); + EXPECT_TRUE(dirty.contains("author=Aether")); + + // Normalize and re-run the same install-style writes. After this the + // file must have exactly one casing per key. + EXPECT_TRUE(MetaIniUtils::normalizeMetaIniCase(path)); + { + QSettings s(path, QSettings::IniFormat); + s.setValue("author", "Aether"); + s.setValue("category", "11,"); + } + + const QByteArray clean = readAll(path); + EXPECT_FALSE(clean.contains("Author=")); + EXPECT_FALSE(clean.contains("Category=")); + EXPECT_FALSE(clean.contains("Comments=")); + EXPECT_TRUE(clean.contains("author=Aether")); + EXPECT_TRUE(clean.contains("category=\"11,\"")); + // The non-empty original Comments value survived the empty duplicate. + EXPECT_TRUE(clean.contains("comments=keep me")); +} |
