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>
204 lines
9.7 KiB
C++
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
|