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

nordisk/monetize

crates/monetize-product-guest/src/conformance.rs

//! **The product guest's conformance table** (A25 §2, A24 §8.4; audit A28 H8; cut to the six
//! ops in P2 T13): what the SDK says about each op, for a host-side test to hold a SIGNED guest to.
//!
//! * [`FATES`]: the six ops of [`ops::ALL`] are KEPT; then every op that left the contract in
//!   T13, with its fate (A24 §2.2/§5.3): it DIED (the box answers it from the pulled reports or
//!   queues it as an order), it MERGED into op 1's manifest or op 90's facts, or it MOVED to
//!   the host (the ticket check). Those numbers are [`ops::RETIRED`];
//! * [`request`]: the well-formed request each of the six is asked with;
//! * [`is_sdk_default`]: whether an answer is the SDK's own default (a guest that never wrote
//!   the op), as the host reads it back.
//!
//! The suite over the real modules is monetize-server's
//! `tests/the_signed_guests_answer_the_op_table.rs`: it loads the signed Gunnar and Holger
//! guests as the ONE box does, asks each of the six with [`request`], and holds every retired
//! number to the unknown-op words.

use serde_json::Value;

use crate::{ops, wire};

/// An op's fate in the 6-op design.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Fate {
    Dies,
    Merges(&'static str),
    ToHost,
    Kept(&'static str),
}

/// **The fates**: the six of [`ops::ALL`], in its order, then the 22 numbers of A25 §2 that left
/// the contract, in the order of [`ops::RETIRED`].
pub const FATES: &[(u32, Fate)] = &[
    (ops::MANIFEST, Fate::Kept("manifest")),
    (ops::LIFECYCLE_FACTS, Fate::Kept("facts")),
    (ops::LIFECYCLE_LAY_PLAN, Fate::Kept("plan")),
    (ops::GUARDIAN_DIAGNOSE, Fate::Kept("diagnose")),
    (ops::GUARDIAN_PLAN, Fate::Kept("remedy")),
    (ops::GUARDIAN_OBSERVE, Fate::Kept("observe")),
    (2, Fate::Merges("manifest.meters")),
    (10, Fate::Dies),
    (11, Fate::Dies),
    (12, Fate::Dies),
    (13, Fate::Dies),
    (14, Fate::Dies),
    (20, Fate::Dies),
    (30, Fate::Dies),
    (31, Fate::Dies),
    (32, Fate::Dies),
    (40, Fate::Merges("facts.tickets")),
    (41, Fate::ToHost),
    (50, Fate::Dies),
    (51, Fate::Dies),
    (52, Fate::Dies),
    (53, Fate::Dies),
    (54, Fate::Merges("facts.test_actions")),
    (60, Fate::Merges("facts.guardian")),
    (61, Fate::Dies),
    (62, Fate::Dies),
    (63, Fate::Dies),
    (64, Fate::Dies),
];

/// The four numbers retired in 5.4 (70, 71, 80, 81): before A25's table, so they have no fate.
pub const RETIRED_IN_5_4: [u32; 4] = [70, 71, 80, 81];

/// The fate of `op`; `None` for an op the table does not have.
pub fn fate(op: u32) -> Option<Fate> {
    FATES.iter().find(|(o, _)| *o == op).map(|(_, f)| *f)
}

/// The request `op` is asked with: well formed, about a company `acme`. An op that takes no
/// request is asked with `null`.
pub fn request(op: u32) -> Value {
    use crate::guardian;
    fn v<T: serde::Serialize>(x: &T) -> Value {
        serde_json::to_value(x).unwrap_or(Value::Null)
    }
    let view = guardian::EstateView::default();
    match op {
        ops::LIFECYCLE_LAY_PLAN => v(&wire::LayPlanReq {
            config: serde_json::json!({}),
            company: "acme".into(),
            ..Default::default()
        }),
        ops::GUARDIAN_DIAGNOSE => v(&view),
        ops::GUARDIAN_PLAN => v(&wire::GuardianPlanReq {
            remedy: guardian::Remedy::ReviewTwin,
            view,
        }),
        ops::GUARDIAN_OBSERVE => v(&wire::GuardianObserveReq {
            role: "main".into(),
            what: "op-table".into(),
            view,
        }),
        _ => Value::Null,
    }
}

/// Is `answer` the SDK's own default for `op` (`Guest`'s provided methods, or the unknown-op
/// phrase), as the host reads it back (`Err` is the error's text)?
pub fn is_sdk_default(op: u32, answer: &Result<Value, String>) -> bool {
    match answer {
        Err(m) if ops::is_unknown_op(m) => true,
        Err(m) => {
            matches!(
                op,
                ops::LIFECYCLE_FACTS
                    | ops::LIFECYCLE_LAY_PLAN
                    | ops::GUARDIAN_DIAGNOSE
                    | ops::GUARDIAN_PLAN
                    | ops::GUARDIAN_OBSERVE
            ) && m.contains("declares no `")
        }
        Ok(_) => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{dispatch, Guest, Manifest, NoHost, ProductError, WireMeter};

    /// A guest that writes only what the SDK requires: every other op is the SDK's default.
    struct Bare;
    impl Guest for Bare {
        fn manifest(&self) -> Manifest {
            Manifest::new("bare", &[]).with_meters(vec![WireMeter::new(
                "things",
                "how many",
                crate::Enforcement::Measured,
            )])
        }
    }

    /// What the host reads back from one op of `guest`, asked with [`request`].
    fn call(guest: &dyn Guest, op: u32) -> Result<Value, String> {
        let raw = dispatch(guest, &NoHost, op, &wire::encode(&request(op)));
        let answer: wire::Answer<Value> =
            wire::decode(&raw).expect("an answer envelope, never a trap");
        answer.map_err(|e| ProductError::from(e).to_string())
    }

    /// ★ **T13: the contract is exactly the six ops** (A24 §2.2): `manifest` (op 1, the meter
    /// table in it), `lifecycle.facts` (90), `lifecycle.lay_plan` (91) and the guardian's three
    /// (100-102). Every other number a product guest ever answered is RETIRED: frozen, never
    /// reused, and the SDK's dispatch answers each with the unknown-op words.
    #[test]
    fn the_contract_is_exactly_the_six_ops_and_every_other_number_is_retired() {
        let all: Vec<u32> = ops::ALL.iter().map(|r| r.0).collect();
        assert_eq!(
            all,
            [
                ops::MANIFEST,
                ops::LIFECYCLE_FACTS,
                ops::LIFECYCLE_LAY_PLAN,
                ops::GUARDIAN_DIAGNOSE,
                ops::GUARDIAN_PLAN,
                ops::GUARDIAN_OBSERVE
            ],
            "the six"
        );
        assert_eq!(ops::name(ops::MANIFEST), Some("manifest"));
        assert_eq!(
            ops::RETIRED.len(),
            26,
            "22 of A25's 28 left the wire, and the four retired in 5.4"
        );
        for (op, name) in ops::RETIRED {
            assert!(
                ops::name(*op).is_none(),
                "op {op} ({name}) is retired, not in the table"
            );
            match call(&Bare, *op) {
                Err(m) if ops::is_unknown_op(&m) => {}
                other => panic!("op {op} ({name}) must answer the unknown-op words: {other:?}"),
            }
        }
    }

    /// ★ **Op 1 answers the whole manifest, the meter table in it** (ops 1 and 2 merged).
    #[test]
    fn op_one_answers_the_manifest_with_its_meters() {
        let m: Manifest = serde_json::from_value(call(&Bare, ops::MANIFEST).expect("op 1 answers"))
            .expect("op 1's answer is the manifest");
        assert_eq!(
            (m.product.as_str(), m.grants.len(), m.kind.as_str()),
            ("bare", 0, crate::kinds::PRODUCT)
        );
        assert_eq!(
            m.meters.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(),
            ["things"]
        );
        let vendor: Manifest = serde_json::from_str(r#"{"abi_min":1,"abi_max":1,"product":"v","grants":[],"identity":false,"kind":"vendor"}"#).unwrap();
        assert!(
            vendor.meters.is_empty(),
            "a manifest of another kind carries no meters, and is read as before"
        );
    }

    /// ★ **The facts carry what ops 40, 54 and 60 answered** (`tickets`, `test_actions`,
    /// `guardian`), and a facts answer that says none of them reads as none.
    #[test]
    fn the_facts_carry_the_merged_ops() {
        let f = wire::LifecycleFacts {
            tickets: true,
            test_actions: vec![crate::TestAction {
                id: "t".into(),
                label: "Test".into(),
                kind: crate::TestActionKind::HostedPlan { cap_gib: 1 },
            }],
            guardian: true,
            ..Default::default()
        };
        let back: wire::LifecycleFacts =
            serde_json::from_value(serde_json::to_value(&f).unwrap()).unwrap();
        assert_eq!(back, f);
        let old: wire::LifecycleFacts = serde_json::from_str(r#"{"kind":"k","update_order":[],"app_medium":"","front_medium":"","front_role":"","keep_medium":"","front_keep_medium":"","upgraded_by_shop":[],"medium_of":{},"other_medium":"","backup_roles":[]}"#).unwrap();
        assert!(!old.tickets && old.test_actions.is_empty() && !old.guardian);
        let facts: crate::facts::Facts = f.clone();
        assert_eq!(
            (facts.tickets, facts.test_actions.len(), facts.guardian),
            (true, 1, true),
            "LifecycleFacts is Facts (P3)"
        );
    }

    /// The fates: the six are KEPT; the 22 of A25 §2 that left the wire die (17), merge into
    /// the manifest or the facts (4) or move to the host (1).
    #[test]
    fn the_fates_keep_the_six_and_account_for_every_op_that_left() {
        let fated: Vec<u32> = FATES.iter().map(|r| r.0).collect();
        let mut want: Vec<u32> = ops::ALL.iter().map(|r| r.0).collect();
        want.extend(
            ops::RETIRED
                .iter()
                .map(|r| r.0)
                .filter(|op| !RETIRED_IN_5_4.contains(op)),
        );
        assert_eq!(
            fated, want,
            "one fate per op of the table, then per op that left it"
        );
        let count = |f: fn(&Fate) -> bool| FATES.iter().filter(|r| f(&r.1)).count();
        assert_eq!(count(|f| matches!(f, Fate::Kept(_))), 6, "the six");
        assert_eq!(count(|f| *f == Fate::Dies), 17, "17 ops died on the wire");
        assert_eq!(
            count(|f| matches!(f, Fate::Merges(_))),
            4,
            "4 merged into manifest() and facts()"
        );
        assert_eq!(
            count(|f| *f == Fate::ToHost),
            1,
            "1 moved to the host (the ticket check)"
        );
        assert_eq!(fate(ops::MANIFEST), Some(Fate::Kept("manifest")));
        assert_eq!(fate(41), Some(Fate::ToHost));
        assert_eq!(fate(ops::GUARDIAN_PLAN), Some(Fate::Kept("remedy")));
        assert_eq!(fate(999), None);
    }

    #[test]
    fn every_op_is_asked_with_a_request_it_decodes() {
        for (op, name) in ops::ALL {
            if let Err(m) = call(&Bare, *op) {
                assert!(!m.contains("undecodable request"), "op {op} ({name}): {m}");
            }
        }
    }

    #[test]
    fn the_sdks_own_answers_are_read_as_its_defaults_and_a_guests_own_are_not() {
        for (op, name) in ops::ALL {
            let answer = call(&Bare, *op);
            let default = is_sdk_default(*op, &answer);
            if *op == ops::MANIFEST {
                assert!(
                    !default,
                    "op {op} ({name}) is the guest's own answer, read as the SDK's: {answer:?}"
                );
            } else {
                assert!(
                    default,
                    "op {op} ({name}) is the SDK's default, not recognised: {answer:?}"
                );
            }
        }
        let unknown = Err(format!("op 999 {}", ops::UNKNOWN_OP_PHRASE));
        assert!(
            is_sdk_default(999, &unknown),
            "an op the guest does not know is the SDK's word"
        );
    }
}