Trusty: provenance library workspace (initial import)
This commit is contained in:
303
crates/adapters/trusty-policy-conditions/tests/enforced.rs
Normal file
303
crates/adapters/trusty-policy-conditions/tests/enforced.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
//! 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:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user