Trusty: provenance library workspace (initial import)

This commit is contained in:
2026-09-09 18:31:00 -04:00
commit 693f3bef1e
73 changed files with 26295 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
[package]
name = "trusty-store"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Storage trait for Trusty backends. Defines the contract any backend (sqlite, memory, postgres, etc.) must satisfy."
[features]
default = []
testing = []
[dependencies]
trusty-core = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@@ -0,0 +1,821 @@
//! Backend-agnostic `Store` trait for Trusty.
//!
//! Defines the contract any storage implementation must satisfy. Core logic in
//! `trusty-core` is unchanged regardless of which backend is in use -- callers
//! can swap SQLite for an in-memory store (or a future Postgres backend) without
//! touching hashing, canonicalization, or verification.
//!
//! Implementations: [`trusty-memory`] (ref impl / tests), [`trusty-sqlite`] (production).
#![deny(clippy::all)]
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[cfg(feature = "testing")]
pub mod testing;
// ------------------------------------------------------------
// Trait
// ------------------------------------------------------------
pub trait Store: Send {
fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>>;
fn insert_row(&mut self, row: &RowInsert) -> StoreResult<i64>;
fn iter_chain<'a>(
&'a self,
entity_id: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>>;
fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>>;
fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>>;
fn set_tip(&mut self, tip: &TipUpsert) -> StoreResult<()>;
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport>;
// Transactional scope. Closure returns `StoreResult<()>`; on Err
// the backend rolls back. Return-value capture uses outer state.
// We use `&mut dyn FnMut` instead of a generic closure because
// trait-object methods cannot be generic.
fn with_txn(&mut self, f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>)
-> StoreResult<()>;
}
// ------------------------------------------------------------
// Types
// ------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LastRow {
pub row_hash: String,
pub sequence: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TipRow {
pub entity_id: String,
pub chain_id: String,
pub current_tip_hash: Option<String>,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredRow {
pub id: i64,
pub entity_id: String,
pub chain_id: String,
pub event_type: String,
pub sequence: i64,
pub prev_hash: String,
pub row_hash: Option<String>,
pub anchor_hash: Option<String>,
pub raw_payload: Option<String>,
pub detected_at: String,
pub received_at: String,
pub source_url: String,
pub extraction_method: String,
}
#[derive(Debug, Clone)]
pub struct RowInsert {
pub entity_id: String,
pub chain_id: String,
pub event_type: String,
pub sequence: i64,
pub prev_hash: String,
pub row_hash: Option<String>,
pub anchor_hash: Option<String>,
pub raw_payload: Option<String>,
pub detected_at: String,
pub received_at: String,
pub source_url: String,
pub extraction_method: String,
}
#[derive(Debug, Clone)]
pub struct TipUpsert {
pub entity_id: String,
pub chain_id: String,
pub current_tip_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct KvCrossCheckReport {
pub ok: bool,
pub kv1_total_hashes: usize,
pub kv2_total_entities: usize,
pub entities_checked: usize,
pub mismatches: Vec<String>,
}
// ------------------------------------------------------------
// Higher-level result types
//
// Promoted from `trusty-sqlite` (S16, resolves §12 OQ #16 /
// `s14_insight_general_library_improvements_from_postgres_impl`
// items 1-2). Backend-agnostic shape; every adapter constructs and
// returns these. trusty-sqlite and trusty-postgres re-export them
// for convenience.
// ------------------------------------------------------------
/// Result of a single `append` (or `append_signed`). Encodes the
/// chain-break-as-event primitive: when a break is detected,
/// `chain_break = true`, `break_reason` is set, and `sealed_chain`
/// names the chain the break sealed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppendResult {
pub entity_id: String,
pub chain_id: String,
pub event_type: String,
pub chain_break: bool,
pub break_reason: Option<String>,
pub sealed_chain: Option<String>,
pub row_hash: String,
pub anchor_hash: String,
pub sequence: i64,
pub is_genesis: bool,
}
/// Operator-facing view of one BREAK envelope row. Surface for
/// `breaks_since` -- treats chain-break-as-event as a query result,
/// not an exception.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BreakEvent {
pub entity_id: String,
pub chain_id: String,
pub sealed_at_hash: String,
pub break_reason: String,
pub detected_at: String,
}
/// An entity that has not appended a non-BREAK row within
/// `max_gap_secs`. Returned by `liveness_check`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SilentEntity {
pub entity_id: String,
pub last_seen: String,
pub gap_secs: f64,
}
/// One row of `history(entity_id, ...)` output.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryRow {
pub id: i64,
pub chain_id: String,
pub entity_id: String,
pub event_type: String,
pub sequence: i64,
pub detected_at: String,
pub source_url: String,
pub row_hash: Option<String>,
pub anchor_hash: Option<String>,
pub raw_payload: Option<String>,
}
/// `integrity()` cross-check report. Aggregates KV cross-check
/// outcomes and any envelope/index drifts into one record.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrityReport {
pub ok: bool,
pub entities_checked: usize,
pub kv1_total_hashes: usize,
pub kv2_total_entities: usize,
pub mismatches: Vec<IntegrityMismatch>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrityMismatch {
pub entity_id: String,
pub reason: String,
pub detail: Option<String>,
}
/// Borrowed input row for `Trusty::append_batch`. Cheap to construct
/// from caller-owned data; one allocation per call, not per row.
/// For a signed batch, use per-row `append_signed` in a loop until a
/// signed-batch surface lands. Promoted from trusty-sqlite in S16
/// alongside the other result types.
#[derive(Debug, Clone, Copy)]
pub struct RowSpec<'a> {
pub entity_id: &'a str,
pub data: &'a serde_json::Value,
pub source_url: &'a str,
}
/// Lightweight tip handle returned by `Trusty::anchor`. Captures the
/// most recent anchor commitment for an entity (chain_id + tip_hash)
/// plus a server-side `anchored_at` timestamp at query time. For the
/// full per-row anchor stream use `anchors_since`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnchorInfo {
pub entity_id: String,
pub chain_id: String,
pub tip_hash: Option<String>,
pub anchored_at: String,
}
/// One row in the `producer_identity` table. OID-anchored producer
/// identity per `s14_insight_oid_anchored_chain_identity`. The OID
/// is the trust spine: a chain's `attested_by` references this row,
/// and the registered `ed25519_pubkey` is the only key trusty trusts
/// for verifying that attestation. `siphash_key` is the producer's
/// per-row SipHash key (see hash.rs::SecretKey); `metadata` is
/// optional caller-supplied JSON.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProducerIdentity {
pub producer_oid: String,
/// Key material, encoded per `key_format`.
pub ed25519_pubkey: Vec<u8>,
pub siphash_key: Vec<u8>,
pub registered_at: String,
pub display_name: Option<String>,
pub metadata: Option<serde_json::Value>,
/// How `ed25519_pubkey` is encoded, and therefore how a signature
/// from it must be checked. Defaults to raw Ed25519 for a row
/// written before the column existed -- a schema default at write
/// time, never a parse fallback.
#[serde(default)]
pub key_format: trusty_core::KeyFormat,
}
/// One row in the `producer_revocation` table. Forward-only
/// revocation per memo §6 / learn-LCG-ARCH-06: rows signed BEFORE
/// `revoked_at` remain verifiable; rows signed AT or AFTER are
/// rejected by `verify_since_checkpoint`. `revocation_row_hash`
/// references the REVOCATION envelope written to the per-user
/// `revocations` chain at revoke time -- the act of revoking is
/// itself a chained event.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevocationRecord {
pub producer_oid: String,
pub revoked_at: String,
pub revoked_pubkey: Vec<u8>,
pub reason: String,
pub attested_by_pubkey: Vec<u8>,
pub revocation_row_hash: String,
}
/// One row in the `chain_checkpoint` table. A checkpoint is a
/// verification-layer construct (memo §5, Gitea #13): a signed
/// `(chain_id, sequence_int, row_hash)` tuple that lets verifiers
/// prune their verify window to "from the most recent valid
/// checkpoint forward". `attested_by` names the attestor (typically
/// an OID from `producer_identity`); `attestation_sig` is Ed25519
/// over `trusty_core::checkpoint_signing_bytes(...)`. `checkpoint_at`
/// is the server-side timestamp at write time.
///
/// Chain payload bytes are NEVER touched by this primitive -- v1
/// byte-shape lock preserved.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckpointRow {
pub id: i64,
pub chain_id: String,
pub sequence_int: i64,
pub row_hash: String,
pub checkpoint_at: String,
pub attested_by: String,
pub attestation_sig: Vec<u8>,
}
// ------------------------------------------------------------
// Error surface
// ------------------------------------------------------------
#[derive(Debug, Error)]
pub enum StoreError {
#[error("not found: {0}")]
NotFound(String),
#[error("io error: {0}")]
Io(String),
#[error("integrity violation: {0}")]
IntegrityViolation(String),
#[error("serialization error: {0}")]
Serialization(String),
#[error("backend error: {0}")]
Backend(String),
}
pub type StoreResult<T> = Result<T, StoreError>;
// ------------------------------------------------------------
// TrustyExt -- Tier 1 Phase B (S20 cycle 1)
//
// Default-method extension trait for any `T: Store`. Promotes
// higher-level read paths (history, counts, latest hash) into a
// substrate-shared implementation so trusty-sqlite, trusty-postgres,
// trusty-memory, and any future backend gain them automatically by
// implementing only the Store trait.
//
// Cycle-1 scope is read-only and additive: existing backend Trusty
// types keep their direct implementations untouched. A subsequent
// cycle migrates them to delegate to TrustyExt and removes the
// duplication. Methods that depend on break-specific fields not yet
// in StoredRow (breaks_since with sealed_at_hash + break_reason,
// integrity with KV cross-check) follow in S21+ once the StoredRow
// shape is extended OR break-specific Store methods are added.
// ------------------------------------------------------------
/// Default-method extension trait for any `T: Store`. Implemented
/// blanket-style; no consumer action required beyond using the
/// methods.
pub trait TrustyExt: Store {
/// History view of an entity's chain: oldest-first, BREAK rows
/// excluded, rows without a `row_hash` excluded. `limit` capped
/// at `i64::MAX` when negative.
///
/// Mirrors the existing per-backend `history(entity_id, limit)`
/// surface, sourced from `Store::iter_chain` so the result shape
/// is uniform across backends.
fn history(&self, entity_id: &str, limit: i64) -> StoreResult<Vec<HistoryRow>> {
let cap = if limit < 0 {
i64::MAX as usize
} else {
limit as usize
};
let mut out: Vec<HistoryRow> = Vec::new();
for row_res in self.iter_chain(entity_id)? {
let row = row_res?;
if row.event_type == "BREAK" || row.row_hash.is_none() {
continue;
}
out.push(stored_to_history(row));
if out.len() >= cap {
break;
}
}
Ok(out)
}
/// Count of rows for an entity (BREAK rows included). Cheaper
/// than collecting `history` when the caller only wants the
/// length.
fn count_rows(&self, entity_id: &str) -> StoreResult<usize> {
let mut n: usize = 0;
for row_res in self.iter_chain(entity_id)? {
let _ = row_res?;
n += 1;
}
Ok(n)
}
/// Latest non-BREAK row hash for an entity. Wraps
/// [`Store::last_row`] for the common single-field-of-interest
/// case.
fn latest_row_hash(&self, entity_id: &str) -> StoreResult<Option<String>> {
Ok(self.last_row(entity_id)?.map(|r| r.row_hash))
}
/// Latest non-BREAK sequence number for an entity. Useful for
/// "how far has this chain advanced" reporting without loading
/// full history.
fn latest_sequence(&self, entity_id: &str) -> StoreResult<Option<i64>> {
Ok(self.last_row(entity_id)?.map(|r| r.sequence))
}
/// Count of BREAK rows for an entity. Health-check primitive:
/// nonzero indicates the chain has been sealed at least once.
fn count_breaks(&self, entity_id: &str) -> StoreResult<usize> {
let mut n: usize = 0;
for row_res in self.iter_chain(entity_id)? {
let row = row_res?;
if row.event_type == "BREAK" {
n += 1;
}
}
Ok(n)
}
/// Whether the entity has at least one row of any kind. Cheaper
/// than counting; short-circuits on first hit.
fn entity_seen(&self, entity_id: &str) -> StoreResult<bool> {
// `.next()` rather than a `for` that returns on its first pass: the loop
// form read as an iteration and was not one, which `#![deny(clippy::all)]`
// at the top of this file rejects as `never_loop`. Behaviour is
// unchanged -- first row wins, and its error still propagates.
match self.iter_chain(entity_id)?.next() {
Some(row_res) => {
row_res?;
Ok(true)
}
None => Ok(false),
}
}
/// Current chain id for an entity, if a tip exists. Wraps
/// [`Store::get_tip_for_entity`] for the common id-only case.
fn chain_id_for(&self, entity_id: &str) -> StoreResult<Option<String>> {
Ok(self.get_tip_for_entity(entity_id)?.map(|t| t.chain_id))
}
/// Current tip hash for an entity, if a tip exists and has been
/// hashed. Wraps [`Store::get_tip_for_entity`].
fn tip_hash(&self, entity_id: &str) -> StoreResult<Option<String>> {
Ok(self
.get_tip_for_entity(entity_id)?
.and_then(|t| t.current_tip_hash))
}
/// All BREAK rows for an entity, oldest-first, as raw
/// `StoredRow`s. Consumers parse `raw_payload` for break-specific
/// fields (sealed-at hash, reason, etc.) since those live in the
/// JSON envelope rather than in dedicated columns of every
/// backend.
///
/// Backend-portable replacement for the per-backend
/// `breaks_since(...)` surface; richer typed views (with
/// `BreakEvent`) stay backend-side until break-specific column
/// access lands on the `Store` trait.
fn iter_breaks(&self, entity_id: &str) -> StoreResult<Vec<StoredRow>> {
let mut out: Vec<StoredRow> = Vec::new();
for row_res in self.iter_chain(entity_id)? {
let row = row_res?;
if row.event_type == "BREAK" {
out.push(row);
}
}
Ok(out)
}
/// Most recent BREAK row for an entity, if any. Cheaper than
/// loading the full break list when only the latest seal is of
/// interest (e.g. health checks).
fn latest_break(&self, entity_id: &str) -> StoreResult<Option<StoredRow>> {
Ok(self.iter_breaks(entity_id)?.pop())
}
/// Count of rows for an entity matching a given `event_type`.
/// Useful for "how many APPEND vs BREAK vs CHECKPOINT" reporting
/// without parsing JSON. `event_type` is case-sensitive,
/// matching the canonical envelope vocabulary.
fn count_events(&self, entity_id: &str, event_type: &str) -> StoreResult<usize> {
let mut n: usize = 0;
for row_res in self.iter_chain(entity_id)? {
let row = row_res?;
if row.event_type == event_type {
n += 1;
}
}
Ok(n)
}
/// All rows for an entity matching a given `event_type`,
/// oldest-first. Generic filter -- callers asking for "EVENT" get
/// the non-BREAK chain; "BREAK" mirrors [`Self::iter_breaks`];
/// future event types (CHECKPOINT, REVOCATION, etc.) work the
/// same way.
fn iter_by_event_type(&self, entity_id: &str, event_type: &str) -> StoreResult<Vec<StoredRow>> {
let mut out: Vec<StoredRow> = Vec::new();
for row_res in self.iter_chain(entity_id)? {
let row = row_res?;
if row.event_type == event_type {
out.push(row);
}
}
Ok(out)
}
}
impl<T: Store + ?Sized> TrustyExt for T {}
fn stored_to_history(row: StoredRow) -> HistoryRow {
HistoryRow {
id: row.id,
chain_id: row.chain_id,
entity_id: row.entity_id,
event_type: row.event_type,
sequence: row.sequence,
detected_at: row.detected_at,
source_url: row.source_url,
row_hash: row.row_hash,
anchor_hash: row.anchor_hash,
raw_payload: row.raw_payload,
}
}
#[cfg(test)]
mod trusty_ext_tests {
use super::*;
use std::collections::{BTreeMap, HashMap};
/// Minimal in-test Store impl. Mirrors trusty-memory but lives
/// inside trusty-store to avoid a dev-dep cycle (trusty-memory
/// depends on trusty-store).
#[derive(Clone, Default)]
struct TestStore {
rows: BTreeMap<i64, StoredRow>,
next_id: i64,
tips_by_chain: HashMap<String, TipRow>,
tips_by_entity: HashMap<String, TipRow>,
}
impl Store for TestStore {
fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>> {
Ok(self
.rows
.values()
.rev()
.find(|r| {
r.entity_id == entity_id && r.event_type != "BREAK" && r.row_hash.is_some()
})
.map(|r| LastRow {
row_hash: r.row_hash.clone().unwrap(),
sequence: r.sequence,
}))
}
fn insert_row(&mut self, row: &RowInsert) -> StoreResult<i64> {
self.next_id += 1;
let id = self.next_id;
self.rows.insert(
id,
StoredRow {
id,
entity_id: row.entity_id.clone(),
chain_id: row.chain_id.clone(),
event_type: row.event_type.clone(),
sequence: row.sequence,
prev_hash: row.prev_hash.clone(),
row_hash: row.row_hash.clone(),
anchor_hash: row.anchor_hash.clone(),
raw_payload: row.raw_payload.clone(),
detected_at: row.detected_at.clone(),
received_at: row.received_at.clone(),
source_url: row.source_url.clone(),
extraction_method: row.extraction_method.clone(),
},
);
Ok(id)
}
fn iter_chain<'a>(
&'a self,
entity_id: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>> {
let entity = entity_id.to_string();
Ok(Box::new(
self.rows
.values()
.filter(move |r| r.entity_id == entity)
.cloned()
.map(Ok),
))
}
fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>> {
Ok(self.tips_by_chain.get(chain_id).cloned())
}
fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>> {
Ok(self.tips_by_entity.get(entity_id).cloned())
}
fn set_tip(&mut self, tip: &TipUpsert) -> StoreResult<()> {
let row = TipRow {
entity_id: tip.entity_id.clone(),
chain_id: tip.chain_id.clone(),
current_tip_hash: tip.current_tip_hash.clone(),
updated_at: String::new(),
};
self.tips_by_chain.insert(tip.chain_id.clone(), row.clone());
self.tips_by_entity.insert(tip.entity_id.clone(), row);
Ok(())
}
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
Ok(KvCrossCheckReport {
ok: true,
kv1_total_hashes: self.rows.values().filter(|r| r.row_hash.is_some()).count(),
kv2_total_entities: self.tips_by_entity.len(),
entities_checked: self.tips_by_entity.len(),
mismatches: vec![],
})
}
fn with_txn(
&mut self,
f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>,
) -> StoreResult<()> {
let snap = self.clone();
match f(self) {
Ok(()) => Ok(()),
Err(e) => {
*self = snap;
Err(e)
}
}
}
}
fn insert(store: &mut TestStore, entity: &str, seq: i64, event: &str, hash: Option<&str>) {
let row = RowInsert {
entity_id: entity.into(),
chain_id: format!("chain_{entity}_001"),
event_type: event.into(),
sequence: seq,
prev_hash: "genesis".into(),
row_hash: hash.map(str::to_string),
anchor_hash: hash.map(str::to_string),
raw_payload: Some(format!("{{\"seq\":{}}}", seq)),
detected_at: format!("2026-05-09T00:00:{:02}Z", seq),
received_at: format!("2026-05-09T00:00:{:02}.000Z", seq),
source_url: "test://".into(),
extraction_method: "trusty".into(),
};
store.insert_row(&row).unwrap();
store
.set_tip(&TipUpsert {
entity_id: entity.into(),
chain_id: row.chain_id.clone(),
current_tip_hash: hash.map(str::to_string),
})
.unwrap();
}
#[test]
fn history_excludes_break_rows_and_unhashed_rows() {
let mut store = TestStore::default();
insert(&mut store, "ent_a", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_a", 2, "BREAK", Some("h2"));
insert(&mut store, "ent_a", 3, "EVENT", Some("h3"));
insert(&mut store, "ent_a", 4, "EVENT", None); // unhashed -- excluded
let hist = store.history("ent_a", 100).unwrap();
assert_eq!(hist.len(), 2, "BREAK + unhashed excluded: {:?}", hist);
assert_eq!(hist[0].sequence, 1);
assert_eq!(hist[1].sequence, 3);
assert!(hist.iter().all(|h| h.event_type != "BREAK"));
assert!(hist.iter().all(|h| h.row_hash.is_some()));
}
#[test]
fn history_respects_positive_limit() {
let mut store = TestStore::default();
for i in 1..=5 {
insert(&mut store, "ent_b", i, "EVENT", Some(&format!("h{i}")));
}
let hist = store.history("ent_b", 3).unwrap();
assert_eq!(hist.len(), 3);
assert_eq!(hist[0].sequence, 1);
assert_eq!(hist[2].sequence, 3);
}
#[test]
fn history_treats_negative_limit_as_unbounded() {
let mut store = TestStore::default();
for i in 1..=5 {
insert(&mut store, "ent_c", i, "EVENT", Some(&format!("h{i}")));
}
let hist = store.history("ent_c", -1).unwrap();
assert_eq!(hist.len(), 5);
}
#[test]
fn count_rows_includes_break_rows() {
let mut store = TestStore::default();
insert(&mut store, "ent_d", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_d", 2, "BREAK", Some("h2"));
insert(&mut store, "ent_d", 3, "EVENT", Some("h3"));
let n = store.count_rows("ent_d").unwrap();
assert_eq!(n, 3);
}
#[test]
fn latest_row_hash_returns_last_non_break_hash() {
let mut store = TestStore::default();
insert(&mut store, "ent_e", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_e", 2, "EVENT", Some("h2"));
insert(&mut store, "ent_e", 3, "BREAK", Some("hbreak"));
let h = store.latest_row_hash("ent_e").unwrap();
// last_row excludes BREAK + unhashed per Store::last_row contract,
// so we expect h2 (the latest non-BREAK).
assert_eq!(h.as_deref(), Some("h2"));
}
#[test]
fn empty_entity_returns_empty_results() {
let store = TestStore::default();
assert_eq!(store.history("nope", 10).unwrap().len(), 0);
assert_eq!(store.count_rows("nope").unwrap(), 0);
assert_eq!(store.latest_row_hash("nope").unwrap(), None);
}
#[test]
fn latest_sequence_returns_last_non_break_seq() {
let mut store = TestStore::default();
insert(&mut store, "ent_g", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_g", 2, "EVENT", Some("h2"));
insert(&mut store, "ent_g", 3, "BREAK", Some("hb"));
assert_eq!(store.latest_sequence("ent_g").unwrap(), Some(2));
assert_eq!(store.latest_sequence("nope").unwrap(), None);
}
#[test]
fn count_breaks_counts_only_break_rows() {
let mut store = TestStore::default();
insert(&mut store, "ent_h", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_h", 2, "BREAK", Some("hb"));
insert(&mut store, "ent_h", 3, "EVENT", Some("h3"));
insert(&mut store, "ent_h", 4, "BREAK", Some("hb2"));
assert_eq!(store.count_breaks("ent_h").unwrap(), 2);
assert_eq!(store.count_breaks("nope").unwrap(), 0);
}
#[test]
fn entity_seen_short_circuits() {
let mut store = TestStore::default();
insert(&mut store, "ent_i", 1, "EVENT", Some("h1"));
assert!(store.entity_seen("ent_i").unwrap());
assert!(!store.entity_seen("nope").unwrap());
}
#[test]
fn chain_id_for_and_tip_hash_track_current_tip() {
let mut store = TestStore::default();
insert(&mut store, "ent_j", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_j", 2, "EVENT", Some("h2"));
assert_eq!(
store.chain_id_for("ent_j").unwrap().as_deref(),
Some("chain_ent_j_001")
);
assert_eq!(store.tip_hash("ent_j").unwrap().as_deref(), Some("h2"));
assert_eq!(store.chain_id_for("nope").unwrap(), None);
assert_eq!(store.tip_hash("nope").unwrap(), None);
}
#[test]
fn iter_breaks_returns_only_break_rows_oldest_first() {
let mut store = TestStore::default();
insert(&mut store, "ent_k", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_k", 2, "BREAK", Some("hb1"));
insert(&mut store, "ent_k", 3, "EVENT", Some("h3"));
insert(&mut store, "ent_k", 4, "BREAK", Some("hb2"));
let breaks = store.iter_breaks("ent_k").unwrap();
assert_eq!(breaks.len(), 2);
assert_eq!(breaks[0].sequence, 2);
assert_eq!(breaks[1].sequence, 4);
assert!(breaks.iter().all(|r| r.event_type == "BREAK"));
}
#[test]
fn latest_break_returns_most_recent() {
let mut store = TestStore::default();
insert(&mut store, "ent_l", 1, "BREAK", Some("hb1"));
insert(&mut store, "ent_l", 2, "EVENT", Some("h2"));
insert(&mut store, "ent_l", 3, "BREAK", Some("hb2"));
let latest = store.latest_break("ent_l").unwrap().unwrap();
assert_eq!(latest.sequence, 3);
assert_eq!(latest.row_hash.as_deref(), Some("hb2"));
assert!(store.latest_break("nope").unwrap().is_none());
}
#[test]
fn count_events_matches_event_type_exactly() {
let mut store = TestStore::default();
insert(&mut store, "ent_m", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_m", 2, "BREAK", Some("hb"));
insert(&mut store, "ent_m", 3, "CHECKPOINT", Some("hc"));
insert(&mut store, "ent_m", 4, "EVENT", Some("h2"));
assert_eq!(store.count_events("ent_m", "EVENT").unwrap(), 2);
assert_eq!(store.count_events("ent_m", "BREAK").unwrap(), 1);
assert_eq!(store.count_events("ent_m", "CHECKPOINT").unwrap(), 1);
assert_eq!(store.count_events("ent_m", "UNKNOWN").unwrap(), 0);
}
#[test]
fn iter_by_event_type_returns_filtered_rows_in_order() {
let mut store = TestStore::default();
insert(&mut store, "ent_n", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_n", 2, "CHECKPOINT", Some("hc1"));
insert(&mut store, "ent_n", 3, "EVENT", Some("h2"));
insert(&mut store, "ent_n", 4, "CHECKPOINT", Some("hc2"));
let cps = store.iter_by_event_type("ent_n", "CHECKPOINT").unwrap();
assert_eq!(cps.len(), 2);
assert_eq!(cps[0].sequence, 2);
assert_eq!(cps[1].sequence, 4);
let evs = store.iter_by_event_type("ent_n", "EVENT").unwrap();
assert_eq!(evs.len(), 2);
assert!(evs.iter().all(|r| r.event_type == "EVENT"));
}
#[test]
fn trusty_ext_is_object_safe_via_dyn_store() {
// The whole point of TrustyExt is that it's blanket-impl'd
// for `T: Store + ?Sized`, including `dyn Store`. Verify by
// calling through a trait object.
let mut store = TestStore::default();
insert(&mut store, "ent_f", 1, "EVENT", Some("h1"));
let dyn_store: &dyn Store = &store;
assert_eq!(dyn_store.count_rows("ent_f").unwrap(), 1);
assert_eq!(
dyn_store.latest_row_hash("ent_f").unwrap().as_deref(),
Some("h1")
);
}
}

View File

@@ -0,0 +1,128 @@
// Shared conformance harness for Store implementations.
//
// Enabled via the `testing` feature. Each backend crate depends on
// trusty-store with features = ["testing"] in its dev-dependencies
// and calls `run_conformance` from its own integration test.
//
// Adding a new Store backend? Pass its fresh, empty instance here;
// if this function returns without panicking, the backend satisfies
// the basic contract Trusty needs.
use crate::{RowInsert, Store, StoreError, StoreResult, TipUpsert};
pub fn run_conformance<S: Store>(store: &mut S) {
insert_and_read_back(store);
iter_chain_ordered(store);
tip_roundtrip(store);
kv_cross_check_clean(store);
with_txn_commit(store);
with_txn_rollback(store);
}
fn mk_row(entity: &str, chain: &str, seq: i64, row_hash: &str) -> RowInsert {
RowInsert {
entity_id: entity.to_string(),
chain_id: chain.to_string(),
event_type: if seq == 0 {
"GENESIS".into()
} else {
"MUTATION".into()
},
sequence: seq,
prev_hash: if seq == 0 {
"genesis".to_string()
} else {
format!("prev_{}", seq - 1)
},
row_hash: Some(row_hash.to_string()),
anchor_hash: Some("a".repeat(64)),
raw_payload: Some(format!("{{\"v\":{}}}", seq)),
detected_at: "2026-04-15T00:00:00Z".to_string(),
received_at: "2026-04-15T00:00:00.001000Z".to_string(),
source_url: "s://test".to_string(),
extraction_method: "trusty".to_string(),
}
}
fn insert_and_read_back<S: Store>(store: &mut S) {
let row = mk_row("ent_conf_a", "chain_conf_a", 0, "000000000000000a");
store.insert_row(&row).expect("insert ok");
let last = store
.last_row("ent_conf_a")
.expect("last ok")
.expect("some");
assert_eq!(last.row_hash, "000000000000000a");
assert_eq!(last.sequence, 0);
}
fn iter_chain_ordered<S: Store>(store: &mut S) {
for i in 0..3i64 {
let h = format!("{:016x}", 0xB00_u64 + i as u64);
let row = mk_row("ent_conf_b", "chain_conf_b", i, &h);
store.insert_row(&row).expect("insert ok");
}
let rows: Vec<_> = store
.iter_chain("ent_conf_b")
.expect("iter ok")
.collect::<Result<Vec<_>, _>>()
.expect("collect ok");
assert_eq!(rows.len(), 3, "expected 3 rows, got {:?}", rows.len());
for (i, row) in rows.iter().enumerate() {
assert_eq!(row.sequence, i as i64, "out of order at index {}", i);
}
}
fn tip_roundtrip<S: Store>(store: &mut S) {
let row = mk_row("ent_conf_c", "chain_conf_c", 0, "cafebabecafebabe");
store.insert_row(&row).expect("insert for tip roundtrip");
let tip = TipUpsert {
entity_id: "ent_conf_c".to_string(),
chain_id: "chain_conf_c".to_string(),
current_tip_hash: Some("cafebabecafebabe".to_string()),
};
store.set_tip(&tip).expect("set tip");
let got = store
.get_tip("chain_conf_c")
.expect("get_tip ok")
.expect("some");
assert_eq!(got.current_tip_hash.as_deref(), Some("cafebabecafebabe"));
let got_ent = store
.get_tip_for_entity("ent_conf_c")
.expect("get_tip_for_entity ok")
.expect("some");
assert_eq!(got_ent.chain_id, "chain_conf_c");
}
fn kv_cross_check_clean<S: Store>(store: &mut S) {
let report = store.kv_cross_check().expect("cross check ok");
assert!(report.ok, "expected clean store ok: {:?}", report);
}
fn with_txn_commit<S: Store>(store: &mut S) {
store
.with_txn(&mut |s: &mut dyn Store| {
let row = mk_row("ent_conf_d", "chain_conf_d", 0, "d000000000000001");
s.insert_row(&row)?;
Ok(())
})
.expect("txn commit ok");
let last = store
.last_row("ent_conf_d")
.expect("last ok")
.expect("committed row visible");
assert_eq!(last.row_hash, "d000000000000001");
}
fn with_txn_rollback<S: Store>(store: &mut S) {
let result = store.with_txn(&mut |s: &mut dyn Store| {
let row = mk_row("ent_conf_e", "chain_conf_e", 0, "e000000000000001");
s.insert_row(&row)?;
Err(StoreError::Backend("rollback test".into()))
});
assert!(result.is_err(), "closure returning Err must propagate");
let last: StoreResult<Option<_>> = store.last_row("ent_conf_e");
assert!(
last.expect("last ok").is_none(),
"rolled-back row must not be visible after txn rollback"
);
}