Skip to main content

monetize_estate_side/
lib.rs

1//! # monetize-estate-side — the estate side of the ONE monetize's wire
2//!
3//! Monetize never calls into an estate (G1-pull, G16). Every estate calls the ONE monetize, and
4//! what that call looks like is the same for every product. Until P4a each product carried its
5//! own copy (A28 §4: Holger's `holger-fleet` and `holger-estate-front/src/shop_door.rs`,
6//! Gunnar's `gunnar-repair` and `gunnar-front/src/shop_door.rs`). This crate holds the one copy:
7//!
8//! | piece | module | feature |
9//! |---|---|---|
10//! | the box pump: the box key, `Fleet.Report` / `Orders` / `Ask`, verify under the monetize key, run through the product's executor, ack in the next report, `fleet/pulled.json` | [`pump`] | `pump` |
11//! | the pump's jera round (report, long-poll, run, report again; the refusal lines) | [`pump::duty`] | `pump` |
12//! | the sign-in-again link a sold box mints and mails: the order's checks, the lifetime, the words (pure; P4c) | [`sign_in`] | none |
13//! | the front's signed `SoldEstate` client (Upgrade, Recharge, TakeMaterial, UpdateTake, UpdateReport), its constants, its bench seam | [`sold_door`] | `sold-door` |
14//!
15//! What stays the product's: its order kinds and executor ([`pump::PumpBox`]), its health
16//! words, and what its front does with the shop's answers. The shared update step is
17//! Skidbladnir's (`nordisk_pairing::frontside::update`); [`sold_door::SoldDoor`] is its `Shop`.
18//!
19//! **The SoldEstate client is thin** (TASKS-4-v2 T16, A28 H16): the SoldEstate → Fleet merge
20//! swaps its transport ([`sold_door::Wire`]) and grows nothing here.
21
22#[cfg(feature = "pump")]
23pub mod pump;
24pub mod sign_in;
25#[cfg(feature = "sold-door")]
26pub mod sold_door;
27
28/// Unix ms now (0 before the epoch).
29pub fn now_unix_ms() -> u64 {
30    std::time::SystemTime::now()
31        .duration_since(std::time::UNIX_EPOCH)
32        .map(|d| d.as_millis() as u64)
33        .unwrap_or(0)
34}
35
36/// 32 bytes → 64 hex.
37pub fn hex(bytes: &[u8]) -> String {
38    bytes.iter().map(|b| format!("{b:02x}")).collect()
39}
40
41/// 64 hex (or 44 base64) → 32 bytes.
42pub fn key32(text: &str) -> Option<[u8; 32]> {
43    let t = text.trim();
44    if t.len() == 64 {
45        return unhex32(t, "the key").ok();
46    }
47    monetize_embed::signing::base64_decode(t)?.try_into().ok()
48}
49
50/// 64 hex → 32 bytes, refused by `what`'s name.
51pub fn unhex32(s: &str, what: &str) -> Result<[u8; 32], String> {
52    let s = s.trim();
53    if s.len() != 64 || !s.is_ascii() {
54        return Err(format!("{what} is not 64 hex"));
55    }
56    let v: Result<Vec<u8>, _> = (0..64)
57        .step_by(2)
58        .map(|i| u8::from_str_radix(&s[i..i + 2], 16))
59        .collect();
60    v.map_err(|_| format!("{what} is not 64 hex"))?
61        .try_into()
62        .map_err(|_| format!("{what} is not 32 bytes"))
63}
64
65/// Write `bytes` to `path` atomically (temp + fsync + rename), owner-only when `secret`.
66#[cfg(feature = "pump")]
67pub(crate) fn write_atomic(
68    path: &std::path::Path,
69    bytes: &[u8],
70    secret: bool,
71) -> std::io::Result<()> {
72    use std::io::Write;
73    let dir = path.parent().unwrap_or(std::path::Path::new("."));
74    std::fs::create_dir_all(dir)?;
75    let tmp = path.with_extension("tmp");
76    let mut opts = std::fs::OpenOptions::new();
77    opts.write(true).create(true).truncate(true);
78    #[cfg(unix)]
79    if secret {
80        use std::os::unix::fs::OpenOptionsExt;
81        opts.mode(0o600);
82    }
83    #[cfg(not(unix))]
84    let _ = secret;
85    let mut f = opts.open(&tmp)?;
86    f.write_all(bytes)?;
87    f.sync_all()?;
88    std::fs::rename(&tmp, path)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn a_key_is_64_hex_or_44_base64_and_anything_else_is_none() {
97        let k = [0x11u8; 32];
98        assert_eq!(key32(&hex(&k)), Some(k));
99        assert_eq!(key32(&format!("  {}\n", hex(&k))), Some(k), "trimmed");
100        assert_eq!(key32(&monetize_embed::signing::base64_encode(&k)), Some(k));
101        assert_eq!(key32("11"), None);
102        assert_eq!(key32(&"zz".repeat(32)), None);
103        assert!(unhex32(&"1".repeat(63), "the client key")
104            .unwrap_err()
105            .contains("the client key is not 64 hex"));
106        assert!(
107            unhex32(&"é".repeat(32), "k").is_err(),
108            "a non-ASCII text never panics on a char boundary"
109        );
110        assert!(now_unix_ms() > 1_700_000_000_000);
111    }
112}