fix(startup): move the single-instance lock to a private per-user file

The single-instance lock lived in /tmp, which is world-writable. Creating
it with mode 0666 there gets masked to 0644, so a second local user
cannot open it O_RDWR and is wrongly blocked from running the wallet at
all. A world-writable directory also lets another user pre-create or
symlink the lock path.

- The lock now lives in a per-user directory: $XDG_RUNTIME_DIR when it is
  an absolute directory the user owns, otherwise the per-user, per-variant
  config directory that startup has already created. Never /tmp.
- It is created mode 0600 with O_NOFOLLOW, and after opening we fstat it
  and refuse anything that is not a regular file we own, rejecting a
  planted symlink or a foreign-owned file.
- The lock file is no longer unlinked on release; flock drops on close,
  and leaving the file in place avoids an unlink/relock race (a stale
  file is harmless).

Tests cover acquiring and conflicting on the lock, the 0600 mode, that
release leaves the file in place, and that a symlinked lock path is
refused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 20:57:21 -05:00
co-authored by Claude Opus 4.8
parent bf5cb39566
commit f47528de53
3 changed files with 73 additions and 14 deletions
+41
View File
@@ -127,6 +127,8 @@
#include <cstdlib>
#include <deque>
#include <filesystem>
#include <sys/stat.h>
#include "util/single_instance.h"
#include <fstream>
#include <iostream>
#include <algorithm>
@@ -3974,6 +3976,44 @@ void testBootstrapDecideResume()
EXPECT_TRUE(Bootstrap::decideResume(3, false, 999) == R::RangedGet); // bogus error-page length ignored
}
// C219/C220: the single-instance lock lives in a per-user dir, is 0600, O_NOFOLLOW (POSIX only).
// Mutates XDG_RUNTIME_DIR, so it saves/restores it and runs before anything that reads that env.
void testSingleInstance()
{
#ifndef _WIN32
namespace fs = std::filesystem;
std::error_code ec;
const fs::path tmp = fs::temp_directory_path(ec) / ("od_si_test_" + std::to_string(::getpid()));
fs::create_directories(tmp, ec);
::chmod(tmp.c_str(), 0700);
const char* prev = ::getenv("XDG_RUNTIME_DIR");
const std::string saved = prev ? prev : "";
const bool had = prev != nullptr;
::setenv("XDG_RUNTIME_DIR", tmp.c_str(), 1);
{
dragonx::util::SingleInstance a("od-test"), b("od-test");
EXPECT_TRUE(a.tryLock()); // first wins
EXPECT_TRUE(!b.tryLock()); // flock conflicts across OFDs, even in one process
const fs::path lockf = tmp / "od-test.lock";
struct stat st{};
EXPECT_TRUE(::stat(lockf.c_str(), &st) == 0);
EXPECT_EQ((int)(st.st_mode & 0777), 0600);
a.unlock();
EXPECT_TRUE(fs::exists(lockf, ec)); // lock file is NOT removed on unlock (stale is harmless)
// A planted symlink at the lock path is refused by O_NOFOLLOW (open fails -> tryLock false).
const fs::path sym = tmp / "od-sym.lock";
fs::create_symlink("/nonexistent/target", sym, ec);
if (!ec) {
dragonx::util::SingleInstance c("od-sym");
EXPECT_TRUE(!c.tryLock());
}
}
if (had) ::setenv("XDG_RUNTIME_DIR", saved.c_str(), 1);
else ::unsetenv("XDG_RUNTIME_DIR");
fs::remove_all(tmp, ec);
#endif
}
void testBootstrapSpaceSufficient()
{
using dragonx::util::Bootstrap;
@@ -18462,6 +18502,7 @@ int main()
testBootstrapSpaceSufficient();
testBootstrapParseChecksum();
testBootstrapDecideResume();
testSingleInstance();
testBootstrapPlanArchive();
#ifndef _WIN32
testBootstrapReclaimable();