Trusty: provenance library workspace (initial import)
This commit is contained in:
18
crates/adapters/trusty-policy-conditions/Cargo.toml
Normal file
18
crates/adapters/trusty-policy-conditions/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "trusty-policy-conditions"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Lets a declared policy FSM act as a trusty break Condition, so the rules for how data may legally change are authored as data and a deviation records which rule broke."
|
||||
|
||||
[dependencies]
|
||||
trusty-core = { workspace = true }
|
||||
trusty-policy = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
trusty-sqlite = { workspace = true }
|
||||
528
crates/adapters/trusty-policy-conditions/src/lib.rs
Normal file
528
crates/adapters/trusty-policy-conditions/src/lib.rs
Normal file
@@ -0,0 +1,528 @@
|
||||
//! Rules for how data may legally change, authored as data.
|
||||
//!
|
||||
//! # The idea
|
||||
//!
|
||||
//! Declare the transitions a datum is allowed to make. A compliant
|
||||
//! mutation is valid and the chain continues. A deviation records a
|
||||
//! **break naming the rule it violated**, and that break is signed,
|
||||
//! travels in an exported bundle, and can be acted on by anyone.
|
||||
//!
|
||||
//! Almost all of this already existed. [`Condition`] evaluates state at
|
||||
//! append time, [`BreakReason::PropertyViolated`] already carries a
|
||||
//! rule's name, and `export_bundle` always includes BREAK rows.
|
||||
//! Chain-break-as-event *was* this design. The one thing missing was
|
||||
//! that the rules were compiled in: [`Condition`] is a Rust trait, so a
|
||||
//! new rule meant a new build.
|
||||
//!
|
||||
//! The policy side had already solved that -- [`FsmSpec`] authored in
|
||||
//! TOML, guards named as strings, and [`GuardRegistry`] refusing to
|
||||
//! build on an unknown name. This crate is the join.
|
||||
//!
|
||||
//! # Guards see the payload
|
||||
//!
|
||||
//! The FSM is built over [`ConditionContext`], so a guard receives the
|
||||
//! **data being appended**, not just chain metadata. A rule can
|
||||
//! therefore be a real predicate over the value -- a threshold, a shape
|
||||
//! check, a comparison against the previous row -- rather than only a
|
||||
//! statement about the shape of the graph.
|
||||
//!
|
||||
//! # The envelope
|
||||
//!
|
||||
//! A row states the transition it is making, under `data.transition`:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "transition": { "from": "draft", "action": "submit", "to": "review" } }
|
||||
//! ```
|
||||
//!
|
||||
//! The condition checks that transition is declared, that any guard on
|
||||
//! it passes, and that the stated destination is the one the FSM says it
|
||||
//! should be. That last check matters: without it a writer could name a
|
||||
//! legal transition and then record an arbitrary destination.
|
||||
//!
|
||||
//! # Rows that claim no transition
|
||||
//!
|
||||
//! By default they pass -- this condition judges rows that claim to be
|
||||
//! transitions, and a chain usually carries other traffic too.
|
||||
//! [`FsmCondition::strict`] reverses that, so an unlabelled row is
|
||||
//! itself a violation.
|
||||
//!
|
||||
//! Which is right depends on whether the chain is dedicated to the
|
||||
//! governed process. Getting it wrong in the lenient direction leaves a
|
||||
//! hole a writer can walk through by omitting a field, so **strict is
|
||||
//! the right default for a chain that governs one thing**, and the
|
||||
//! lenient default exists only because most chains do not.
|
||||
//!
|
||||
//! # The marker crosses the boundary
|
||||
//!
|
||||
//! A break's reason travels in the exported bundle, so a party holding
|
||||
//! only that bundle -- and no secret key, and no access to the machine
|
||||
//! that recorded it -- can ask `ProofBundle::stance()` and get one of
|
||||
//! three answers: clean, violated (with the rule named), or
|
||||
//! unverifiable. That third answer is the point: a bundle that cannot be
|
||||
//! checked must not read as clean.
|
||||
//!
|
||||
//! # One limitation, stated rather than discovered
|
||||
//!
|
||||
//! **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 the opening row is recorded whatever it claims.
|
||||
//! If the opening state must also be governed, check it before the
|
||||
//! first append -- the chain will not do it for you.
|
||||
//!
|
||||
//! # Why this crate and not another
|
||||
//!
|
||||
//! [`trusty_policy`] is deliberately substrate-free and does not depend
|
||||
//! on [`trusty_core`]. Putting this in either would give one of them a
|
||||
//! dependency it was designed not to have -- the same split, for the
|
||||
//! same reason, as `trusty-policy-audit`.
|
||||
|
||||
#![deny(clippy::all)]
|
||||
|
||||
use serde_json::Value;
|
||||
use trusty_core::conditions::{BreakReason, Condition, ConditionContext, ConditionOutcome};
|
||||
use sha2::{Digest, Sha256};
|
||||
use trusty_policy::{Fsm, FsmSpec, GuardRegistry, PolicyError};
|
||||
|
||||
/// Where the transition envelope lives inside `data`.
|
||||
pub const TRANSITION_FIELD: &str = "transition";
|
||||
|
||||
/// A declared state machine, acting as a break condition.
|
||||
pub struct FsmCondition {
|
||||
fsm: Fsm<String, String, ConditionContext>,
|
||||
rule: String,
|
||||
/// The rule set's content hash, short form, when it was built from a
|
||||
/// spec. Stamped into every break so a violation names the rule AND
|
||||
/// the version of it that produced the verdict.
|
||||
rule_hash: Option<String>,
|
||||
strict: bool,
|
||||
}
|
||||
|
||||
impl FsmCondition {
|
||||
/// Build from an already-constructed FSM.
|
||||
///
|
||||
/// `rule` is the name that appears in the recorded break. Make it
|
||||
/// something an operator reading a chain six months later can look
|
||||
/// up -- the whole point of `PropertyViolated` carrying a name is
|
||||
/// that the break says *which* invariant broke.
|
||||
pub fn new(fsm: Fsm<String, String, ConditionContext>, rule: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fsm,
|
||||
rule: rule.into(),
|
||||
rule_hash: None,
|
||||
strict: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The content hash of a rule set, hex, first 16 characters.
|
||||
///
|
||||
/// Truncated because it travels inside every break reason and a full
|
||||
/// digest would crowd out the part a human reads. 64 bits is ample
|
||||
/// for telling two rule sets apart; it is not a security boundary,
|
||||
/// since the full canonical form is what gets signed.
|
||||
pub fn hash_of(spec: &FsmSpec) -> Result<String, PolicyError> {
|
||||
let canonical = spec.canonical_json()?;
|
||||
Ok(hex_short(&Sha256::digest(canonical.as_bytes())))
|
||||
}
|
||||
|
||||
/// The rule set version this condition enforces, if it was built
|
||||
/// from a spec.
|
||||
pub fn rule_hash(&self) -> Option<&str> {
|
||||
self.rule_hash.as_deref()
|
||||
}
|
||||
|
||||
/// Build from a TOML spec, with guards resolved by name.
|
||||
///
|
||||
/// A transition naming a guard the registry does not hold **refuses
|
||||
/// to build**. That refusal is the feature: a policy that silently
|
||||
/// runs without its guard is a policy that is not being enforced,
|
||||
/// and it would look identical to one that is.
|
||||
pub fn from_json(
|
||||
json: &str,
|
||||
registry: &mut GuardRegistry<ConditionContext>,
|
||||
rule: impl Into<String>,
|
||||
) -> Result<Self, PolicyError> {
|
||||
let spec = FsmSpec::from_json(json)?;
|
||||
let rule_hash = Self::hash_of(&spec)?;
|
||||
let (_scope, fsm) = spec.build_fsm_with_guards(registry)?;
|
||||
let mut built = Self::new(fsm, rule);
|
||||
built.rule_hash = Some(rule_hash);
|
||||
Ok(built)
|
||||
}
|
||||
|
||||
/// Treat a row that claims no transition as a violation.
|
||||
///
|
||||
/// Correct when the chain is dedicated to the governed process.
|
||||
/// Without it, omitting the envelope is a way around the rule.
|
||||
pub fn strict(mut self) -> Self {
|
||||
self.strict = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn violated(&self, detail: &str) -> ConditionOutcome {
|
||||
// The rule set version rides along, so a reader of the break can
|
||||
// tell WHICH `doc.review` produced it. Without it a rule could be
|
||||
// loosened after the fact and the old marker would still verify
|
||||
// against a rule that no longer says what it said.
|
||||
let name = match &self.rule_hash {
|
||||
Some(h) => format!("{}@{h}", self.rule),
|
||||
None => self.rule.clone(),
|
||||
};
|
||||
ConditionOutcome::Fail(BreakReason::PropertyViolated(format!("{name}:{detail}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The declared transition a row claims to be making.
|
||||
struct Declared {
|
||||
from: String,
|
||||
action: String,
|
||||
to: String,
|
||||
}
|
||||
|
||||
fn read_envelope(data: &Value) -> Option<Declared> {
|
||||
let t = data.get(TRANSITION_FIELD)?;
|
||||
Some(Declared {
|
||||
from: t.get("from")?.as_str()?.to_string(),
|
||||
action: t.get("action")?.as_str()?.to_string(),
|
||||
to: t.get("to")?.as_str()?.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Condition for FsmCondition {
|
||||
fn name(&self) -> &'static str {
|
||||
"FsmCondition"
|
||||
}
|
||||
|
||||
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome {
|
||||
let Some(declared) = read_envelope(&ctx.data) else {
|
||||
return if self.strict {
|
||||
// Distinguished from a malformed one on purpose: "you
|
||||
// did not say" and "you said something impossible" are
|
||||
// different operator problems.
|
||||
self.violated("no-transition-declared")
|
||||
} else {
|
||||
ConditionOutcome::Pass
|
||||
};
|
||||
};
|
||||
|
||||
// Exclusions first, and they cannot be overridden -- the same
|
||||
// ordering the permission model uses, for the same reason.
|
||||
if let Some(why) = self.fsm.excluded_by(&declared.from, &declared.action) {
|
||||
return self.violated(&format!("excluded:{why}"));
|
||||
}
|
||||
|
||||
let Some(expected) = self.fsm.next_state(&declared.from, &declared.action) else {
|
||||
return self.violated(&format!(
|
||||
"undeclared-transition:{}--{}",
|
||||
declared.from, declared.action
|
||||
));
|
||||
};
|
||||
|
||||
if let Some(guard) = self.fsm.guard(&declared.from, &declared.action) {
|
||||
if let Err(code) = guard.check(ctx) {
|
||||
return self.violated(&format!("guard-rejected:{code}"));
|
||||
}
|
||||
}
|
||||
|
||||
if *expected != declared.to {
|
||||
// A writer naming a legal transition and then recording a
|
||||
// destination of its own choosing is exactly what this
|
||||
// stops.
|
||||
return self.violated(&format!(
|
||||
"wrong-destination:{}--{}->{} expected {expected}",
|
||||
declared.from, declared.action, declared.to
|
||||
));
|
||||
}
|
||||
|
||||
ConditionOutcome::Pass
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_short(digest: &[u8]) -> String {
|
||||
digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// The transition envelope, for a writer to embed in its payload.
|
||||
pub fn transition(from: &str, action: &str, to: &str) -> Value {
|
||||
serde_json::json!({ TRANSITION_FIELD: { "from": from, "action": action, "to": to } })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
"#;
|
||||
|
||||
pub(super) fn ctx(data: Value) -> ConditionContext {
|
||||
ConditionContext {
|
||||
entity_id: "e".into(),
|
||||
chain_id: "c".into(),
|
||||
sequence: 1,
|
||||
stored_tip_hash: None,
|
||||
computed_hash: None,
|
||||
prev_hash: None,
|
||||
last_sequence: Some(0),
|
||||
source_url: String::new(),
|
||||
extraction_method: "trusty".into(),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
fn registry() -> GuardRegistry<ConditionContext> {
|
||||
let mut r = GuardRegistry::new();
|
||||
r.insert("big_enough", |c: &ConditionContext| {
|
||||
match c.data.get("size").and_then(Value::as_i64) {
|
||||
Some(n) if n >= 10 => Ok(()),
|
||||
Some(n) => Err(format!("too-small:{n}")),
|
||||
None => Err("no-size".to_string()),
|
||||
}
|
||||
});
|
||||
r
|
||||
}
|
||||
|
||||
fn condition() -> FsmCondition {
|
||||
FsmCondition::from_json(SPEC, &mut registry(), "doc.review").expect("build")
|
||||
}
|
||||
|
||||
/// ConditionOutcome does not derive PartialEq, and adding a derive to
|
||||
/// a core type to suit a test is the wrong direction of dependency.
|
||||
fn passed(outcome: ConditionOutcome) -> bool {
|
||||
matches!(outcome, ConditionOutcome::Pass)
|
||||
}
|
||||
|
||||
fn reason(outcome: ConditionOutcome) -> String {
|
||||
match outcome {
|
||||
ConditionOutcome::Fail(BreakReason::PropertyViolated(r)) => r,
|
||||
other => panic!("expected a property violation, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_transition_passes() {
|
||||
let outcome = condition().evaluate(&ctx(transition("draft", "submit", "review")));
|
||||
assert!(
|
||||
passed(outcome),
|
||||
"a compliant mutation must leave the chain unbroken; that is what \
|
||||
makes a break mean something"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undeclared_transition_breaks_the_chain_and_names_the_rule() {
|
||||
let outcome = condition().evaluate(&ctx(transition("draft", "publish", "published")));
|
||||
let r = reason(outcome);
|
||||
assert!(
|
||||
r.starts_with("doc.review@") && r.contains(":undeclared-transition"),
|
||||
"the break must name the rule that broke, or an operator reading \
|
||||
the chain later learns only that something went wrong: {r}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_a_legal_transition_does_not_licence_an_arbitrary_destination() {
|
||||
let outcome = condition().evaluate(&ctx(transition("draft", "submit", "published")));
|
||||
let r = reason(outcome);
|
||||
assert!(
|
||||
r.contains("wrong-destination"),
|
||||
"submit is legal from draft, but it goes to review; without this \
|
||||
check a writer could name a legal transition and then record \
|
||||
wherever it liked: {r}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guard_sees_the_payload_not_just_the_chain_metadata() {
|
||||
let mut small = transition("review", "approve", "published");
|
||||
small["size"] = Value::from(3);
|
||||
let r = reason(condition().evaluate(&ctx(small)));
|
||||
assert!(
|
||||
r.contains("guard-rejected:too-small:3"),
|
||||
"the guard must be able to judge the DATA being appended -- a rule \
|
||||
that can only see chain metadata cannot express most invariants \
|
||||
worth having: {r}"
|
||||
);
|
||||
|
||||
let mut big = transition("review", "approve", "published");
|
||||
big["size"] = Value::from(50);
|
||||
assert!(passed(condition().evaluate(&ctx(big))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_guard_input_is_a_rejection_not_a_pass() {
|
||||
let r = reason(condition().evaluate(&ctx(transition("review", "approve", "published"))));
|
||||
assert!(
|
||||
r.contains("guard-rejected:no-size"),
|
||||
"absent evidence must not read as a satisfied guard, for the same \
|
||||
reason absent policy is not permission: {r}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unregistered_guard_refuses_to_build() {
|
||||
let mut empty = GuardRegistry::<ConditionContext>::new();
|
||||
let built = FsmCondition::from_json(SPEC, &mut empty, "doc.review");
|
||||
assert!(
|
||||
built.is_err(),
|
||||
"a policy that silently runs without its guard is a policy that is \
|
||||
not being enforced, and it looks identical to one that is"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlabelled_row_passes_by_default_and_fails_under_strict() {
|
||||
let plain = ctx(serde_json::json!({ "temperature": 21 }));
|
||||
assert!(
|
||||
passed(condition().evaluate(&plain)),
|
||||
"most chains carry traffic this condition has no opinion about"
|
||||
);
|
||||
|
||||
let r = reason(condition().strict().evaluate(&plain));
|
||||
assert!(
|
||||
r.contains("no-transition-declared"),
|
||||
"on a chain dedicated to the governed process, omitting the \
|
||||
envelope would otherwise be a way around the rule: {r}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_envelope_is_reported_as_undeclared_not_as_absent() {
|
||||
let half = ctx(serde_json::json!({ TRANSITION_FIELD: { "from": "draft" } }));
|
||||
assert!(
|
||||
passed(condition().evaluate(&half)),
|
||||
"an envelope missing required fields is not a transition claim at \
|
||||
all; under strict it fails, and the two cases are distinguished \
|
||||
because 'you did not say' and 'you said something impossible' are \
|
||||
different operator problems"
|
||||
);
|
||||
let r = reason(condition().strict().evaluate(&half));
|
||||
assert!(r.contains("no-transition-declared"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exclusion_cannot_be_overridden_by_a_declared_transition() {
|
||||
let mut fsm: Fsm<String, String, ConditionContext> = Fsm::new("draft".to_string());
|
||||
fsm.allow(trusty_policy::Transition::new(
|
||||
"draft".to_string(),
|
||||
"submit".to_string(),
|
||||
"review".to_string(),
|
||||
));
|
||||
fsm.exclude("submit".to_string(), "frozen-for-audit");
|
||||
let cond = FsmCondition::new(fsm, "doc.review");
|
||||
|
||||
let r = reason(cond.evaluate(&ctx(transition("draft", "submit", "review"))));
|
||||
assert!(
|
||||
r.contains("excluded:frozen-for-audit"),
|
||||
"exclusions are checked before the transition is looked up, so a \
|
||||
declared transition cannot reopen what an exclusion shut -- the \
|
||||
same ordering the permission model uses: {r}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rule_version_tests {
|
||||
use super::*;
|
||||
|
||||
const NARROW: &str = r#"
|
||||
{
|
||||
"scope": "doc.review.v1",
|
||||
"initial": "draft",
|
||||
"transitions": [
|
||||
{ "from": "draft", "action": "submit", "to": "review" }
|
||||
]
|
||||
}
|
||||
"#;
|
||||
|
||||
const LOOSENED: &str = r#"
|
||||
{
|
||||
"scope": "doc.review.v1",
|
||||
"initial": "draft",
|
||||
"transitions": [
|
||||
{ "from": "draft", "action": "submit", "to": "review" },
|
||||
{ "from": "draft", "action": "publish", "to": "published" }
|
||||
]
|
||||
}
|
||||
"#;
|
||||
|
||||
fn built(spec: &str) -> FsmCondition {
|
||||
FsmCondition::from_json(spec, &mut GuardRegistry::new(), "doc.review")
|
||||
.expect("build")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loosening_the_rule_changes_the_version_the_break_cites() {
|
||||
let narrow = built(NARROW);
|
||||
let loosened = built(LOOSENED);
|
||||
assert_ne!(
|
||||
narrow.rule_hash(),
|
||||
loosened.rule_hash(),
|
||||
"a rule set that can be widened without changing its version is a \
|
||||
rule set a violation marker cannot honestly cite -- loosen it \
|
||||
after the fact and the old marker still appears to hold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_rule_set_always_yields_the_same_version() {
|
||||
assert_eq!(
|
||||
built(NARROW).rule_hash(),
|
||||
built(NARROW).rule_hash(),
|
||||
"a version that varied between processes would make every marker \
|
||||
uncomparable, which is worse than having no version at all"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_built_fsm_has_no_version_rather_than_a_made_up_one() {
|
||||
let mut fsm: Fsm<String, String, ConditionContext> = Fsm::new("draft".to_string());
|
||||
fsm.allow(trusty_policy::Transition::new(
|
||||
"draft".to_string(),
|
||||
"submit".to_string(),
|
||||
"review".to_string(),
|
||||
));
|
||||
let cond = FsmCondition::new(fsm, "doc.review");
|
||||
assert_eq!(
|
||||
cond.rule_hash(),
|
||||
None,
|
||||
"an FSM assembled in Rust has no canonical document to hash, and \
|
||||
inventing a version for it would be a claim nobody can check"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_versionless_rule_still_names_itself_in_the_break() {
|
||||
let mut fsm: Fsm<String, String, ConditionContext> = Fsm::new("draft".to_string());
|
||||
fsm.allow(trusty_policy::Transition::new(
|
||||
"draft".to_string(),
|
||||
"submit".to_string(),
|
||||
"review".to_string(),
|
||||
));
|
||||
let outcome = FsmCondition::new(fsm, "doc.review")
|
||||
.evaluate(&super::tests::ctx(transition("draft", "publish", "gone")));
|
||||
match outcome {
|
||||
ConditionOutcome::Fail(BreakReason::PropertyViolated(r)) => assert!(
|
||||
r.starts_with("doc.review:"),
|
||||
"no version is not the same as no name; the rule must still \
|
||||
identify itself: {r}"
|
||||
),
|
||||
other => panic!("expected a violation, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
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