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

nordisk/monetize

crates/monetize-front/src/ledger_backup.rs

//! **monetize's export off the front: the bundle, the store names, the loopback export**
//! (gunnar's pattern, gunnar-front `ledger_backup.rs`; its shape, not its code).
//!
//! monetize runs in the front's process on a front that has ONE disk. Its state — the
//! ledger, usage, the paid signups, the reclamation queue, the vendor pass stamps, the
//! refunds — and its SIGNING KEY leave it as ONE deterministic bundle ([`bundle`],
//! [`encode`] / [`decode`]): the export's files ([`FILES`], nothing else from the directory)
//! and the front's secrets (the signing key, any `monetize/identity-*`). The export itself is
//! the RUNNING monetize's (`Ledger.Export` over loopback, [`loopback_export`]) into its
//! `backup_dir` ([`BACKUP_REL`], set by `intake::config_with`). monetize-admin's offsite
//! bundle (`offsite.rs`) is the one that leaves the box.
//!
//! The sealed per-product [`Store`] backup (`backup_once`, `tick`, the hourly schedule, the
//! restore onto a front laid again) went in monetize v2.0 step 1 (A25 1.8): no product ran it.
//! [`Store`] / [`MemStore`] and the object names stay for the product that keeps a store.

use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;

use monetize_server::backup as mb;

/// Where monetize writes its export (its `backup_dir` on the front).
pub const BACKUP_REL: &str = "monetize/backup";
/// The status of the last attempt.
pub const STATUS_REL: &str = "monetize/backup-status.toml";
/// Written once a laid-again front took its state back: never twice.
pub const RESTORED_REL: &str = "monetize/restored";
/// Where a restored export is written out before it is imported.
pub const RESTORE_REL: &str = "monetize/restore";
/// **The kit's word that THIS front was laid again**: `required`.
pub const FEED_RESTORE: &str = "monetize_restore";
/// Object names: `snapshot-<unix, 11 digits>-<digest, 32 hex>.mfb`.
pub const OBJECT_PREFIX: &str = "snapshot-";
pub const OBJECT_SUFFIX: &str = ".mfb";
/// **The files the export writes** — by monetize's names ([`mb::FILES`]) and
/// its manifest. Only these.
pub const FILES: [&str; 9] = [
    mb::MANIFEST,
    mb::FILES[0],
    mb::FILES[1],
    mb::FILES[2],
    mb::FILES[3],
    mb::FILES[4],
    mb::FILES[5],
    mb::FILES[6],
    mb::FILES[7],
];
/// The name a secret rides under in a bundle.
const SECRET: &str = "secret/";
const SIGNING: &str = "secret/signing.key";
const MAGIC_PLAIN: &[u8] = b"MFB1\n";

/// **Where a product keeps its front's backups** — holger: a raw repository
/// on its primary (which its twin follows). Objects are written once.
pub trait Store: Send + Sync {
    /// Where, for the lines that say so.
    fn describe(&self) -> String;
    /// Every object name held (an empty store is `Ok(vec![])`, never an error).
    fn list(&self) -> Result<Vec<String>, String>;
    fn get(&self, name: &str) -> Result<Vec<u8>, String>;
    fn put(&self, name: &str, bytes: &[u8]) -> Result<(), String>;
}

/// An in-memory [`Store`] (tests; a product's test rig).
#[derive(Default)]
pub struct MemStore(Mutex<BTreeMap<String, Vec<u8>>>);

impl Store for MemStore {
    fn describe(&self) -> String {
        "memory".into()
    }
    fn list(&self) -> Result<Vec<String>, String> {
        Ok(self.0.lock().unwrap_or_else(|p| p.into_inner()).keys().cloned().collect())
    }
    fn get(&self, name: &str) -> Result<Vec<u8>, String> {
        self.0.lock().unwrap_or_else(|p| p.into_inner()).get(name).cloned().ok_or_else(|| format!("{name}: not held"))
    }
    fn put(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
        self.0.lock().unwrap_or_else(|p| p.into_inner()).insert(name.to_string(), bytes.to_vec());
        Ok(())
    }
}

// ── names ────────────────────────────────────────────────────────────────────

/// Pure: an object's `(unix, digest)`, or `None` when the name is not one of ours.
pub fn parse_name(name: &str) -> Option<(u64, &str)> {
    let body = name.strip_prefix(OBJECT_PREFIX)?.strip_suffix(OBJECT_SUFFIX)?;
    let (unix, digest) = body.split_once('-')?;
    (unix.len() == 11 && digest.len() == 32 && digest.bytes().all(|c| c.is_ascii_hexdigit())).then_some(())?;
    Some((unix.parse().ok()?, digest))
}

/// Pure: the newest of ours among `names`.
pub fn newest(names: &[String]) -> Option<String> {
    names.iter().filter(|n| parse_name(n).is_some()).max().cloned()
}

/// Pure: the name of a new object — after `newest`, whatever `now` says.
pub fn next_name(now_unix: u64, digest: &str, newest: Option<&str>) -> String {
    let after = newest.and_then(parse_name).map(|(u, _)| u + 1).unwrap_or(0);
    format!("{OBJECT_PREFIX}{:011}-{digest}{OBJECT_SUFFIX}", now_unix.max(after))
}

// ── the bundle ───────────────────────────────────────────────────────────────

fn signing_key_path(root: &Path) -> PathBuf {
    monetize_server::config::Config { data_dir: root.join(crate::intake::DATA_REL), ..Default::default() }.signing_key_path()
}

/// The identities this front minted (`monetize/identity-*`), by file name.
fn identities(root: &Path) -> Vec<(String, PathBuf)> {
    let mut v: Vec<(String, PathBuf)> = std::fs::read_dir(root.join(crate::intake::MONETIZE_REL))
        .into_iter()
        .flatten()
        .flatten()
        .filter_map(|e| {
            let n = e.file_name().to_string_lossy().to_string();
            (n.starts_with("identity-") && !n.ends_with(".pub") && !n.contains(".staging") && e.path().is_file()).then(|| (n, e.path()))
        })
        .collect();
    v.sort();
    v
}

/// Pure: entries → the deterministic bundle (sorted by name).
pub fn encode(entries: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
    let mut out = MAGIC_PLAIN.to_vec();
    for (name, data) in entries {
        out.extend_from_slice(&(name.len() as u16).to_be_bytes());
        out.extend_from_slice(name.as_bytes());
        out.extend_from_slice(&(data.len() as u64).to_be_bytes());
        out.extend_from_slice(data);
    }
    out
}

/// Pure: the bundle → its entries, refused whole when it is not one.
pub fn decode(b: &[u8]) -> Result<BTreeMap<String, Vec<u8>>, String> {
    let bad = |w: &str| format!("[restore-bundle] {w}");
    let mut rest = b.strip_prefix(MAGIC_PLAIN).ok_or_else(|| bad("not a monetize-front backup"))?;
    let mut out = BTreeMap::new();
    while !rest.is_empty() {
        let take = |r: &mut &[u8], n: usize| -> Result<Vec<u8>, String> {
            if r.len() < n {
                return Err(bad("truncated"));
            }
            let (h, t) = r.split_at(n);
            *r = t;
            Ok(h.to_vec())
        };
        let nl = u16::from_be_bytes(take(&mut rest, 2)?.try_into().map_err(|_| bad("length"))?) as usize;
        let name = String::from_utf8(take(&mut rest, nl)?).map_err(|_| bad("a name is not UTF-8"))?;
        if name.contains("..") || name.starts_with('/') {
            return Err(bad(&format!("`{name}` is not a name this backup writes")));
        }
        let dl = u64::from_be_bytes(take(&mut rest, 8)?.try_into().map_err(|_| bad("length"))?) as usize;
        out.insert(name, take(&mut rest, dl)?);
    }
    Ok(out)
}

/// **The bundle of `root`** after an export into `dir`: [`FILES`] (the
/// manifest required), the signing key (required), the identities.
pub fn bundle(root: &Path, dir: &Path) -> Result<Vec<u8>, String> {
    let mut e = BTreeMap::new();
    for f in FILES {
        match std::fs::read(dir.join(f)) {
            Ok(b) => {
                e.insert(f.to_string(), b);
            }
            Err(x) if f == mb::MANIFEST => return Err(format!("[backup-export] {}: {x}", dir.join(f).display())),
            Err(_) => {}
        }
    }
    let sk = signing_key_path(root);
    e.insert(SIGNING.to_string(), std::fs::read(&sk).map_err(|x| format!("[backup-secrets] {}: {x}", sk.display()))?);
    for (n, p) in identities(root) {
        e.insert(format!("{SECRET}{n}"), std::fs::read(&p).map_err(|x| format!("[backup-secrets] {}: {x}", p.display()))?);
    }
    Ok(encode(&e))
}

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

/// Pure: the digest an object is named by (sha256 of the bundle, 32 hex).
pub fn digest(plain: &[u8]) -> String {
    hex(ring::digest::digest(&ring::digest::SHA256, plain).as_ref())[..32].to_string()
}

// ── the export over loopback ─────────────────────────────────────────────────

/// **Ask the running monetize at `grpc` to export** (`Ledger.Export`, bearer
/// `token`) and check it wrote where it is expected: the RPC takes no path.
pub fn loopback_export(grpc: SocketAddr, token: String) -> impl Fn(&Path) -> Result<(), String> + Send + Sync + 'static {
    move |dir: &Path| {
        let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| format!("[backup-export] no runtime: {e}"))?;
        let wrote = rt.block_on(async {
            let channel = tonic::transport::Endpoint::from_shared(format!("http://{grpc}"))
                .map_err(|e| e.to_string())?
                .connect_timeout(Duration::from_secs(10))
                .timeout(Duration::from_secs(300))
                .connect()
                .await
                .map_err(|e| format!("monetize does not answer on {grpc}: {e}"))?;
            let mut client = monetize_proto::ledger_client::LedgerClient::new(channel);
            let mut req = tonic::Request::new(monetize_proto::ExportBackupRequest {});
            req.metadata_mut().insert("authorization", format!("Bearer {token}").parse().map_err(|_| "the monetize bearer is not a header value".to_string())?);
            let out = client.export(req).await.map_err(|s| format!("Ledger.Export on {grpc}: {:?}: {}", s.code(), s.message()))?;
            Ok::<_, String>(out.into_inner().dir)
        })?;
        if Path::new(&wrote) != dir {
            return Err(format!("[backup-export] monetize wrote its export to {wrote}, not {} — its backup_dir is not the front's", dir.display()));
        }
        Ok(())
    }
}