304 lines
9.4 KiB
Rust
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:?}"
|
|
);
|
|
}
|