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

nordisk/monetize

crates/monetize-shop-pairing/src/sale_proof.rs

//! **The sale check, run from data** (A28 H12, v2.0 P3): a
//! [`SaleProof`](monetize_server::facts::SaleProof) (monetize-product-guest's `facts`) is the
//! product's own list of typed checks (a route, its method and body, and what its answer must
//! hold), declared by its guest; [`prove`] runs it over the sold front and [`first_door`] asks
//! its first-admin door. The shop holds no product's proof of its own.
//!
//! Each product names its own routes: there is no shared `/auth/first-admin`.

use monetize_server::facts::{ProofAsk, ProofExpect, ProofMethod, SaleProof};
use monetize_server::estate_order::FirstDoor;
use serde_json::Value;

use crate::sale_check::{excerpt, query, scrub, Front};

/// The words a check is asked with.
struct Words<'a> {
    account: &'a str,
    invite: &'a str,
    bearer: &'a str,
}

impl Words<'_> {
    /// The route, its `{invite}` and `{account}` percent-encoded.
    fn path(&self, path: &str) -> String {
        path.replace("{invite}", &query(self.invite)).replace("{account}", &query(self.account))
    }
    /// The body, its string leaves `{invite}` and `{account}` (trimmed) filled in.
    fn body(&self, v: &Value) -> Value {
        match v {
            Value::String(s) => Value::String(s.replace("{invite}", self.invite).replace("{account}", self.account.trim())),
            Value::Array(a) => Value::Array(a.iter().map(|x| self.body(x)).collect()),
            Value::Object(o) => Value::Object(o.iter().map(|(k, x)| (k.clone(), self.body(x))).collect()),
            other => other.clone(),
        }
    }
    fn quiet(&self, s: &str) -> String {
        scrub(s, &[self.invite, self.bearer])
    }
}

/// `METHOD https://<host><path>`, the query withheld.
fn shown(host: &str, ask: &ProofAsk, path: &str) -> String {
    let method = match ask.method {
        ProofMethod::Get => "GET",
        ProofMethod::Post => "POST",
    };
    match path.split_once('?') {
        Some((p, _)) => format!("{method} https://{host}{p}?…"),
        None => format!("{method} https://{host}{path}"),
    }
}

/// One request, as `ask` says it.
fn send(front: &dyn Front, ask: &ProofAsk, w: &Words<'_>) -> Result<(u16, String), String> {
    let path = w.path(&ask.path);
    let bearer = ask.bearer.then_some(w.bearer);
    match ask.method {
        ProofMethod::Get => front.get(&path, bearer),
        ProofMethod::Post => front.post(&path, &ask.body.as_ref().map(|b| w.body(b).to_string()).unwrap_or_default(), bearer),
    }
}

/// Pure: does `answer` hold `e`? `Ok` carries what a list rule found, for the words the check
/// says (`exactly one alice/welcome`); `Err` says what the answer lacks. `json` is the body
/// read once.
fn holds(e: &ProofExpect, body: &str, json: Option<&Value>, w: &Words<'_>) -> Result<Option<String>, String> {
    let json = || json.ok_or_else(|| "the answer is not JSON".to_string());
    match e {
        ProofExpect::Contains { text } => body.contains(text.as_str()).then_some(None).ok_or_else(|| format!("its answer does not hold {text}")),
        ProofExpect::Equals { field, value } => {
            let got = json()?.pointer(field).cloned().unwrap_or(Value::Null);
            (got == *value).then_some(None).ok_or_else(|| format!("{field} is {got}, not {value}"))
        }
        ProofExpect::Text { field } => json()?.pointer(field).and_then(Value::as_str).filter(|s| !s.is_empty()).map(|_| None).ok_or_else(|| format!("{field} is no text (no passkey challenge)")),
        ProofExpect::ExactlyOne { list, name_field, name, id_field, id } => {
            let items = json()?.pointer(list).and_then(Value::as_array).ok_or_else(|| format!("{list} lists no items"))?;
            let want = id.replace("{account}", w.account);
            let named: Vec<&str> = items.iter().filter(|i| i[name_field.as_str()].as_str() == Some(name.as_str())).map(|i| i[id_field.as_str()].as_str().unwrap_or("?")).collect();
            (named == [want.as_str()]).then(|| Some(format!("exactly one {want}"))).ok_or_else(|| format!("holds {} items named {name:?} ({}), not exactly {want}", named.len(), named.join(", ")))
        }
        ProofExpect::NotEmpty { list, name_field } => {
            let items = json()?.pointer(list).and_then(Value::as_array).ok_or_else(|| format!("{list} carries no list"))?;
            let names: Vec<&str> = items.iter().filter_map(|i| i[name_field.as_str()].as_str()).collect();
            (!names.is_empty()).then(|| Some(format!("{} ({})", names.len(), names.join(", ")))).ok_or_else(|| format!("{list} lists nothing"))
        }
    }
}

/// **The sale check from data**: does `https://<host>` serve what was sold to `account`, by
/// `proof`'s checks in order? `Ok` says what it proved; `Err` names the first check that did
/// not hold (`<what>: …`). Neither ever says the invite or the bearer.
pub fn prove(proof: &SaleProof, front: &dyn Front, host: &str, account: &str, bearer: &str, invite: &str) -> Result<String, String> {
    let w = Words { account, invite, bearer };
    if proof.needs_account && account.trim().is_empty() {
        return Err("no account was sold, so there is nothing to prove".into());
    }
    let mut proved = Vec::new();
    for check in &proof.checks {
        let path = w.path(&check.ask.path);
        let shown = shown(host, &check.ask, &path);
        let body = match send(front, &check.ask, &w) {
            Ok((200, body)) => body,
            Ok((status, body)) => return Err(w.quiet(&format!("{}: {shown} answered HTTP {status}: {}", check.what, excerpt(&body)))),
            Err(e) => return Err(w.quiet(&format!("{}: {shown} did not answer: {e}", check.what))),
        };
        let json = serde_json::from_str::<Value>(&body).ok();
        let mut found = Vec::new();
        for e in &check.expect {
            found.extend(holds(e, &body, json.as_ref(), &w).map_err(|why| w.quiet(&format!("{}: {shown}: {why}", check.what)))?);
        }
        let said = if found.is_empty() { check.what.clone() } else { format!("{} ({})", check.what, found.join(", ")) };
        if !proved.contains(&said) {
            proved.push(said);
        }
    }
    Ok(format!("https://{host} serves {}", proved.join(", ")))
}

/// **The first-admin door from data**: spent,
/// open to `invite` (a passkey challenge, nothing spent), or closed to it. A product whose
/// proof has no first door is refused by name. Never says the invite.
pub fn first_door(proof: &SaleProof, front: &dyn Front, host: &str, account: &str, invite: &str) -> Result<FirstDoor, String> {
    let d = proof.first_door.as_ref().ok_or_else(|| format!("REFUSED [sale-proof-no-first-door] https://{host}'s product names no first-admin door"))?;
    let w = Words { account, invite, bearer: "" };
    let answered = |what: &str, got: Result<(u16, String), String>| -> Result<(u16, String), String> { got.map_err(|e| w.quiet(&format!("{what} did not answer: {e}"))) };
    let status_path = w.path(&d.status.path);
    let (st, body) = answered(&format!("GET https://{host}{status_path}"), send(front, &d.status, &w))?;
    if st != 200 {
        return Err(w.quiet(&format!("GET https://{host}{status_path} answered HTTP {st}: {}", excerpt(&body))));
    }
    let status: Value = serde_json::from_str(&body).map_err(|e| format!("https://{host}{status_path} is not the console's status: {e}"))?;
    if status.pointer(&d.spent) == Some(&Value::Bool(true)) {
        return Ok(FirstDoor::Spent);
    }
    if status.pointer(&d.enabled) != Some(&Value::Bool(true)) {
        return Ok(FirstDoor::Closed(format!("https://{host} offers no passkeys")));
    }
    let shown = shown(host, &d.start, &w.path(&d.start.path));
    let challenged = |body: &str| serde_json::from_str::<Value>(body).ok().and_then(|v| v.pointer(&d.challenge).and_then(Value::as_str).map(|s| !s.is_empty())).unwrap_or(false);
    match answered(&shown, send(front, &d.start, &w))? {
        (200, body) if challenged(&body) => Ok(FirstDoor::Open),
        (200, _) => Ok(FirstDoor::Closed(format!("{shown} gave no passkey challenge for the invite"))),
        (st, body) => Ok(FirstDoor::Closed(w.quiet(&format!("{shown} answered HTTP {st}: {}", excerpt(&body))))),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A front that answers each path from a table and records what it was asked.
    struct Table(Vec<(&'static str, u16, &'static str)>, std::sync::Mutex<Vec<(String, String, Option<String>)>>);
    impl Front for Table {
        fn get(&self, p: &str, b: Option<&str>) -> Result<(u16, String), String> {
            self.1.lock().unwrap().push(("GET".into(), p.into(), b.map(str::to_string)));
            self.0.iter().find(|(path, _, _)| *path == p).map(|(_, s, body)| (*s, body.to_string())).ok_or_else(|| "connection refused".into())
        }
        fn post(&self, p: &str, body: &str, b: Option<&str>) -> Result<(u16, String), String> {
            self.1.lock().unwrap().push(("POST".into(), format!("{p} {body}"), b.map(str::to_string)));
            self.get(p, b)
        }
    }
    fn table(rows: Vec<(&'static str, u16, &'static str)>) -> Table {
        Table(rows, Default::default())
    }

    /// Each expectation holds or says what the answer lacks; a check that reads JSON fails on
    /// a body that is not.
    use monetize_server::facts::ProofCheck;

    #[test]
    fn each_expectation_holds_or_says_what_is_missing() {
        let w = Words { account: "alice", invite: "i", bearer: "b" };
        let j = serde_json::json!({"a": true, "c": {"d": "x"}, "items": [{"name": "welcome", "path": "alice/welcome"}, {"name": "w", "path": "bob/w"}], "r": [{"name": "crates"}], "e": []});
        let ok = |e: ProofExpect| holds(&e, "page", Some(&j), &w);
        assert!(ok(ProofExpect::Contains { text: "pa".into() }).is_ok() && ok(ProofExpect::Contains { text: "zz".into() }).is_err());
        assert_eq!(ok(ProofExpect::NotEmpty { list: "/r".into(), name_field: "name".into() }), Ok(Some("1 (crates)".into())), "a list rule says what it found");
        assert!(ok(ProofExpect::Equals { field: "/a".into(), value: Value::Bool(true) }).is_ok());
        assert!(ok(ProofExpect::Equals { field: "/missing".into(), value: Value::Bool(false) }).is_err(), "an absent field is null, not false");
        assert!(ok(ProofExpect::Text { field: "/c/d".into() }).is_ok() && ok(ProofExpect::Text { field: "/a".into() }).is_err());
        let one = |id: &str| ProofExpect::ExactlyOne { list: "/items".into(), name_field: "name".into(), name: "welcome".into(), id_field: "path".into(), id: id.into() };
        assert!(ok(one("{account}/welcome")).is_ok());
        assert!(ok(one("bob/welcome")).unwrap_err().contains("not exactly bob/welcome"));
        assert!(ok(ProofExpect::NotEmpty { list: "/r".into(), name_field: "name".into() }).is_ok());
        assert!(ok(ProofExpect::NotEmpty { list: "/e".into(), name_field: "name".into() }).is_err());
        assert!(holds(&ProofExpect::Text { field: "/a".into() }, "<html>", None, &w).unwrap_err().contains("not JSON"));
    }

    /// The method and body check: a `POST` carries its body with `{invite}` and the trimmed
    /// `{account}` filled in, a route its percent-encoded words, and only a bearer check sends
    /// the bearer.
    #[test]
    fn a_check_is_asked_by_its_method_body_and_bearer() {
        let w = Words { account: " al ice ", invite: "in/v", bearer: "BEARER-TOKEN" };
        assert_eq!(w.path("/s?invite={invite}&user={account}"), "/s?invite=in%2Fv&user=%20al%20ice%20");
        assert_eq!(w.body(&serde_json::json!({"invite": "{invite}", "display": "{account}", "n": [1, "{account}"]})), serde_json::json!({"invite": "in/v", "display": "al ice", "n": [1, "al ice"]}));
        let front = table(vec![("/p", 200, "{}"), ("/g", 200, "{}")]);
        send(&front, &ProofAsk { method: ProofMethod::Post, path: "/p".into(), body: Some(serde_json::json!({"i": "{invite}"})), bearer: false }, &w).unwrap();
        send(&front, &ProofAsk { method: ProofMethod::Get, path: "/g".into(), body: None, bearer: true }, &w).unwrap();
        let asked = front.1.lock().unwrap().clone();
        assert_eq!(asked[0], ("POST".into(), "/p {\"i\":\"in/v\"}".into(), None));
        assert_eq!(asked[2], ("GET".into(), "/g".into(), Some("BEARER-TOKEN".into())));
    }

    /// A product's own routes: a proof of the Lake's shape (`/login`, `/first-login`), with no
    /// `/auth/first-admin` anywhere, is proven by those routes alone; a proof with no first door
    /// is refused by name when its first door is asked.
    #[test]
    fn a_product_names_its_own_routes() {
        let proof = SaleProof {
            checks: vec![
                ProofCheck { what: "the login page".into(), ask: ProofAsk { method: ProofMethod::Get, path: "/login".into(), body: None, bearer: false }, expect: vec![] },
                ProofCheck { what: "the first-login door".into(), ask: ProofAsk { method: ProofMethod::Get, path: "/first-login".into(), body: None, bearer: false }, expect: vec![ProofExpect::Equals { field: "/open".into(), value: Value::Bool(true) }] },
            ],
            needs_account: true,
            first_door: None,
        };
        let front = table(vec![("/login", 200, "ok"), ("/first-login", 200, r#"{"open":true}"#)]);
        assert!(prove(&proof, &front, "lake.test", "alice", "b", "i").unwrap().contains("the first-login door"));
        let shut = table(vec![("/login", 200, "ok"), ("/first-login", 200, r#"{"open":false}"#)]);
        assert!(prove(&proof, &shut, "lake.test", "alice", "b", "i").unwrap_err().starts_with("the first-login door:"));
        assert!(first_door(&proof, &front, "lake.test", "alice", "i").unwrap_err().contains("[sale-proof-no-first-door]"));
        for p in [monetize_server::testing::lifecycles::gunnar_facts().proof.unwrap(), monetize_server::testing::lifecycles::holger_facts().proof.unwrap()] {
            let routes = serde_json::to_string(&p).unwrap();
            assert!(!routes.contains("/auth/first-admin"), "no shared first-admin route");
        }
    }
}