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(())
}
}