fix(startup): surface filesystem failures and verify Sapling param integrity

Three verified daemon-startup edge-case fixes centered on the config/params
filesystem path:

- F7: new non-throwing Platform::ensureDirectory(dir, outError) with one
  consistent "Cannot create <dir>: <reason>. Check permissions / free space."
  message. Replaces the unchecked/throwing create_directories sites at main.cpp
  (pre-init: log + Windows MessageBox + return 1), connection.cpp's
  autoDetectConfig (was the *throwing* overload -- could raise an uncaught
  filesystem_error through its callers; now sets the new
  ConnectionConfig::dir_error), and both app.cpp daemon-dir sites (surface via
  daemon_status_ + return false). The primary connect path (app_network.cpp)
  checks dir_error and shows it instead of mislabelling it "waiting for config".
  embedded_resources.cpp already checked its error_code, so it is left as-is.

- F6: verifySaplingParams() now hash-verifies each param against its pinned
  canonical SHA-256 (source of truth: scripts/build-lite-backend-artifact.sh)
  instead of only checking existence, so a truncated / corrupt-but-present param
  is rejected up front rather than failing later on a shielded operation. A
  <params_dir>/.sapling_verified marker keyed on size:mtime avoids re-hashing
  ~48MB on every startup. Logic extracted to the injectable, unit-testable
  verifySaplingParamsIn(dir, digests); reuses util::sha256Hex (no new hash impl).

- F5: startEmbeddedDaemon() now checks extractEmbeddedResources()'s return and
  the previously-dropped copy_file error_code in the daemon-binary fallback loop,
  aborting with a clear status (sb_daemon_extract_failed / sb_daemon_files_failed)
  instead of failing opaquely at spawn. An absent source file stays non-fatal.

Adds testPlatformEnsureDirectory and testVerifySaplingParams to test_phase4.cpp.
i18n keys added to i18n.cpp (English source of truth); the res/lang/*.json
back-fill via add_missing_translations.py is deferred to a single run at the end
of the batch. Progress tracked in docs/daemon-startup-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 11:07:32 -05:00
parent b3444e0a89
commit 2675b8ab93
10 changed files with 340 additions and 35 deletions

View File

@@ -29,8 +29,8 @@ around a single shared helper. The connectivity-breaking security flip lands las
| 1 | **F1** | `embedded_daemon.cpp` · `isRunning()` | Smallest/highest-severity; establishes the reliable Error/crash-count transition steps 3 & 6 depend on. | ☑ |
| 2 | **F2** | `embedded_daemon.cpp` · `startProcess()` | Same file family, different function; test the F1+F2 pair together with `kill -SEGV` / bad-binary repros. | ☑ |
| 3 | **F4** | `embedded_daemon.cpp` · `start()` | After F1/F2 so crash-count semantics are settled; its bail deliberately stays out of the crash path. | ☑ |
| 4 | **F7** | `util/platform` · `connection.cpp` | Structural owner of the fs-error idiom + `ConnectionConfig` that F5/F6/F8 reuse. | |
| 5 | **F6 + F5** | `app.cpp` · `verifySaplingParams()` | Same `startEmbeddedDaemon` / `verifySaplingParams` block; land together. | |
| 4 | **F7** | `util/platform` · `connection.cpp` | Structural owner of the fs-error idiom + `ConnectionConfig` that F5/F6/F8 reuse. | |
| 5 | **F6 + F5** | `app.cpp` · `verifySaplingParams()` | Same `startEmbeddedDaemon` / `verifySaplingParams` block; land together. | |
| 6 | **F3** | `app.cpp` · `renderLoadingOverlay()` | After F1 — panel is guarded off during `State::Error` (owned by the crash-count hint). | ☐ |
| 7 | **F8** | `connection.cpp` · `tryConnect()` | Largest; only connectivity-breaking default flip — land last, with release notes. | ☐ |
@@ -216,7 +216,7 @@ F1/F2 (must not touch `crash_count_`; wording must not collide with the monitor'
## F5 — Extraction / copy write-failures never surfaced up front
**Severity:** Medium · **Effort:** S (~23h) · **Status:**
**Severity:** Medium · **Effort:** S (~23h) · **Status:** ☑ landed & verified
### The defect
`startEmbeddedDaemon()` discards `extractEmbeddedResources()`'s `bool` return
@@ -281,7 +281,14 @@ template F7 matches. Open item: remove truncated dst files so a retry re-copies.
## F6 — Sapling params validated by existence/size only, never hashed
**Severity:** Medium · **Effort:** S (~35h) · **Status:**
**Severity:** Medium · **Effort:** S (~35h) · **Status:** ☑ landed & verified
> **As-built note.** `verifySaplingParams()` now delegates to a public, injectable
> `verifySaplingParamsIn(dir, digests)` so the integrity + marker-cache logic is unit-testable
> with synthetic small files (the real 48 MB params aren't in the repo). i18n keys for F5 were
> added to `i18n.cpp` (English source of truth); the `res/lang/*.json` back-fill via
> `scripts/add_missing_translations.py` is deferred to a single run at the end of the batch,
> per the cross-cutting note. Non-English locales fall back to English until then.
### The defect
`verifySaplingParams()` (`connection.cpp:123`) only calls `fs::exists()`;
@@ -331,7 +338,11 @@ Third caller of the existing `util::sha256Hex`.
## F7 — Directory-create errors universally ignored on the daemon-env path
**Severity:** Medium · **Effort:** S (~34h) · **Status:**
**Severity:** Medium · **Effort:** S (~34h) · **Status:** ☑ landed & verified
> **As-built notes.** Two deviations from the original design, both confirmed against the code:
> (1) `embedded_resources.cpp:270` already checks its `error_code` and returns `false` on failure — it was **not** a bug, so it is left untouched.
> (2) Of the four `autoDetectConfig` callers, only the primary connect path (`app_network.cpp:243`) was wired to check `dir_error`; the other three degrade gracefully on their own — `app.cpp:4306` and `app_wizard.cpp:912` are stop paths that already gate on empty creds, and `settings_page.cpp:434` is read-only display. `dir_error` is set by `autoDetectConfig`, so they can be wired later if desired.
### The defect
Five startup directory-create sites either drop the `error_code` or use the throwing
@@ -492,4 +503,7 @@ design record; see the shared-helper table above.
- **F1** — ☑ landed: `isRunning()` (POSIX) now reads the atomic `state_` (predicate `Running || Stopping`) instead of calling `waitpid`, leaving `monitorProcess()` the sole reaper. Clean build (all targets link); `ctest` 1/1 passing. Not unit-testable — needs the manual `kill -SEGV` repro before release.
- **F2** — ☑ landed: `startProcess()` (POSIX) now creates a `FD_CLOEXEC` self-pipe before `fork()`; the child writes `errno` to it on `execv` failure, the parent reads EOF-vs-errno and, on failure, reaps the zombie + sets a precise `last_error_` ("not executable or wrong architecture") + returns `false` (so `start()` no longer reports `Running` for a daemon that never started). Parent-side `setpgid` is now best-effort with a `DEBUG_LOGF` on failure. Clean build; `ctest` 1/1 passing. Not unit-testable — needs the manual non-executable / wrong-arch-binary repro before release.
- **F6** — ☑ landed: `verifySaplingParams()` now hash-verifies each Sapling param against its pinned canonical SHA-256 (from `build-lite-backend-artifact.sh`), replacing the existence-only check, so a truncated/corrupt-but-present param is rejected instead of failing later on a shielded op. A `<params_dir>/.sapling_verified` marker keyed on `size:mtime` skips re-hashing ~48 MB on every startup. Logic extracted to the injectable `verifySaplingParamsIn(dir, digests)`; new `testVerifySaplingParams` unit test (valid / marker fast-path / wrong-hash / truncated / missing). Clean build; `ctest` 1/1 passing.
- **F5** — ☑ landed: `startEmbeddedDaemon()` now checks `extractEmbeddedResources()`'s return (abort with `sb_daemon_extract_failed` on failure) and the previously-dropped `copy_file` `error_code` in the daemon-binary fallback loop (abort with `sb_daemon_files_failed` incl. the dir), so a disk-full / truncated `dragonxd` write is surfaced up front instead of failing opaquely at spawn. An absent source file stays non-fatal. Two i18n keys added to `i18n.cpp`. Clean build; `ctest` 1/1 passing.
- **F7** — ☑ landed: new non-throwing `Platform::ensureDirectory(dir, outError)` in `util/platform.{h,cpp}` with one consistent message. Replaces the unchecked/throwing directory-create sites at `main.cpp:730` (pre-init: now logs + `MessageBoxA` on Windows + `return 1`), `connection.cpp:216` (autoDetectConfig now uses the ec overload — **no more uncaught `filesystem_error`** — and sets the new `ConnectionConfig::dir_error`), and both `app.cpp` daemon-dir sites (surface via `daemon_status_` + `return false`). Primary connect path (`app_network.cpp:243`) checks `dir_error` and bails to the status line instead of mislabelling it "waiting for config". `embedded_resources.cpp:270` left as-is (already correct). New `testPlatformEnsureDirectory` unit test (existing-dir / fresh-nested / empty / parent-is-file). Clean build; `ctest` 1/1 passing.
- **F4** — ☑ landed: `start()` now gates on a lingering datadir lock after the port bail. When `!skip_port_check_ && override_datadir_.empty()`, it polls `isDaemonProcessRunning()` with a bounded ~300 ms wait (3 × 100 ms, breaks early), then a pure header-inline `evaluateDatadirLockGate()` decides: if a sibling `dragonxd` is still alive it bails with a distinct **non-crash** `State::Error` ("…holding the data directory lock. Retrying shortly…") that never touches `crash_count_`, so the 3-strike cap can't trip; the connect loop's retry resumes once the lock clears. Isolated migrate-to-seed starts are exempt. New `testDatadirLockGate` unit test (5 assertions, proceed/bail/2× exempt) added to `test_phase4.cpp`. Clean build; `ctest` 1/1 passing.

View File

@@ -4149,7 +4149,11 @@ bool App::startEmbeddedDaemon()
if (resources::hasEmbeddedResources()) {
DEBUG_LOGF("Extracting embedded Sapling params...\n");
daemon_status_ = TR("sb_extracting_sapling");
resources::extractEmbeddedResources();
if (!resources::extractEmbeddedResources()) {
daemon_status_ = TR("sb_daemon_extract_failed");
DEBUG_LOGF("[ERROR] extractEmbeddedResources() failed — disk full or permission denied?\n");
return false;
}
// Check again after extraction
if (!rpc::Connection::verifySaplingParams()) {
@@ -4168,8 +4172,13 @@ bool App::startEmbeddedDaemon()
const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" };
bool copied = false;
if (!exe_dir.empty()) {
std::string dirErr;
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
daemon_status_ = dirErr;
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
return false;
}
std::error_code ec;
fs::create_directories(daemon_dir, ec);
// On macOS .app bundles, params are in Contents/Resources/
// while the executable is in Contents/MacOS/
@@ -4214,8 +4223,13 @@ bool App::startEmbeddedDaemon()
std::string exe_dir = util::Platform::getExecutableDirectory();
std::string daemon_dir = resources::getDaemonDirectory();
if (!exe_dir.empty()) {
std::string dirErr;
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
daemon_status_ = dirErr;
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
return false;
}
std::error_code ec;
fs::create_directories(daemon_dir, ec);
std::vector<std::string> searchDirs = { exe_dir };
#ifdef __APPLE__
@@ -4226,18 +4240,31 @@ bool App::startEmbeddedDaemon()
}
#endif
const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
bool copyFailed = false;
for (const char* name : extraFiles) {
fs::path dst = fs::path(daemon_dir) / name;
if (fs::exists(dst)) continue;
for (const auto& dir : searchDirs) {
fs::path src = fs::path(dir) / name;
if (fs::exists(src)) {
if (fs::exists(src)) { // an absent source is optional; only a real copy error counts
DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
fs::copy_file(src, dst, ec);
if (ec) {
DEBUG_LOGF("[ERROR] Failed to copy %s: %s\n", name, ec.message().c_str());
copyFailed = true;
ec.clear();
}
break;
}
}
}
if (copyFailed) {
char buf[512];
snprintf(buf, sizeof(buf), TR("sb_daemon_files_failed"), daemon_dir.c_str());
daemon_status_ = buf;
DEBUG_LOGF("[ERROR] One or more daemon files failed to copy to %s\n", daemon_dir.c_str());
return false;
}
}
}

View File

@@ -241,6 +241,16 @@ void App::tryConnect()
// Auto-detect configuration (file I/O — fast, safe on main thread)
auto config = rpc::Connection::autoDetectConfig();
if (!config.dir_error.empty()) {
// The data directory could not be created (read-only home, permission denied,
// disk full). Retrying won't fix it, so surface it in the status line instead of
// mislabelling it as "waiting for config" below.
connection_in_progress_ = false;
connection_status_ = config.dir_error;
VERBOSE_LOGF("[connect #%d] data dir error: %s\n", connect_attempt, config.dir_error.c_str());
return;
}
if (config.rpcuser.empty() || config.rpcpassword.empty()) {
connection_in_progress_ = false;

View File

@@ -726,8 +726,16 @@ int main(int argc, char* argv[])
// Ensure ObsidianDragon config directory exists early (before any file I/O)
{
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
std::error_code ec;
std::filesystem::create_directories(odDir, ec);
std::string odErr;
if (!dragonx::util::Platform::ensureDirectory(odDir, &odErr)) {
// Pre-App-init: nothing (ini, logs, config) can persist if this fails, and the
// Windows log redirect below isn't set up yet — report loudly before any setup.
std::fprintf(stderr, "%s\n", odErr.c_str());
#ifdef _WIN32
MessageBoxA(nullptr, odErr.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONERROR);
#endif
return 1;
}
}
#ifdef _WIN32

View File

@@ -14,8 +14,12 @@
#include <filesystem>
#include <algorithm>
#include <cctype>
#include <vector>
#include <chrono>
#include "../util/logger.h"
#include "../util/platform.h"
#include "../util/xmrig_updater.h" // util::sha256Hex
#ifdef _WIN32
#include <shlobj.h>
@@ -120,30 +124,121 @@ std::string Connection::getSaplingParamsDir()
return resources::getDaemonDirectory();
}
bool Connection::verifySaplingParams()
namespace {
std::string joinParamPath(const std::string& dir, const std::string& file) {
#ifdef _WIN32
return dir + "\\" + file;
#else
return dir + "/" + file;
#endif
}
// "<size>:<mtime>" fingerprint used to skip re-hashing an unchanged file. Empty on error.
std::string paramStatLine(const std::string& path) {
std::error_code ec;
auto sz = fs::file_size(path, ec);
if (ec) return {};
auto mtime = fs::last_write_time(path, ec);
long long ticks = ec ? 0 :
std::chrono::duration_cast<std::chrono::seconds>(mtime.time_since_epoch()).count();
return std::to_string(static_cast<unsigned long long>(sz)) + ":" + std::to_string(ticks);
}
bool paramHashMatches(const std::string& path, const std::string& expectedHex) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) return false;
std::streamsize sz = f.tellg();
if (sz <= 0) return false;
f.seekg(0, std::ios::beg);
std::vector<char> buf(static_cast<size_t>(sz));
if (!f.read(buf.data(), sz)) return false;
std::string got = util::sha256Hex(buf.data(), buf.size());
return !got.empty() && got == expectedHex;
}
// The verification cache: <params_dir>/.sapling_verified holds one paramStatLine per param,
// in list order, from the last successful hash check.
bool saplingMarkerMatches(const std::string& markerPath, const std::vector<std::string>& expected) {
for (const auto& s : expected) if (s.empty()) return false; // couldn't stat -> don't trust
std::ifstream f(markerPath);
if (!f) return false;
std::vector<std::string> lines;
std::string l;
while (std::getline(f, l)) lines.push_back(l);
return lines == expected;
}
void writeSaplingMarker(const std::string& markerPath, const std::vector<std::string>& lines) {
std::ofstream f(markerPath, std::ios::trunc);
if (!f) return;
for (const auto& l : lines) f << l << "\n";
}
// Canonical Zcash-family Sapling trusted-setup param digests — identical bytes across every
// fork/platform. Source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params().
// Keep in sync if the params are ever rotated.
const std::pair<std::string, std::string> kSaplingParamDigests[] = {
{ "sapling-spend.params", "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" },
{ "sapling-output.params", "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" },
};
} // namespace
bool Connection::verifySaplingParamsIn(
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests)
{
std::string params_dir = getSaplingParamsDir();
if (params_dir.empty()) {
if (dir.empty()) {
DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
return false;
}
#ifdef _WIN32
std::string spend_path = params_dir + "\\sapling-spend.params";
std::string output_path = params_dir + "\\sapling-output.params";
#else
std::string spend_path = params_dir + "/sapling-spend.params";
std::string output_path = params_dir + "/sapling-output.params";
#endif
bool spend_exists = fs::exists(spend_path);
bool output_exists = fs::exists(output_path);
DEBUG_LOGF("verifySaplingParams: dir=%s\n", params_dir.c_str());
DEBUG_LOGF(" spend: %s -> %s\n", spend_path.c_str(), spend_exists ? "found" : "MISSING");
DEBUG_LOGF(" output: %s -> %s\n", output_path.c_str(), output_exists ? "found" : "MISSING");
return spend_exists && output_exists;
if (digests.empty()) return false;
// 1) Every param must exist.
std::vector<std::string> paths;
paths.reserve(digests.size());
for (const auto& d : digests) {
std::string p = joinParamPath(dir, d.first);
if (!fs::exists(p)) {
DEBUG_LOGF("verifySaplingParams: %s MISSING\n", p.c_str());
return false;
}
paths.push_back(std::move(p));
}
// 2) Fast path: if the cached marker matches the current size:mtime of every param, trust
// the previous successful hash instead of re-hashing ~48MB on every startup.
const std::string markerPath = joinParamPath(dir, ".sapling_verified");
std::vector<std::string> current;
current.reserve(paths.size());
for (const auto& p : paths) current.push_back(paramStatLine(p));
if (saplingMarkerMatches(markerPath, current)) {
return true;
}
// 3) Integrity-check each param against its pinned SHA-256. A truncated or corrupt param
// (a partial extraction, or a Linux bundle where the file merely *exists*) is rejected
// here instead of being handed to the daemon and failing later on a shielded operation.
for (size_t i = 0; i < paths.size(); ++i) {
if (!paramHashMatches(paths[i], digests[i].second)) {
DEBUG_LOGF("verifySaplingParams: %s FAILED integrity check (truncated or corrupt)\n",
paths[i].c_str());
return false;
}
}
// 4) Record the verified state so later startups take the fast path.
writeSaplingMarker(markerPath, current);
DEBUG_LOGF("verifySaplingParams: %zu params verified (sha256)\n", paths.size());
return true;
}
bool Connection::verifySaplingParams()
{
std::vector<std::pair<std::string, std::string>> digests;
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
return verifySaplingParamsIn(getSaplingParamsDir(), digests);
}
ConnectionConfig Connection::parseConfFile(const std::string& path)
@@ -209,11 +304,14 @@ ConnectionConfig Connection::autoDetectConfig()
{
ConnectionConfig config;
// Ensure data directory exists
// Ensure the data directory exists. Use the non-throwing helper and report any failure
// via config.dir_error so callers can surface it — the old throwing create_directories()
// overload could raise an uncaught filesystem_error straight through autoDetectConfig()'s
// callers (read-only home, permission denied, etc.).
std::string data_dir = getDefaultDataDir();
if (!fs::exists(data_dir)) {
DEBUG_LOGF("Creating data directory: %s\n", data_dir.c_str());
fs::create_directories(data_dir);
if (!util::Platform::ensureDirectory(data_dir, &config.dir_error)) {
DEBUG_LOGF("[ERROR] autoDetectConfig: %s\n", config.dir_error.c_str());
return config; // data dir unusable — bail early with dir_error set
}
// Try to find DRAGONX.conf

View File

@@ -5,6 +5,8 @@
#pragma once
#include <string>
#include <vector>
#include <utility>
namespace dragonx {
namespace rpc {
@@ -28,6 +30,9 @@ struct ConnectionConfig {
bool use_embedded = true;
bool use_tls = false;
AuthSource auth_source = AuthSource::Missing;
// Non-empty when autoDetectConfig() could not create the data directory; callers
// should surface it and abort the connect rather than proceeding blindly.
std::string dir_error;
};
/**
@@ -69,6 +74,14 @@ public:
*/
static bool verifySaplingParams();
// Verify the Sapling params in `dir` against a { filename, expected-sha256-hex } list.
// Exposed with an injectable dir + digest list so the integrity + marker-cache logic is
// unit-testable without the real ~48MB params; verifySaplingParams() calls it with the
// pinned production digests and getSaplingParamsDir().
static bool verifySaplingParamsIn(
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests);
/**
* @brief Get the Sapling params directory
*/

View File

@@ -1316,6 +1316,8 @@ void I18n::loadBuiltinEnglish()
strings_["sb_extracting_sapling"] = "Extracting Sapling parameters...";
strings_["sb_sapling_failed"] = "Failed to extract Sapling parameters.";
strings_["sb_sapling_not_found"] = "Sapling parameters not found.";
strings_["sb_daemon_extract_failed"] = "Failed to write daemon files — check free disk space and permissions.";
strings_["sb_daemon_files_failed"] = "Failed to write daemon files to %s — check free disk space and permissions.";
strings_["sb_dragonxd_running"] = "dragonxd running";
strings_["sb_dragonxd_stopping"] = "Stopping dragonxd...";
strings_["sb_dragonxd_stopped"] = "dragonxd stopped";

View File

@@ -126,6 +126,27 @@ bool Platform::openUrl(const std::string& url)
#endif
}
bool Platform::ensureDirectory(const std::string& dir, std::string* outError)
{
if (dir.empty()) {
if (outError) *outError = "Cannot create directory: empty path.";
return false;
}
std::error_code ec;
if (std::filesystem::is_directory(dir, ec)) return true;
ec.clear();
std::filesystem::create_directories(dir, ec);
if (ec) {
if (outError) {
*outError = "Cannot create " + dir + ": " + ec.message() +
". Check permissions / free space.";
}
DEBUG_LOGF("[ERROR] ensureDirectory failed for %s: %s\n", dir.c_str(), ec.message().c_str());
return false;
}
return true;
}
bool Platform::openFolder(const std::string& path, bool createIfMissing)
{
if (path.empty()) return false;

View File

@@ -128,6 +128,17 @@ public:
*/
static void ensureObsidianDragonSetup();
/**
* @brief Create a directory (and parents) if missing, with a clear error on failure.
*
* Uses the non-throwing std::error_code overload internally. On failure sets *outError
* (when non-null) to one consistent, user-facing message:
* "Cannot create <dir>: <reason>. Check permissions / free space."
*
* @return true if the directory exists (already did, or was just created).
*/
static bool ensureDirectory(const std::string& dir, std::string* outError = nullptr);
/**
* @brief Get total system RAM in megabytes
* @return Total physical RAM in MB, or 0 on failure

View File

@@ -2478,6 +2478,105 @@ void testDaemonShutdownPolicy()
EXPECT_TRUE(bootstrap.disconnectRpc);
}
void testVerifySaplingParams()
{
using dragonx::rpc::Connection;
namespace fsn = std::filesystem;
fsn::path dir = fsn::temp_directory_path() / "od_sapling_test";
std::error_code rmec;
fsn::remove_all(dir, rmec);
fsn::create_directories(dir);
auto writeFile = [](const fsn::path& p, const std::string& content) {
std::ofstream(p.string(), std::ios::binary) << content;
};
const std::string spendContent = "fake-spend-params-contents";
const std::string outputContent = "fake-output-params-contents";
writeFile(dir / "sapling-spend.params", spendContent);
writeFile(dir / "sapling-output.params", outputContent);
const std::string spendHash = dragonx::util::sha256Hex(spendContent.data(), spendContent.size());
const std::string outputHash = dragonx::util::sha256Hex(outputContent.data(), outputContent.size());
const std::vector<std::pair<std::string, std::string>> good = {
{ "sapling-spend.params", spendHash },
{ "sapling-output.params", outputHash },
};
// Valid params → pass, and a verification marker is written.
EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good));
EXPECT_TRUE(fsn::exists(dir / ".sapling_verified"));
// Second call → marker fast-path, still true (round-trips the cache).
EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good));
// Wrong expected hash → integrity failure (fresh dir so no marker can short-circuit it).
fsn::path dir2 = fsn::temp_directory_path() / "od_sapling_test2";
fsn::remove_all(dir2, rmec);
fsn::create_directories(dir2);
writeFile(dir2 / "sapling-spend.params", spendContent);
writeFile(dir2 / "sapling-output.params", outputContent);
const std::vector<std::pair<std::string, std::string>> wrong = {
{ "sapling-spend.params", std::string(64, 'a') },
{ "sapling-output.params", outputHash },
};
EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir2.string(), wrong));
// Truncated content (size change) invalidates the marker AND fails the hash.
writeFile(dir / "sapling-spend.params", std::string("x"));
EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good));
// A missing param → fail.
fsn::remove(dir / "sapling-output.params", rmec);
EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good));
fsn::remove_all(dir, rmec);
fsn::remove_all(dir2, rmec);
}
void testPlatformEnsureDirectory()
{
using dragonx::util::Platform;
// An existing directory → true (temp_directory_path always exists).
{
std::string err = "sentinel";
EXPECT_TRUE(Platform::ensureDirectory(std::filesystem::temp_directory_path().string(), &err));
}
// A fresh nested path → created, no error.
{
std::filesystem::path base = std::filesystem::temp_directory_path() / "od_ensuredir_test";
std::error_code rmec; std::filesystem::remove_all(base, rmec);
std::filesystem::path nested = base / "a" / "b" / "c";
std::string err;
EXPECT_TRUE(Platform::ensureDirectory(nested.string(), &err));
EXPECT_TRUE(std::filesystem::is_directory(nested));
EXPECT_TRUE(err.empty());
std::filesystem::remove_all(base, rmec);
}
// Empty path → false with a message.
{
std::string err;
EXPECT_TRUE(!Platform::ensureDirectory("", &err));
EXPECT_TRUE(!err.empty());
}
// A path whose parent component is a regular file cannot be created. This fails the
// same way for root and non-root, so it's a stable negative case across environments.
{
std::filesystem::path f = std::filesystem::temp_directory_path() / "od_ensuredir_file";
std::error_code rmec; std::filesystem::remove_all(f, rmec);
{ std::ofstream(f.string()) << "x"; }
std::string err;
bool ok = Platform::ensureDirectory((f / "child").string(), &err);
std::filesystem::remove_all(f, rmec);
EXPECT_TRUE(!ok);
EXPECT_TRUE(err.find("Cannot create") != std::string::npos);
}
}
void testDatadirLockGate()
{
using dragonx::daemon::EmbeddedDaemon;
@@ -6643,6 +6742,8 @@ int main()
testWalletSecurityWorkflowExecutor();
testDaemonShutdownPolicy();
testDatadirLockGate();
testPlatformEnsureDirectory();
testVerifySaplingParams();
testDaemonLifecycleExecution();
testDaemonLifecycleAdapters();
testConsoleTextLayout();