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

@@ -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;
}
}
}