Files

304 lines
9.4 KiB
Rust

//! 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<String> {
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:?}"
);
}