Trusty: provenance library workspace (initial import)
This commit is contained in:
334
crates/core/trusty-core/src/conditions.rs
Normal file
334
crates/core/trusty-core/src/conditions.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
// Pluggable break conditions.
|
||||
//
|
||||
// A condition evaluates the state at append time and votes Pass or
|
||||
// Fail(reason). First failure wins — subsequent conditions do not
|
||||
// run. Default set: HashIntegrity, SequenceContinuity. SourceDomain
|
||||
// exists but is opt-in (requires a domain list).
|
||||
//
|
||||
// Byte-identical vote outcomes to the Python reference at
|
||||
// trusty_core_watermark_proxy/core/trusty/conditions/defaults.py
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConditionContext {
|
||||
pub entity_id: String,
|
||||
pub chain_id: String,
|
||||
pub sequence: i64,
|
||||
pub stored_tip_hash: Option<String>,
|
||||
pub computed_hash: Option<String>,
|
||||
pub prev_hash: Option<String>,
|
||||
pub last_sequence: Option<i64>,
|
||||
pub source_url: String,
|
||||
pub extraction_method: String,
|
||||
/// The MEASURED DATA being appended (the payload). S126 gap B: this is what
|
||||
/// lets a condition monitor the DATA PLANE, not just chain metadata -- e.g. a
|
||||
/// threshold over a measured metric (`latency_ms > 500`) evaluated at append
|
||||
/// time, recording a break that names the variable and the observed value.
|
||||
/// `Value::Null` when the backend supplies no data (older callers, tests).
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BreakReason {
|
||||
TamperDetected,
|
||||
SequenceGap,
|
||||
SourceChange,
|
||||
ManualReset,
|
||||
SchemaMigration,
|
||||
ConditionEvaluatorError,
|
||||
/// A declared RUN RULE / invariant was violated at append time -- carries the
|
||||
/// rule's name so the recorded break says WHICH invariant broke, instead of
|
||||
/// being mislabelled `SequenceGap` (the S126 gap A). ADDITIVE and safe: BREAK
|
||||
/// rows are skipped in hash verification (`chain::verify_links`), so the break
|
||||
/// reason was never part of the hashed payload -- existing chains are
|
||||
/// unaffected. The rule name travels via `code()`, never via the frozen
|
||||
/// `as_code()`.
|
||||
PropertyViolated(String),
|
||||
}
|
||||
|
||||
impl BreakReason {
|
||||
/// The FROZEN category code (`&'static str`). Unchanged for existing variants
|
||||
/// so any historical use is byte-stable; `PropertyViolated` reports its
|
||||
/// category here and its rule name via [`code`](Self::code).
|
||||
pub fn as_code(&self) -> &'static str {
|
||||
match self {
|
||||
BreakReason::TamperDetected => "TAMPER_DETECTED",
|
||||
BreakReason::SequenceGap => "SEQUENCE_GAP",
|
||||
BreakReason::SourceChange => "SOURCE_CHANGE",
|
||||
BreakReason::ManualReset => "MANUAL_RESET",
|
||||
BreakReason::SchemaMigration => "SCHEMA_MIGRATION",
|
||||
BreakReason::ConditionEvaluatorError => "CONDITION_EVALUATOR_ERROR",
|
||||
BreakReason::PropertyViolated(_) => "PROPERTY_VIOLATED",
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorded reason string: the category, plus the rule name for a
|
||||
/// `PropertyViolated` (`PROPERTY_VIOLATED:<rule>`). This is what a break row
|
||||
/// stores, so the answer to "which invariant broke?" travels on the chain.
|
||||
pub fn code(&self) -> String {
|
||||
match self {
|
||||
BreakReason::PropertyViolated(rule) => format!("PROPERTY_VIOLATED:{rule}"),
|
||||
other => other.as_code().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule name, when this break is a `PropertyViolated`. `None` otherwise.
|
||||
pub fn rule(&self) -> Option<&str> {
|
||||
match self {
|
||||
BreakReason::PropertyViolated(rule) => Some(rule),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConditionOutcome {
|
||||
Pass,
|
||||
Fail(BreakReason),
|
||||
}
|
||||
|
||||
pub trait Condition: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome;
|
||||
}
|
||||
|
||||
pub struct HashIntegrity;
|
||||
|
||||
impl Condition for HashIntegrity {
|
||||
fn name(&self) -> &'static str {
|
||||
"hash_integrity"
|
||||
}
|
||||
|
||||
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome {
|
||||
let stored = &ctx.stored_tip_hash;
|
||||
let computed = &ctx.computed_hash;
|
||||
|
||||
// Genesis — first ingestion for this entity.
|
||||
if stored.is_none() && ctx.sequence == 0 {
|
||||
return ConditionOutcome::Pass;
|
||||
}
|
||||
|
||||
// Stored matches computed — chain intact.
|
||||
if let (Some(s), Some(c)) = (stored.as_ref(), computed.as_ref()) {
|
||||
if s == c {
|
||||
return ConditionOutcome::Pass;
|
||||
}
|
||||
return ConditionOutcome::Fail(BreakReason::TamperDetected);
|
||||
}
|
||||
|
||||
// stored present, computed missing — cannot compare, treat as fail.
|
||||
if stored.is_some() && computed.is_none() {
|
||||
return ConditionOutcome::Fail(BreakReason::TamperDetected);
|
||||
}
|
||||
|
||||
// stored=None, sequence>0 — unusual but recoverable.
|
||||
ConditionOutcome::Pass
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SequenceContinuity;
|
||||
|
||||
impl Condition for SequenceContinuity {
|
||||
fn name(&self) -> &'static str {
|
||||
"sequence_continuity"
|
||||
}
|
||||
|
||||
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome {
|
||||
match ctx.last_sequence {
|
||||
None => ConditionOutcome::Pass,
|
||||
Some(last) => {
|
||||
if ctx.sequence == last + 1 {
|
||||
ConditionOutcome::Pass
|
||||
} else {
|
||||
ConditionOutcome::Fail(BreakReason::SequenceGap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SourceDomain {
|
||||
pub trusted_domains: Vec<String>,
|
||||
pub hard: bool,
|
||||
}
|
||||
|
||||
impl SourceDomain {
|
||||
pub fn new(trusted_domains: Vec<String>, hard: bool) -> Self {
|
||||
Self {
|
||||
trusted_domains,
|
||||
hard,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Condition for SourceDomain {
|
||||
fn name(&self) -> &'static str {
|
||||
"source_domain"
|
||||
}
|
||||
|
||||
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome {
|
||||
if ctx.source_url.is_empty() || self.trusted_domains.is_empty() {
|
||||
return ConditionOutcome::Pass;
|
||||
}
|
||||
let ok = self
|
||||
.trusted_domains
|
||||
.iter()
|
||||
.any(|d| ctx.source_url.contains(d));
|
||||
if ok {
|
||||
ConditionOutcome::Pass
|
||||
} else if self.hard {
|
||||
ConditionOutcome::Fail(BreakReason::SourceChange)
|
||||
} else {
|
||||
ConditionOutcome::Pass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_conditions() -> Vec<Box<dyn Condition>> {
|
||||
vec![Box::new(HashIntegrity), Box::new(SequenceContinuity)]
|
||||
}
|
||||
|
||||
pub fn evaluate(conditions: &[Box<dyn Condition>], ctx: &ConditionContext) -> ConditionOutcome {
|
||||
for c in conditions {
|
||||
match c.evaluate(ctx) {
|
||||
ConditionOutcome::Pass => continue,
|
||||
ConditionOutcome::Fail(reason) => return ConditionOutcome::Fail(reason),
|
||||
}
|
||||
}
|
||||
ConditionOutcome::Pass
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn property_violated_carries_the_rule_name_via_code_not_the_frozen_as_code() {
|
||||
let r = BreakReason::PropertyViolated("anchor-before-commit".into());
|
||||
assert_eq!(r.as_code(), "PROPERTY_VIOLATED", "frozen category");
|
||||
assert_eq!(
|
||||
r.code(),
|
||||
"PROPERTY_VIOLATED:anchor-before-commit",
|
||||
"recorded reason names the rule"
|
||||
);
|
||||
assert_eq!(r.rule(), Some("anchor-before-commit"));
|
||||
// Existing variants are byte-stable: code() == as_code() for them, so no
|
||||
// historical break row changes meaning.
|
||||
assert_eq!(BreakReason::SequenceGap.code(), "SEQUENCE_GAP");
|
||||
assert_eq!(BreakReason::TamperDetected.rule(), None);
|
||||
}
|
||||
|
||||
fn ctx_genesis() -> ConditionContext {
|
||||
ConditionContext {
|
||||
data: serde_json::Value::Null,
|
||||
entity_id: "acme".into(),
|
||||
chain_id: "chain_acme_001".into(),
|
||||
sequence: 0,
|
||||
stored_tip_hash: None,
|
||||
computed_hash: None,
|
||||
prev_hash: None,
|
||||
last_sequence: None,
|
||||
source_url: "s".into(),
|
||||
extraction_method: "trusty".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_integrity_passes_on_genesis() {
|
||||
let c = HashIntegrity;
|
||||
matches!(c.evaluate(&ctx_genesis()), ConditionOutcome::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_integrity_fails_on_mismatch() {
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.sequence = 1;
|
||||
ctx.stored_tip_hash = Some("abc".into());
|
||||
ctx.computed_hash = Some("def".into());
|
||||
ctx.last_sequence = Some(0);
|
||||
let out = HashIntegrity.evaluate(&ctx);
|
||||
assert!(matches!(
|
||||
out,
|
||||
ConditionOutcome::Fail(BreakReason::TamperDetected)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_integrity_passes_when_match() {
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.sequence = 1;
|
||||
ctx.stored_tip_hash = Some("same".into());
|
||||
ctx.computed_hash = Some("same".into());
|
||||
ctx.last_sequence = Some(0);
|
||||
assert!(matches!(
|
||||
HashIntegrity.evaluate(&ctx),
|
||||
ConditionOutcome::Pass
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_continuity_passes_first_row() {
|
||||
assert!(matches!(
|
||||
SequenceContinuity.evaluate(&ctx_genesis()),
|
||||
ConditionOutcome::Pass
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_continuity_fails_on_gap() {
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.last_sequence = Some(5);
|
||||
ctx.sequence = 7;
|
||||
let out = SequenceContinuity.evaluate(&ctx);
|
||||
assert!(matches!(
|
||||
out,
|
||||
ConditionOutcome::Fail(BreakReason::SequenceGap)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_continuity_passes_on_next() {
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.last_sequence = Some(5);
|
||||
ctx.sequence = 6;
|
||||
assert!(matches!(
|
||||
SequenceContinuity.evaluate(&ctx),
|
||||
ConditionOutcome::Pass
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_returns_first_failure() {
|
||||
let conditions: Vec<Box<dyn Condition>> =
|
||||
vec![Box::new(HashIntegrity), Box::new(SequenceContinuity)];
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.sequence = 7;
|
||||
ctx.last_sequence = Some(5);
|
||||
ctx.stored_tip_hash = Some("abc".into());
|
||||
ctx.computed_hash = Some("def".into());
|
||||
// Both conditions fail — HashIntegrity runs first, wins.
|
||||
let out = evaluate(&conditions, &ctx);
|
||||
assert!(matches!(
|
||||
out,
|
||||
ConditionOutcome::Fail(BreakReason::TamperDetected)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_domain_soft_does_not_break() {
|
||||
let cond = SourceDomain::new(vec!["sec.gov".into()], false);
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.source_url = "http://evil.example/data".into();
|
||||
assert!(matches!(cond.evaluate(&ctx), ConditionOutcome::Pass));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_domain_hard_breaks_on_mismatch() {
|
||||
let cond = SourceDomain::new(vec!["sec.gov".into()], true);
|
||||
let mut ctx = ctx_genesis();
|
||||
ctx.source_url = "http://evil.example/data".into();
|
||||
assert!(matches!(
|
||||
cond.evaluate(&ctx),
|
||||
ConditionOutcome::Fail(BreakReason::SourceChange)
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user