//! Does a declared rule actually break a real chain? //! //! The unit tests check the condition's verdict. These check that the //! verdict reaches the chain: that a compliant mutation leaves it //! unbroken, a deviation records a break, and the break says which rule //! broke rather than merely that something did. use trusty_core::{Ed25519Signer, SecretKey, Stance}; use trusty_policy::GuardRegistry; use trusty_policy_conditions::{transition, FsmCondition}; use trusty_sqlite::Trusty; const SPEC: &str = r#" { "scope": "doc.review.v1", "initial": "draft", "transitions": [ { "from": "draft", "action": "submit", "to": "review" }, { "from": "review", "action": "approve", "to": "published", "guard": "big_enough" } ] } "#; fn store() -> Trusty { let mut registry = GuardRegistry::new(); registry.insert("big_enough", |c: &trusty_core::ConditionContext| { match c.data.get("size").and_then(serde_json::Value::as_i64) { Some(n) if n >= 10 => Ok(()), Some(n) => Err(format!("too-small:{n}")), None => Err("no-size".to_string()), } }); let condition = FsmCondition::from_json(SPEC, &mut registry, "doc.review").expect("build the rule"); // The integrity defaults PLUS the declared rule: a policy rule is an // addition to tamper detection, never a replacement for it. let mut conditions = trusty_core::conditions::default_conditions(); conditions.push(Box::new(condition)); Trusty::open_in_memory_with_conditions(SecretKey::new([9u8; 16]), conditions) .expect("store") } /// Every recorded break reason, in order. /// /// Read through `breaks_since`, not from the exported bundle: a BREAK /// row's reason lives in the `old_value`/`new_value` columns, and /// `BundleRow` carries neither. So the reason does **not** travel to a /// remote party today -- see the sibling issue on making the marker /// actionable across a boundary. fn break_reasons(trusty: &Trusty, entity: &str) -> Vec { trusty .breaks_since(entity, None) .expect("breaks") .into_iter() .map(|b| b.break_reason) .collect() } /// Establish the chain with a compliant row. /// /// A rule cannot be violated by the FIRST row of a chain: break-as-event /// seals an existing chain, and at genesis there is nothing to seal. So /// every test that wants to observe a violation must first give the /// chain something to break. fn genesis(trusty: &mut Trusty, signer: &Ed25519Signer, entity: &str) { trusty .append_signed( entity, &transition("draft", "submit", "review"), "test://", signer, ) .expect("genesis"); } #[test] fn a_compliant_mutation_leaves_the_chain_unbroken() { let mut trusty = store(); let signer = Ed25519Signer::generate(); trusty .append_signed( "doc:1", &transition("draft", "submit", "review"), "test://", &signer, ) .expect("append"); assert!( break_reasons(&trusty, "doc:1").is_empty(), "a declared transition must pass; if compliant data broke the chain, \ a break would stop meaning anything" ); assert!( trusty .export_bundle("doc:1", None, None) .expect("export") .verify_public() .continuous, "and the chain must read as continuous" ); } #[test] fn an_undeclared_mutation_breaks_the_chain_and_the_break_names_the_rule() { let mut trusty = store(); let signer = Ed25519Signer::generate(); trusty .append_signed( "doc:2", &transition("draft", "submit", "review"), "test://", &signer, ) .expect("first"); // draft --publish--> published is not declared anywhere. trusty .append_signed( "doc:2", &transition("draft", "publish", "published"), "test://", &signer, ) .expect("second"); let reasons = break_reasons(&trusty, "doc:2"); assert_eq!( reasons.len(), 1, "the deviation must be recorded as an event, not rejected silently -- \ chain-break-as-event is what makes the violation evidence rather than \ an error somebody's log swallowed; got {reasons:?}" ); assert!( reasons[0].starts_with("PROPERTY_VIOLATED:doc.review@") && reasons[0].contains(":undeclared-transition"), "the break must carry the rule's name, so an operator reading this \ chain in six months learns WHICH invariant broke; got {}", reasons[0] ); } #[test] fn a_guard_rejection_names_the_guard_and_its_reason() { let mut trusty = store(); let signer = Ed25519Signer::generate(); genesis(&mut trusty, &signer, "doc:3"); let mut small = transition("review", "approve", "published"); small["size"] = serde_json::Value::from(2); trusty .append_signed("doc:3", &small, "test://", &signer) .expect("append"); let reasons = break_reasons(&trusty, "doc:3"); assert!( reasons .iter() .any(|r| r.contains("guard-rejected:too-small:2")), "a rule over the DATA must be able to break the chain and say what it \ saw; a break that only said 'guard failed' would send someone back to \ the source to find out which one; got {reasons:?}" ); } #[test] fn the_marker_crosses_the_boundary_carrying_the_rule_name() { let mut trusty = store(); let signer = Ed25519Signer::generate(); genesis(&mut trusty, &signer, "doc:4"); trusty .append_signed( "doc:4", &transition("draft", "publish", "published"), "test://", &signer, ) .expect("append"); // Everything below is what a REMOTE party can do: it holds the // bundle and no secret key, and never talks to the machine that // recorded the break. let bundle = trusty.export_bundle("doc:4", None, None).expect("export"); let violations = bundle.violations(); assert_eq!( violations.len(), 1, "the violation must be readable off the bundle alone; a marker only \ the writer can read is not something anyone else can act on" ); assert_eq!( violations[0] .rule .as_deref() .map(|r| r.split('@').next().unwrap_or(r)), Some("doc.review"), "and it must name the RULE, not merely report that something broke. \ Refuse, quarantine or inspect are different decisions and the rule is \ what tells them apart; got {:?}", violations[0] ); match bundle.stance() { Stance::Violated { violations } => { assert_eq!(violations.len(), 1, "one break, one violation") } other => panic!("expected a violation stance, got {other:?}"), } assert!( !bundle.stance().is_clean(), "a datum carrying a signed violation must never be waved through" ); } #[test] fn a_clean_bundle_reads_as_clean_and_a_tampered_one_does_not() { let mut trusty = store(); let signer = Ed25519Signer::generate(); genesis(&mut trusty, &signer, "doc:6"); let clean = trusty.export_bundle("doc:6", None, None).expect("export"); assert_eq!( clean.stance(), Stance::Clean, "a compliant chain must read as clean, or the marker means nothing" ); // The same bundle, edited after export. let mut forged = clean.clone(); for row in forged.rows.iter_mut() { if let Some(payload) = row.raw_payload.as_mut() { *payload = payload.replace("submit", "publish"); } } match forged.stance() { Stance::Unverifiable { .. } => {} other => panic!( "a bundle that no longer matches its signatures must report \ Unverifiable -- counting zero violations and looking spotless is \ exactly the failure the third answer exists to prevent; got {other:?}" ), } assert!( !forged.stance().is_clean(), "and unverifiable is not a permit" ); } #[test] fn a_break_with_no_rule_is_still_reported() { // Tamper detection and sequence gaps are real breaks with no // invariant to name. They must still surface, with rule: None, // rather than being filtered out for lacking a name. let v = trusty_core::Violation { entity_id: "e".into(), chain_id: "c".into(), rule: None, reason: "TAMPER_DETECTED".into(), }; assert!( v.rule.is_none() && !v.reason.is_empty(), "a break without a rule name is still a break, and dropping it would \ hide the most serious kind" ); } #[test] fn a_declared_transition_to_the_wrong_destination_still_breaks() { let mut trusty = store(); let signer = Ed25519Signer::generate(); genesis(&mut trusty, &signer, "doc:5"); trusty .append_signed( "doc:5", &transition("draft", "submit", "published"), "test://", &signer, ) .expect("append"); let reasons = break_reasons(&trusty, "doc:5"); assert!( reasons.iter().any(|r| r.contains("wrong-destination")), "submit is legal from draft, but it goes to review. Without this the \ rule would check the move and ignore where it landed; got {reasons:?}" ); }