Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-admin/src/restore.rs

//! **The ONE box brought back from its off-box copy** (STINKY ST-12, 2026-10-11: the copy
//! was packed, sealed and shipped, and nothing could put it back on a box).
//!
//! 1. The operator opens the copy on their OWN machine (`monetize-admin offsite open`, the
//!    vault identity never reaches a box) and the kit puts the opened entries on a FRESH box's
//!    door: `POST /restore`, operator only, `{entry: hex}` ([`entries_of_json`]).
//! 2. [`stage`]: the copy is verified as the drill verifies it (its export imports under its
//!    own signing root, its custody has its KEK), the box is judged FRESH (no estate joined, no
//!    custody sealed, never restored) and the copy is kept at [`STAGED_REL`] (0600). Nothing
//!    else is written while monetize may be running.
//! 3. [`apply_staged`]: at the next start, PID 1 — BEFORE monetize's key is prepared and
//!    before any child runs — writes the copy's `data/` (the signing root, the custody, the
//!    journals, the joined estates), its identities and its export back, moves the fresh
//!    box's own stores aside, imports the ledger, and records what it did at [`APPLIED_REL`].
//!    monetize then offers the RECORDED key again: the estates' trust holds.
//!
//! What is said: counts and the signing root's PUBLIC key, never a byte of a secret.

use std::collections::BTreeMap;
use std::path::Path;

use monetize_front::{intake, ledger_backup as lb};
use monetize_server::backup as mb;

/// Where a staged copy waits for the next start (0600).
pub const STAGED_REL: &str = "monetize/restore/staged.mfb";
/// What the last restore did.
pub const APPLIED_REL: &str = "monetize/restore/applied.toml";
/// Where the fresh box's own stores are moved at the restore (never deleted).
pub const REPLACED_REL: &str = "monetize/restore/replaced";
/// The stores the export carries (imported, never copied as raw files).
const STORES: [&str; 4] = ["ledger.redb", "usage.redb", "signups.redb", "reconcile.redb"];

fn hex(b: &[u8]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

fn unhex(s: &str) -> Option<Vec<u8>> {
    if s.len() % 2 != 0 {
        return None;
    }
    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
}

/// **`POST /restore`'s body**: a JSON object of the opened copy's entries, each `entry` →
/// its bytes as hex. Refused by name (`[restore-json]`) otherwise.
pub fn entries_of_json(body: &[u8]) -> Result<BTreeMap<String, Vec<u8>>, String> {
    let m: BTreeMap<String, String> = serde_json::from_slice(body).map_err(|e| format!("[restore-json] the body is not a JSON object of entry → hex: {e}"))?;
    m.into_iter().map(|(k, v)| unhex(v.trim()).map(|b| (k.clone(), b)).ok_or_else(|| format!("[restore-json] `{k}` is not hex"))).collect()
}

/// A name the copy may write: relative, no `.`/`..`, no empty component.
fn safe(k: &str) -> bool {
    !k.starts_with('/') && !k.split('/').any(|c| c.is_empty() || c == "." || c == "..")
}

/// **Is this box fresh?** No estate joined, no custody sealed, never restored. Answers the
/// things that say it is not.
fn not_fresh(root: &Path) -> Vec<String> {
    let mut why = Vec::new();
    if root.join(APPLIED_REL).is_file() {
        why.push(format!("{APPLIED_REL} (this box was restored already)"));
    }
    let data = root.join(intake::DATA_REL);
    for (dir, ext) in [("custody", "sealed"), (monetize_server::fleet::JOINED_REL, "json")] {
        if let Ok(rd) = std::fs::read_dir(data.join(dir)) {
            let mut names: Vec<String> = rd.flatten().map(|e| e.file_name().to_string_lossy().to_string()).filter(|n| n.rsplit_once('.').is_some_and(|(_, x)| x == ext)).collect();
            names.sort();
            why.extend(names.into_iter().map(|n| format!("{dir}/{n}")));
        }
    }
    why
}

/// **Stage an opened copy on this box** (module docs, step 2). Refused by name: a copy that
/// does not verify (`[offsite-verify]`), a box that is not fresh (`[restore-not-fresh]`).
pub fn stage(root: &Path, entries: &BTreeMap<String, Vec<u8>>) -> Result<String, String> {
    if let Some(bad) = entries.keys().find(|k| !safe(k)) {
        return Err(format!("[restore-shape] `{bad}` is not a name a copy writes"));
    }
    let busy = not_fresh(root);
    if !busy.is_empty() {
        return Err(format!("[restore-not-fresh] this box already serves ({}): a restore goes onto a FRESH box, never over a live one", busy.join(", ")));
    }
    let v = crate::offsite::verify(entries, &root.join("monetize/restore/.verify"))?;
    write_file(&root.join(STAGED_REL), &lb::encode(entries)).map_err(|e| format!("[restore-store] {e}"))?;
    Ok(format!(
        "staged: {} entries, {} ledger rows, {} signups, {} custody sealed, signing root {} — restored at the box's next start, before monetize runs",
        v.entries, v.ledger_rows, v.signups, v.custody_sealed, v.signing_public_key
    ))
}

/// Whether a copy waits for the next start.
pub fn staged(root: &Path) -> bool {
    root.join(STAGED_REL).is_file()
}

fn write_file(p: &Path, bytes: &[u8]) -> Result<(), String> {
    use std::io::Write as _;
    use std::os::unix::fs::OpenOptionsExt as _;
    if let Some(parent) = p.parent() {
        use std::os::unix::fs::DirBuilderExt as _;
        std::fs::DirBuilder::new().recursive(true).mode(0o700).create(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
    }
    let tmp = p.with_extension("restore-staging");
    let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
    f.write_all(bytes).and_then(|_| f.sync_all()).map_err(|e| format!("{}: {e}", tmp.display()))?;
    std::fs::rename(&tmp, p).map_err(|e| format!("{}: {e}", p.display()))
}

/// **Apply a staged copy** (module docs, step 3) — PID 1, before monetize prepares its key
/// or any child runs. `Ok(None)`: nothing staged. The staged copy is removed only after the
/// ledger imported; a failed apply leaves it staged and says why.
pub fn apply_staged(root: &Path) -> Result<Option<String>, String> {
    let at = root.join(STAGED_REL);
    let bytes = match std::fs::read(&at) {
        Ok(b) => b,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(format!("[restore-apply] {}: {e}", at.display())),
    };
    let fail = |w: String| format!("[restore-apply] {w}");
    let entries = lb::decode(&bytes).map_err(fail)?;
    let v = crate::offsite::verify(&entries, &root.join("monetize/restore/.verify"))?;
    let data = root.join(intake::DATA_REL);
    // The fresh box's own stores, moved aside (never deleted): the copy's are imported.
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or_default();
    let aside = root.join(REPLACED_REL).join(now.to_string());
    for s in STORES {
        let p = data.join(s);
        if p.exists() {
            std::fs::create_dir_all(&aside).map_err(|e| fail(format!("{}: {e}", aside.display())))?;
            std::fs::rename(&p, aside.join(s)).map_err(|e| fail(format!("{}: {e}", p.display())))?;
        }
    }
    let export = root.join(lb::BACKUP_REL);
    for (k, b) in &entries {
        if !safe(k) {
            return Err(fail(format!("`{k}` is not a name a copy writes")));
        }
        let target = if let Some(r) = k.strip_prefix("data/") {
            data.join(r)
        } else if let Some(r) = k.strip_prefix("identity/") {
            root.join(intake::MONETIZE_REL).join(r)
        } else if let Some(r) = k.strip_prefix("export/") {
            export.join(r)
        } else {
            continue;
        };
        write_file(&target, b).map_err(fail)?;
    }
    let cfg = monetize_server::config::Config { data_dir: data.clone(), ..Default::default() };
    let signer = monetize::Signer::from_path(&cfg.signing_key_path()).map_err(|e| fail(format!("the restored signing root does not read: {e}")))?;
    let pk = hex(&signer.public_key());
    let m = {
        let ledger = monetize::Ledger::open(&cfg.ledger_path()).map_err(|e| fail(format!("ledger: {e}")))?;
        let usage = monetize_server::state::UsageStore::open(&cfg.usage_path()).map_err(|e| fail(format!("usage: {e}")))?;
        let signups = monetize_server::signup::SignupStore::open(&cfg.signup_path()).map_err(|e| fail(format!("signups: {e}")))?;
        let reconcile = monetize_server::reconcile::ReconcileStore::open(&cfg.reconcile_path()).map_err(|e| fail(format!("reconcile: {e}")))?;
        let nonpay = monetize_server::nonpay::NonpayJournal::open(cfg.nonpay_path()).map_err(|e| fail(format!("nonpay: {e}")))?;
        let stores = mb::Stores { ledger: &ledger, usage: &usage, signups: &signups, reconcile: &reconcile, nonpay: &nonpay };
        mb::import(&export, &stores, &pk, false).map_err(|e| fail(format!("the export does not import under the restored signing root: {e}")))?
    };
    let said = format!(
        "restored: {} entries, {} ledger rows, {} signups, {} custody sealed, signing root {pk} (the box's own stores kept under {})",
        v.entries,
        m.ledger_rows,
        m.signups,
        v.custody_sealed,
        aside.display()
    );
    let record = toml::to_string(&BTreeMap::from([
        ("at_unix", now.to_string()),
        ("signing_public_key", pk),
        ("ledger_rows", m.ledger_rows.to_string()),
        ("custody_sealed", v.custody_sealed.to_string()),
    ]))
    .unwrap_or_default();
    monetize_front::write_secret(root, APPLIED_REL, &record).map_err(|e| fail(format!("{APPLIED_REL}: {e}")))?;
    std::fs::remove_file(&at).map_err(|e| fail(format!("{}: {e}", at.display())))?;
    Ok(Some(said))
}