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"
);
}
}