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:
35
src/app.cpp
35
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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
12
src/main.cpp
12
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user