diff --git a/docs/daemon-startup-hardening.md b/docs/daemon-startup-hardening.md index 5aa315d..e44174e 100644 --- a/docs/daemon-startup-hardening.md +++ b/docs/daemon-startup-hardening.md @@ -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 (~2–3h) · **Status:** ☐ +**Severity:** Medium · **Effort:** S (~2–3h) · **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 (~3–5h) · **Status:** ☐ +**Severity:** Medium · **Effort:** S (~3–5h) · **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 (~3–4h) · **Status:** ☐ +**Severity:** Medium · **Effort:** S (~3–4h) · **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 `/.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. diff --git a/src/app.cpp b/src/app.cpp index 9258b5d..1c4569f 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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 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; + } } } diff --git a/src/app_network.cpp b/src/app_network.cpp index 72824e6..1e12fc1 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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; diff --git a/src/main.cpp b/src/main.cpp index 31a404a..68af202 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 diff --git a/src/rpc/connection.cpp b/src/rpc/connection.cpp index 276cbaa..ecd30c2 100644 --- a/src/rpc/connection.cpp +++ b/src/rpc/connection.cpp @@ -14,8 +14,12 @@ #include #include #include +#include +#include #include "../util/logger.h" +#include "../util/platform.h" +#include "../util/xmrig_updater.h" // util::sha256Hex #ifdef _WIN32 #include @@ -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 +} + +// ":" 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(mtime.time_since_epoch()).count(); + return std::to_string(static_cast(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 buf(static_cast(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: /.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& 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 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& 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 kSaplingParamDigests[] = { + { "sapling-spend.params", "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" }, + { "sapling-output.params", "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" }, +}; + +} // namespace + +bool Connection::verifySaplingParamsIn( + const std::string& dir, + const std::vector>& 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 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 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> 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 diff --git a/src/rpc/connection.h b/src/rpc/connection.h index 2ee4575..734c598 100644 --- a/src/rpc/connection.h +++ b/src/rpc/connection.h @@ -5,6 +5,8 @@ #pragma once #include +#include +#include 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>& digests); + /** * @brief Get the Sapling params directory */ diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index b49b19e..13c1288 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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"; diff --git a/src/util/platform.cpp b/src/util/platform.cpp index 6ebe8bd..c0f7d4f 100644 --- a/src/util/platform.cpp +++ b/src/util/platform.cpp @@ -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; diff --git a/src/util/platform.h b/src/util/platform.h index 35ea134..f5fbc79 100644 --- a/src/util/platform.h +++ b/src/util/platform.h @@ -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 : . 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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index d868e22..2a59ff4 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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> 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> 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();