Trusty: provenance library workspace (initial import)
This commit is contained in:
20
crates/core/trusty-store/Cargo.toml
Normal file
20
crates/core/trusty-store/Cargo.toml
Normal 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 }
|
||||
|
||||
821
crates/core/trusty-store/src/lib.rs
Normal file
821
crates/core/trusty-store/src/lib.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
128
crates/core/trusty-store/src/testing.rs
Normal file
128
crates/core/trusty-store/src/testing.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user