Files
ObsidianDragon/src/util/bootstrap.h
T
DanSandClaude Opus 4.8 667c89ba51 fix(bootstrap): resume downloads without discarding a valid archive
Resuming a partial download leaned entirely on the remote size from a
HEAD request. When that size could not be determined the code played it
safe by deleting the partial file and starting over, so a flaky HEAD (or
an offline mirror) threw away gigabytes that were fine to resume. It also
could not tell a real size from an error page, and if the server answered
a ranged request with the whole file (200, not 206) libcurl could append
it to the partial and silently corrupt the archive.

- The HEAD now returns transport status, HTTP code, and size together,
  and a size counts as authoritative only on a 2xx reply with a positive
  Content-Length.
- A pure helper decides what to do from the existing bytes and that
  authoritative flag: start fresh, truncate a stale oversized file, reuse
  a complete one, or resume with a ranged GET. When the size is not
  authoritative the partial is kept and a ranged GET is attempted rather
  than discarded.
- A ranged request that comes back as 200 instead of 206 now restarts
  cleanly from scratch instead of appending to the partial.
- The free-space pre-check runs only when the size is authoritative, and
  a failed download reports how many bytes were kept for the retry.

Tests cover the resume-decision helper across the fresh, stale, complete,
resuming, and non-authoritative cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 20:57:21 -05:00

204 lines
9.7 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include <string>
#include <vector>
#include <mutex>
#include <atomic>
#include <chrono>
#include <thread>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <system_error>
namespace dragonx {
namespace util {
/**
* @brief Bootstrap downloader + extractor for DRAGONX blockchain data.
*
* Downloads a blockchain snapshot zip from bootstrap.dragonx.is, verifies
* SHA-256 / MD5 checksums, extracts it into the DRAGONX data directory
* (skipping wallet.dat), and cleans old chain data before extraction.
* All work runs on a background thread; progress is queried thread-safely
* from the UI thread.
*
* The old chain data is removed only after a preflight passes (every archive entry is safe and the
* disk has room for the extracted data). From that point the run is not cancellable by the user
* (Progress::cancellable), and a failed or aborted extraction removes the partial chain again before
* the result is published, so dragonxd never starts on a half-written blocks/ + chainstate/.
*/
class Bootstrap {
public:
enum class State {
Idle,
Downloading,
Verifying,
Extracting,
Completed,
Failed
};
struct Progress {
State state = State::Idle;
double downloaded_bytes = 0;
double total_bytes = 0; // 0 if server doesn't send Content-Length
float percent = 0.0f; // 0..100
std::string status_text; // human-readable status
std::string error; // non-empty on failure
bool cancellable = true; // false from the removal of the old chain data onwards
};
/// What extract() would write: the preflight result for an archive.
struct ArchivePlan {
bool ok = false;
std::uint64_t bytes = 0; // uncompressed bytes of the members that would be extracted
std::string error; // set when !ok
};
using DaemonUpFn = std::function<bool()>;
using SpaceFn = std::function<std::filesystem::space_info(const std::string&, std::error_code&)>;
Bootstrap() = default;
~Bootstrap();
// Non-copyable
Bootstrap(const Bootstrap&) = delete;
Bootstrap& operator=(const Bootstrap&) = delete;
/// Base URL for bootstrap downloads (zip + checksum files).
static constexpr const char* kBaseUrl = "https://bootstrap.dragonx.is";
static constexpr const char* kMirrorUrl = "https://bootstrap2.dragonx.is";
static constexpr const char* kZipName = "DRAGONX.zip";
/// Start the bootstrap process on a background thread.
/// @param dataDir Path to DRAGONX data dir (e.g. ~/.hush/DRAGONX/)
/// @param url Bootstrap zip URL
void start(const std::string& dataDir,
const std::string& url = "https://bootstrap.dragonx.is/DRAGONX.zip");
/// Test seam: run only the install half (daemon-exit wait, preflight, clean, extract) on a local zip,
/// skipping the download and checksum steps. daemonUp defaults to the real probe, spaceFn to
/// std::filesystem::space.
void startFromZip(const std::string& dataDir, const std::string& zipPath,
DaemonUpFn daemonUp = {}, SpaceFn spaceFn = {});
/// Cancel an in-progress download/extract. Unconditional: for quit and the destructor, which may abort
/// the destructive window too (the partial chain is then removed again).
void cancel();
/// The user's Cancel: refused (returns false, nothing changes) once the destructive window has started
/// (Progress::cancellable false). The window's start re-checks the request under the same lock, so a
/// click either lands before the old chain data is touched or not at all.
bool requestCancel();
/// Quit: true once leaving the worker to the process exit can no longer leave a part-replaced chain: it
/// has finished, or it is cancelled and has not opened the destructive window (the window's start
/// re-checks the cancel under the same lock, so it never will).
bool safeToAbandon() const;
/// Quit: cancel() and wait for the worker until `deadline`. True = finished (the object can be destroyed);
/// false = still running, and the caller must leave it to the process exit (destroying it would join).
bool cancelAndWait(std::chrono::steady_clock::time_point deadline);
/// Thread-safe read of current progress.
Progress getProgress() const;
/// Whether the operation is finished (success or failure). True only once the worker has made its
/// last filesystem change.
bool isDone() const;
/// Format a byte count as human-readable string (e.g. "1.24 GB")
static std::string formatSize(double bytes);
/// Preflight: open the zip, refuse it if any entry is unsafe (zip-slip; also any ':', an 8.3 "~N"
/// alias, or a component of only dots/spaces other than ".") or unreadable, and sum the
/// uncompressed size of the members extract() would write (directories and protected names excluded).
static ArchivePlan planArchive(const std::string& zipPath);
/// Bytes that cleanChainData() would free on the data dir's filesystem: regular files under
/// blocks/, chainstate/ and notarizations/. A symlinked directory (or file) counts 0, since only the
/// link is removed.
static std::uint64_t reclaimableBytes(const std::string& dataDir);
/// False = refuse (with *msg set). Needs avail + reclaimable >= needed + max(256 MiB, needed / 20).
/// "Unknown" (capacity 0 or avail == UINT64_MAX; the caller also treats an error as unknown) returns
/// true. avail 0 with a known capacity is a full disk, which is a refusal.
static bool spaceSufficient(std::uint64_t needed, std::uint64_t avail, std::uint64_t capacity,
std::uint64_t reclaimable, std::string* msg);
/// Snapshot members that are never extracted: any wallet* name, database/, *.conf, .cookie, and
/// top-level *.dat other than fee_estimates/peers/banlist. Judged case-insensitively on the name the
/// member lands on: "." components dropped, trailing dots/spaces and an NTFS stream suffix (":...")
/// stripped from each component, so "./wallet.dat" and "DRAGONX.conf." are protected too.
static bool isProtectedEntry(const std::string& name);
/// First whitespace-delimited token of a checksum file, lowercased, iff it is exactly
/// expectedHexLen hex chars; "" otherwise (an HTTP error page is never a checksum).
static std::string parseChecksumFile(const std::string& content, std::size_t expectedHexLen);
enum class ResumeAction { Fresh, TruncateAndFresh, ReuseComplete, RangedGet };
/// Pure, no-I/O. authoritative = HEAD was 2xx with a positive Content-Length.
static ResumeAction decideResume(long long existingBytes, bool authoritative, long long remoteSize);
private:
mutable std::mutex mutex_;
Progress progress_;
std::atomic<bool> cancel_requested_{false};
std::thread worker_;
std::atomic<bool> worker_running_{false};
// Worker bodies. The caller sets worker_running_ = false after they return; each one publishes its
// terminal state (Failed/Completed) as its last action.
void runDownloadAndInstall(const std::string& dataDir, const std::string& url);
void installFromZip(const std::string& dataDir, const std::string& zipPath,
const DaemonUpFn& daemonUp, const SpaceFn& spaceFn);
void beginRun();
bool download(const std::string& url, const std::string& destZip);
/// Never publishes a state: returns false with *err set.
bool extract(const std::string& zipPath, const std::string& dataDir, std::string* err);
/// True iff blocks/, chainstate/ and notarizations/ are absent afterwards.
bool cleanChainData(const std::string& dataDir);
void setProgress(State state, const std::string& text, double downloaded = 0, double total = 0);
/// Compute SHA-256 of a file, return lowercase hex digest.
/// pctBase/pctRange map file progress onto a portion of the overall percent.
std::string computeSHA256(const std::string& filePath, float pctBase = 0.0f, float pctRange = 100.0f);
/// Compute MD5 of a file, return lowercase hex digest.
/// pctBase/pctRange map file progress onto a portion of the overall percent.
std::string computeMD5(const std::string& filePath, float pctBase = 0.0f, float pctRange = 100.0f);
/// Verify downloaded zip against remote checksums. Returns true if valid.
bool verifyChecksums(const std::string& zipPath, const std::string& baseUrl);
struct RemoteInfo { bool transportOk = false; long httpCode = 0; long long size = -1; };
/// HEAD the remote file. transportOk=false on any curl error; size=-1 when absent.
RemoteInfo getRemoteFileSize(const std::string& url);
/// Byte offset of the partial file when resuming a download.
/// Added to dlnow in progress reports so the bar reflects true total.
double resume_offset_{0};
/// Set by download() on failure (worker thread only): the CURLcode of the transfer and, for a
/// failure that is not a transfer error, the reason.
int last_curl_{0};
std::string download_error_;
/// Set by verifyChecksums (worker thread only): true only on a real digest MISMATCH, so the caller
/// deletes the archive only then - never for a missing/unusable sidecar or a transient fetch error.
bool last_verify_mismatch_{false};
// libcurl progress callback (static → calls into instance via clientp)
static int progressCallback(void* clientp, long long dltotal, long long dlnow,
long long ultotal, long long ulnow);
};
} // namespace util
} // namespace dragonx