aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-01 10:09:18 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-01 10:09:18 -0500
commit5d1fb2964a172e3444f18ca2ea823ee36784e699 (patch)
treeb658255c6465f049a6041cf8ee5970ff2521ca5a
parent17756f8337bc71abf10aa0e7612dc31f2e0a588a (diff)
Fix meta.ini duplicate keys and SKSE Log Redirector mapped folders
meta.ini: Qt6 QSettings IniFormat is case-sensitive on Linux. Pre-existing CamelCase keys + lowercase setValue() left both casings in the file, so mods grew duplicate entries on every install. Added MetaIniUtils:: normalizeMetaIniCase() to fold keys to lowercase and dedupe per-section (keeping non-empty values) before any QSettings open in readMeta, saveMeta, doInstall, and createMod. SKSE Log Redirector: deployExternalMappings created per-file symlinks under the redirected destination, so any new log file the game wrote post-deploy ended up as a real file outside the mapping. For isDirectory && createTarget mappings, now publish a single directory symlink dst -> src when the destination is missing, an existing symlink, or empty. Falls back to per-file symlinks when dst already has real content (preserves user data). Cleanup removes the symlink, leaving no files behind. Tests under src/tests/ (gtest, opt-in via BUILD_TESTING): - test_metainiutils: 6 tests covering no-op cases, case-only dedup, multi-line continuations, per-section scoping, and end-to-end verification that QSettings duplication is fixed. - test_external_dir_mapping: 6 tests covering directory symlink deploy, flow-through writes, idempotency, empty-dir replacement, real-file fallback, and cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-rw-r--r--CMakeLists.txt5
-rw-r--r--src/src/fuseconnector.cpp56
-rw-r--r--src/src/installationmanager.cpp5
-rw-r--r--src/src/metainiutils.cpp176
-rw-r--r--src/src/metainiutils.h24
-rw-r--r--src/src/modinforegular.cpp9
-rw-r--r--src/src/organizercore.cpp5
-rw-r--r--src/tests/CMakeLists.txt53
-rw-r--r--src/tests/test_external_dir_mapping.cpp277
-rw-r--r--src/tests/test_metainiutils.cpp190
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"));
+}