mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
338 lines
11 KiB
Rust
338 lines
11 KiB
Rust
//! Replay engine: validate a sequence of [`TraceStep`]s against the spec's
|
|
//! transition relation (re-implemented in [`crate::transitions`]).
|
|
//!
|
|
//! The checker is intentionally minimal — it walks the trace, bootstraps
|
|
//! its model on the first step, and runs [`transitions::check_step`] for
|
|
//! each subsequent step. The first failure stops the trace (fail-closed).
|
|
//!
|
|
//! The other half of the checker's job is **coverage breach**: declaring
|
|
//! up-front which critical actions a scenario MUST exercise, and failing
|
|
//! the trace if any are missing. Without this, a regression that silently
|
|
//! removed an emit site would still pass conformance — the trace would
|
|
//! just be shorter. The skill is explicit: this mode is mandatory.
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use crate::{
|
|
transitions::{check_step, ModelState, TransitionError},
|
|
TraceStep,
|
|
};
|
|
|
|
/// A scenario the checker is validating: the recorded trace plus the set
|
|
/// of critical actions the scenario asserts must appear.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Scenario {
|
|
/// Trace steps in emission order. Stamps and worker ids are NOT
|
|
/// modeled — observations are unordered in the spec, so the only
|
|
/// invariant the order enforces is "within one request, observations
|
|
/// share the same `state_after`".
|
|
pub trace: Vec<TraceStep>,
|
|
/// Action kinds that this scenario must include at least once. If any
|
|
/// are missing the checker returns a coverage breach.
|
|
///
|
|
/// Use [`crate::TraceAction::kind`] to get the canonical strings:
|
|
/// `"write_insert"`, `"write_insert_global"`, `"write_duplicate"`,
|
|
/// `"sanitized_error"`, `"auth_check"`, `"read_message_rows"`,
|
|
/// `"read_by_id_rows"`, `"read_host_feed_rows"`.
|
|
pub required_critical_actions: HashSet<String>,
|
|
}
|
|
|
|
impl Scenario {
|
|
/// Build a scenario with no required actions — used for traces where
|
|
/// the only thing being asserted is "every observation is consistent
|
|
/// with non-interference". Most ingest fixtures need explicit
|
|
/// requirements; this helper is for replays of unstructured traffic.
|
|
pub fn unstructured(trace: Vec<TraceStep>) -> Self {
|
|
Self {
|
|
trace,
|
|
required_critical_actions: HashSet::new(),
|
|
}
|
|
}
|
|
|
|
/// Builder helper: add a required critical action kind. Returns self
|
|
/// for chaining.
|
|
pub fn require(mut self, kind: &str) -> Self {
|
|
self.required_critical_actions.insert(kind.to_string());
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Check one scenario. Returns `Ok(())` on conformance; returns the first
|
|
/// transition error on any failure.
|
|
///
|
|
/// Stages:
|
|
/// 1. **Bootstrap.** Read the first step's `state_after` as the model.
|
|
/// A trace with zero steps fails as a coverage breach (the seam was
|
|
/// reached and emitted nothing).
|
|
/// 2. **Schema-version check.** Each step's `schema_version` must equal
|
|
/// [`crate::SCHEMA_VERSION`] — a divergence means the relay and the
|
|
/// checker speak different schemas. We treat that as an illegal
|
|
/// transition because no transition rule applies.
|
|
/// 3. **Per-step transition check.** [`check_step`] runs on each step.
|
|
/// 4. **Coverage check.** After all steps pass, every entry in
|
|
/// `required_critical_actions` must appear in the trace.
|
|
pub fn check_trace(scenario: &Scenario) -> Result<(), TransitionError> {
|
|
if scenario.trace.is_empty() {
|
|
return Err(TransitionError::CoverageBreach {
|
|
detail: "trace is empty — seam reached without emitting any action; \
|
|
this is the no-trace coverage breach"
|
|
.to_string(),
|
|
});
|
|
}
|
|
|
|
let first = &scenario.trace[0];
|
|
if first.schema_version != crate::SCHEMA_VERSION {
|
|
return Err(TransitionError::IllegalTransition {
|
|
step_index: 0,
|
|
detail: format!(
|
|
"trace schema_version={} but checker schema_version={}",
|
|
first.schema_version,
|
|
crate::SCHEMA_VERSION
|
|
),
|
|
});
|
|
}
|
|
let model = ModelState::bootstrap(&first.state_after);
|
|
|
|
for (i, step) in scenario.trace.iter().enumerate() {
|
|
if step.schema_version != crate::SCHEMA_VERSION {
|
|
return Err(TransitionError::IllegalTransition {
|
|
step_index: i,
|
|
detail: format!(
|
|
"trace schema_version={} but checker schema_version={}",
|
|
step.schema_version,
|
|
crate::SCHEMA_VERSION
|
|
),
|
|
});
|
|
}
|
|
check_step(i, &model, step)?;
|
|
}
|
|
|
|
// Coverage breach: required actions missing.
|
|
let mut seen: HashSet<String> = HashSet::with_capacity(scenario.trace.len());
|
|
for step in &scenario.trace {
|
|
seen.insert(step.action.kind().to_string());
|
|
}
|
|
let missing: Vec<&String> = scenario
|
|
.required_critical_actions
|
|
.iter()
|
|
.filter(|k| !seen.contains(*k))
|
|
.collect();
|
|
if !missing.is_empty() {
|
|
let mut sorted: Vec<&&String> = missing.iter().collect();
|
|
sorted.sort();
|
|
return Err(TransitionError::CoverageBreach {
|
|
detail: format!(
|
|
"scenario required actions never emitted: {:?}",
|
|
sorted.iter().map(|s| s.as_str()).collect::<Vec<_>>()
|
|
),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::CommunityLabel;
|
|
use crate::{
|
|
AbstractState, ActorLabel, ChannelLabel, HostLabel, OpaqueId, SanitizedReason, TraceAction,
|
|
Verdict,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
fn cid(u: u128) -> CommunityLabel {
|
|
CommunityLabel::from_uuid(Uuid::from_u128(u))
|
|
}
|
|
|
|
fn ch(u: u128) -> ChannelLabel {
|
|
ChannelLabel(Uuid::from_u128(u))
|
|
}
|
|
|
|
fn state(c: CommunityLabel) -> AbstractState {
|
|
AbstractState {
|
|
resolved_community: c,
|
|
bound_host: HostLabel("h_local".into()),
|
|
actor: ActorLabel("a_alice".into()),
|
|
}
|
|
}
|
|
|
|
fn step(action: TraceAction, c: CommunityLabel) -> TraceStep {
|
|
TraceStep::new(action, state(c))
|
|
}
|
|
|
|
#[test]
|
|
fn empty_trace_is_coverage_breach() {
|
|
let sc = Scenario::unstructured(vec![]);
|
|
let err = check_trace(&sc).unwrap_err();
|
|
assert!(matches!(err, TransitionError::CoverageBreach { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn write_insert_then_read_with_only_resolved_rows_passes() {
|
|
let c = cid(1);
|
|
let trace = vec![
|
|
step(
|
|
TraceAction::AuthCheck {
|
|
channel: ch(10),
|
|
claimed_community: Some(c),
|
|
verdict: Verdict::Allow,
|
|
},
|
|
c,
|
|
),
|
|
step(
|
|
TraceAction::WriteInsert {
|
|
msg_id: OpaqueId("m1".into()),
|
|
channel: ch(10),
|
|
claimed_community: Some(c),
|
|
},
|
|
c,
|
|
),
|
|
step(
|
|
TraceAction::ReadMessageRows {
|
|
channel: Some(ch(10)),
|
|
row_communities: vec![c, c],
|
|
},
|
|
c,
|
|
),
|
|
];
|
|
let sc = Scenario {
|
|
trace,
|
|
required_critical_actions: ["auth_check", "write_insert", "read_message_rows"]
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect(),
|
|
};
|
|
check_trace(&sc).expect("trace should conform");
|
|
}
|
|
|
|
#[test]
|
|
fn cross_community_row_bites_non_interference() {
|
|
let c = cid(1);
|
|
let foreign = cid(2);
|
|
let trace = vec![step(
|
|
TraceAction::ReadMessageRows {
|
|
channel: Some(ch(10)),
|
|
row_communities: vec![c, foreign],
|
|
},
|
|
c,
|
|
)];
|
|
let err = check_trace(&Scenario::unstructured(trace)).unwrap_err();
|
|
assert!(
|
|
matches!(err, TransitionError::NonInterference { .. }),
|
|
"expected NonInterference, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_allow_with_foreign_claim_bites_m2() {
|
|
let c = cid(1);
|
|
let foreign = cid(2);
|
|
let trace = vec![step(
|
|
TraceAction::AuthCheck {
|
|
channel: ch(10),
|
|
claimed_community: Some(foreign),
|
|
verdict: Verdict::Allow,
|
|
},
|
|
c,
|
|
)];
|
|
let err = check_trace(&Scenario::unstructured(trace)).unwrap_err();
|
|
assert!(
|
|
matches!(err, TransitionError::IllegalTransition { .. }),
|
|
"expected IllegalTransition for M2 bite, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_deny_with_foreign_claim_is_fine() {
|
|
let c = cid(1);
|
|
let foreign = cid(2);
|
|
let trace = vec![step(
|
|
TraceAction::AuthCheck {
|
|
channel: ch(10),
|
|
claimed_community: Some(foreign),
|
|
verdict: Verdict::Deny,
|
|
},
|
|
c,
|
|
)];
|
|
check_trace(&Scenario::unstructured(trace)).expect("deny with foreign claim is in-spec");
|
|
}
|
|
|
|
#[test]
|
|
fn state_after_changing_mid_request_is_state_mismatch() {
|
|
let c1 = cid(1);
|
|
let c2 = cid(2);
|
|
let trace = vec![
|
|
step(
|
|
TraceAction::AuthCheck {
|
|
channel: ch(10),
|
|
claimed_community: Some(c1),
|
|
verdict: Verdict::Allow,
|
|
},
|
|
c1,
|
|
),
|
|
step(
|
|
TraceAction::ReadMessageRows {
|
|
channel: Some(ch(10)),
|
|
row_communities: vec![c2],
|
|
},
|
|
c2,
|
|
),
|
|
];
|
|
let err = check_trace(&Scenario::unstructured(trace)).unwrap_err();
|
|
assert!(
|
|
matches!(err, TransitionError::StateMismatch { .. }),
|
|
"expected StateMismatch, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn impl_bug_action_bites_coverage_breach() {
|
|
let c = cid(1);
|
|
let trace = vec![step(
|
|
TraceAction::ImplBug {
|
|
kind: "ingest_exited_without_trace".into(),
|
|
},
|
|
c,
|
|
)];
|
|
let err = check_trace(&Scenario::unstructured(trace)).unwrap_err();
|
|
assert!(
|
|
matches!(err, TransitionError::CoverageBreach { .. }),
|
|
"expected CoverageBreach from ImplBug, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn required_critical_action_missing_bites_coverage_breach() {
|
|
let c = cid(1);
|
|
let trace = vec![step(
|
|
TraceAction::SanitizedError {
|
|
reason: SanitizedReason::Restricted,
|
|
},
|
|
c,
|
|
)];
|
|
let sc = Scenario {
|
|
trace,
|
|
required_critical_actions: ["auth_check".to_string()].into_iter().collect(),
|
|
};
|
|
let err = check_trace(&sc).unwrap_err();
|
|
assert!(
|
|
matches!(err, TransitionError::CoverageBreach { ref detail } if detail.contains("auth_check")),
|
|
"expected CoverageBreach naming auth_check, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sanitized_error_alone_is_well_formed() {
|
|
let c = cid(1);
|
|
for reason in [
|
|
SanitizedReason::Restricted,
|
|
SanitizedReason::Invalid,
|
|
SanitizedReason::ServerError,
|
|
] {
|
|
let trace = vec![step(TraceAction::SanitizedError { reason }, c)];
|
|
check_trace(&Scenario::unstructured(trace)).expect("sanitized_error alone is in-spec");
|
|
}
|
|
}
|
|
}
|