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,39 @@
use thiserror::Error;
use trusty_store::StoreError;
#[derive(Debug, Error)]
pub enum PostgresError {
#[error("postgres driver error: {0}")]
Driver(#[from] postgres::Error),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("malformed chain_id: {0}")]
MalformedChainId(String),
#[error("schema migration error: {0}")]
Migration(String),
#[error("integrity violation: {0}")]
IntegrityViolation(String),
#[error("not found: {0}")]
NotFound(String),
#[error("trusty-core error: {0}")]
Core(#[from] trusty_core::TrustyError),
}
pub type PostgresResult<T> = Result<T, PostgresError>;
impl From<PostgresError> for StoreError {
fn from(err: PostgresError) -> Self {
match err {
PostgresError::NotFound(s) => StoreError::NotFound(s),
PostgresError::IntegrityViolation(s) => StoreError::IntegrityViolation(s),
PostgresError::Serialization(e) => StoreError::Serialization(e.to_string()),
other => StoreError::Backend(other.to_string()),
}
}
}

View File

@@ -0,0 +1,31 @@
//! PostgreSQL adapter for Trusty.
//!
//! Implements the `trusty_store::Store` trait against PostgreSQL 14+ using
//! the synchronous `postgres` crate. Mirrors the three-table schema of
//! `trusty-sqlite` (`provenance_envelope`, `hash_index`, `chain_tips`) with
//! Postgres dialect and adds:
//! - `chain_checkpoint` table for verification-layer checkpoints (#13).
//! - `producer_identity` table for OID-anchored producer identity (#14, #22).
//! - `schema_meta` table for planned schema migration versioning.
//! - Optional TimescaleDB hypertable on `received_at` via `timescale` feature.
//!
//! Design memo: `docs/trusty-postgres-design.md`.
//!
//! TCB note: the trusted computing base added by this adapter is Postgres +
//! libpq + the `postgres` Rust driver. Trusty chain math is backend-agnostic;
//! v1 byte-shape is preserved by copying `raw_payload` verbatim as `TEXT`
//! (never `JSONB`, which would normalize whitespace and key order).
#![deny(clippy::all)]
pub mod error;
pub mod schema;
pub mod store;
pub mod trusty;
pub use error::{PostgresError, PostgresResult};
pub use store::TrustyPostgres;
pub use trusty::{
AnchorInfo, AppendResult, BreakEvent, CheckpointRow, HistoryRow, IntegrityMismatch,
IntegrityReport, ProducerIdentity, RevocationRecord, RowSpec, SilentEntity, Trusty,
};

View File

@@ -0,0 +1,144 @@
//! Schema DDL for trusty-postgres.
//!
//! Mirrors the three-table layout of trusty-sqlite (provenance_envelope,
//! hash_index, chain_tips) in Postgres dialect. Adds chain_checkpoint,
//! producer_identity, and schema_meta. See docs/trusty-postgres-design.md.
//!
//! raw_payload MUST be TEXT, never JSONB -- JSONB normalizes whitespace
//! and key order, which would break v1 byte-shape verification.
pub const SCHEMA_VERSION: &str = "1";
pub const DDL_V1: &[&str] = &[
// ----------------------------------------------------------------
// Core three-table schema (mirrors trusty-sqlite).
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS provenance_envelope (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
chain_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
event_type TEXT NOT NULL,
field_name TEXT,
old_value TEXT,
new_value TEXT,
source_url TEXT NOT NULL DEFAULT '',
detected_at TEXT NOT NULL,
received_at TEXT NOT NULL,
extraction_method TEXT NOT NULL DEFAULT 'trusty',
prev_hash TEXT NOT NULL,
raw_payload TEXT,
row_hash TEXT,
anchor_hash TEXT,
sequence_int BIGINT NOT NULL DEFAULT 0
)"#,
r#"CREATE INDEX IF NOT EXISTS idx_pe_entity ON provenance_envelope(entity_id)"#,
r#"CREATE INDEX IF NOT EXISTS idx_pe_chain ON provenance_envelope(chain_id)"#,
r#"CREATE INDEX IF NOT EXISTS idx_pe_entity_chain ON provenance_envelope(entity_id, chain_id)"#,
r#"CREATE INDEX IF NOT EXISTS idx_pe_row_hash ON provenance_envelope(row_hash)"#,
// Partial index accelerates breaks_since on Postgres (SQLite scans).
r#"CREATE INDEX IF NOT EXISTS idx_pe_break ON provenance_envelope(entity_id, detected_at)
WHERE event_type = 'BREAK'"#,
r#"CREATE TABLE IF NOT EXISTS hash_index (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
row_hash TEXT NOT NULL UNIQUE,
chain_id TEXT NOT NULL,
sequence_int BIGINT NOT NULL,
entity_id TEXT NOT NULL,
recorded_at TEXT NOT NULL
)"#,
r#"CREATE INDEX IF NOT EXISTS idx_hi_entity ON hash_index(entity_id)"#,
r#"CREATE INDEX IF NOT EXISTS idx_hi_chain ON hash_index(chain_id)"#,
r#"CREATE TABLE IF NOT EXISTS chain_tips (
entity_id TEXT PRIMARY KEY,
current_tip_hash TEXT,
chain_id TEXT,
tip_state TEXT NOT NULL DEFAULT 'WARM'
CHECK (tip_state IN ('UNKNOWN','COLD','WARM')),
updated_at TEXT NOT NULL
)"#,
r#"CREATE INDEX IF NOT EXISTS idx_ct_chain ON chain_tips(chain_id)"#,
// ----------------------------------------------------------------
// Producer identity (#14, #22) -- OID-anchored. Nullable in the
// sense that rows are optional; table always exists.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS producer_identity (
producer_oid TEXT PRIMARY KEY,
-- Key material in the format `key_format` declares. Named for
-- the raw Ed25519 case it was built for; a COSE credential from
-- a WebAuthn authenticator lives here too, and key_format is
-- what says which. Renaming the column would break every
-- existing installation for a cosmetic gain.
ed25519_pubkey BYTEA NOT NULL,
siphash_key BYTEA NOT NULL,
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
display_name TEXT,
metadata JSONB,
-- How ed25519_pubkey is encoded, and therefore how a signature
-- from it must be checked. A user enrolling through WebAuthn
-- registers a COSE key whose signature covers
-- authenticatorData || SHA-256(clientDataJSON), NOT the message,
-- so it cannot be checked as a bare signature. One column and
-- one verifier made that mistake available.
key_format TEXT NOT NULL DEFAULT 'ed25519-raw'
CHECK (key_format IN ('ed25519-raw','cose'))
)"#,
// Existing installations predate the column; CREATE TABLE IF NOT
// EXISTS does nothing for them, so it needs saying twice.
r#"ALTER TABLE producer_identity
ADD COLUMN IF NOT EXISTS key_format TEXT NOT NULL DEFAULT 'ed25519-raw'"#,
// ----------------------------------------------------------------
// Producer revocation (memo §6 / learn-LCG-ARCH-06) -- forward-
// only revocation per the Cert-Transparency stance: rows signed
// BEFORE revoked_at remain verifiable; rows signed AT or AFTER
// are rejected. The revocation event itself is also written as a
// chain envelope (event_type='REVOCATION') so the act is part of
// the audit chain. revocation_row_hash references that envelope.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS producer_revocation (
producer_oid TEXT PRIMARY KEY,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_pubkey BYTEA NOT NULL,
reason TEXT NOT NULL,
attested_by_pubkey BYTEA NOT NULL,
revocation_row_hash TEXT NOT NULL
)"#,
// ----------------------------------------------------------------
// Checkpoints (#13) -- verification-layer only; no row deletion.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS chain_checkpoint (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
chain_id TEXT NOT NULL,
sequence_int BIGINT NOT NULL,
row_hash TEXT NOT NULL,
checkpoint_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
attested_by TEXT NOT NULL,
attestation_sig BYTEA NOT NULL,
UNIQUE (chain_id, sequence_int)
)"#,
r#"CREATE INDEX IF NOT EXISTS idx_ckpt_chain ON chain_checkpoint(chain_id)"#,
// ----------------------------------------------------------------
// Schema meta (planned migration versioning).
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)"#,
];
/// Upserts the schema_version row. Runs on every open to declare the
/// version this build expects.
pub const DDL_SCHEMA_VERSION_UPSERT: &str = r#"
INSERT INTO schema_meta(key, value) VALUES ('schema_version', $1)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
"#;
/// TimescaleDB hypertable creation (guarded by the `timescale` feature
/// flag at the call site). Uses `if_not_exists` so it is idempotent.
#[cfg(feature = "timescale")]
pub const DDL_TIMESCALE_HYPERTABLE: &str = r#"
SELECT create_hypertable(
'provenance_envelope', 'received_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
)
"#;

View File

@@ -0,0 +1,463 @@
//! `Store` trait implementation for PostgreSQL.
//!
//! Uses the synchronous `postgres` crate (libpq-backed, not tokio-postgres)
//! per the `with_txn` sync-trait decision in docs/trusty-postgres-design.md
//! section 7. Advisory locks serialize per-entity appends; different entities
//! run concurrently.
//!
//! Interior mutability: the `postgres` crate's `Client::query*` methods
//! require `&mut Client`, but the `Store` trait exposes several read-only
//! methods (`last_row`, `iter_chain`, `get_tip`, `get_tip_for_entity`,
//! `kv_cross_check`) that take `&self`. We wrap `Client` in `RefCell` to
//! reconcile. This matches how `rusqlite::Connection` provides `&self`
//! query APIs internally; we just make the bookkeeping explicit.
//! `RefCell<Client>` is `Send` (not `Sync`), which satisfies the
//! `Store: Send` bound.
use std::cell::RefCell;
use chrono::SecondsFormat;
use postgres::{Client, NoTls, Transaction};
use tracing::instrument;
use trusty_store::{
KvCrossCheckReport, LastRow, RowInsert, Store, StoreError, StoreResult, StoredRow, TipRow,
TipUpsert,
};
use crate::error::PostgresError;
use crate::schema::{DDL_SCHEMA_VERSION_UPSERT, DDL_V1, SCHEMA_VERSION};
pub struct TrustyPostgres {
client: RefCell<Client>,
}
impl TrustyPostgres {
/// Open a connection and apply the v1 schema idempotently. Accepts any
/// standard libpq connection string, e.g.
/// `host=localhost user=postgres dbname=trusty password=...`.
pub fn open(conn_str: &str) -> Result<Self, PostgresError> {
let mut client = Client::connect(conn_str, NoTls)?;
apply_schema(&mut client)?;
Ok(Self {
client: RefCell::new(client),
})
}
}
fn apply_schema(client: &mut Client) -> Result<(), PostgresError> {
let mut txn = client.transaction()?;
for stmt in DDL_V1 {
txn.batch_execute(stmt)?;
}
txn.execute(DDL_SCHEMA_VERSION_UPSERT, &[&SCHEMA_VERSION])?;
txn.commit()?;
#[cfg(feature = "timescale")]
{
use crate::schema::DDL_TIMESCALE_HYPERTABLE;
client.batch_execute(DDL_TIMESCALE_HYPERTABLE)?;
}
Ok(())
}
fn now_iso_micros() -> String {
chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true)
}
fn backend<E: std::fmt::Display>(e: E) -> StoreError {
StoreError::Backend(e.to_string())
}
// ------------------------------------------------------------
// Row mapping helpers
// ------------------------------------------------------------
fn row_to_last(row: &postgres::Row) -> LastRow {
LastRow {
row_hash: row.get::<_, String>(0),
sequence: row.get::<_, i64>(1),
}
}
fn row_to_tip(row: &postgres::Row) -> TipRow {
TipRow {
entity_id: row.get::<_, String>(0),
chain_id: row.get::<_, Option<String>>(1).unwrap_or_default(),
current_tip_hash: row.get::<_, Option<String>>(2),
updated_at: row.get::<_, String>(3),
}
}
fn row_to_stored(row: &postgres::Row) -> StoredRow {
StoredRow {
id: row.get::<_, i64>(0),
entity_id: row.get::<_, String>(1),
chain_id: row.get::<_, String>(2),
event_type: row.get::<_, String>(3),
sequence: row.get::<_, i64>(4),
prev_hash: row.get::<_, String>(5),
row_hash: row.get::<_, Option<String>>(6),
anchor_hash: row.get::<_, Option<String>>(7),
raw_payload: row.get::<_, Option<String>>(8),
detected_at: row.get::<_, String>(9),
received_at: row.get::<_, String>(10),
source_url: row.get::<_, String>(11),
extraction_method: row.get::<_, String>(12),
}
}
const SQL_SELECT_LAST_ROW: &str = "
SELECT row_hash, sequence_int FROM provenance_envelope
WHERE entity_id = $1 AND event_type != 'BREAK' AND row_hash IS NOT NULL
ORDER BY id DESC LIMIT 1";
const SQL_SELECT_CHAIN: &str = "
SELECT id, entity_id, chain_id, event_type, sequence_int, prev_hash,
row_hash, anchor_hash, raw_payload, detected_at, received_at,
source_url, extraction_method
FROM provenance_envelope
WHERE entity_id = $1
ORDER BY id ASC";
const SQL_SELECT_TIP_BY_CHAIN: &str = "
SELECT entity_id, chain_id, current_tip_hash, updated_at
FROM chain_tips WHERE chain_id = $1";
const SQL_SELECT_TIP_BY_ENTITY: &str = "
SELECT entity_id, chain_id, current_tip_hash, updated_at
FROM chain_tips WHERE entity_id = $1";
const SQL_INSERT_ROW: &str = "
INSERT INTO provenance_envelope (
chain_id, entity_id, event_type, source_url, detected_at,
received_at, extraction_method, prev_hash, raw_payload,
row_hash, anchor_hash, sequence_int
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING id";
const SQL_INSERT_HASH_INDEX: &str = "
INSERT INTO hash_index (row_hash, chain_id, sequence_int, entity_id, recorded_at)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (row_hash) DO NOTHING";
const SQL_UPSERT_TIP: &str = "
INSERT INTO chain_tips (entity_id, current_tip_hash, chain_id, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (entity_id) DO UPDATE SET
current_tip_hash = EXCLUDED.current_tip_hash,
chain_id = EXCLUDED.chain_id,
updated_at = EXCLUDED.updated_at";
// ------------------------------------------------------------
// Store trait impl
// ------------------------------------------------------------
impl Store for TrustyPostgres {
#[instrument(skip(self), fields(entity_id = %entity_id))]
fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>> {
let mut client = self.client.borrow_mut();
let row = client
.query_opt(SQL_SELECT_LAST_ROW, &[&entity_id])
.map_err(backend)?;
Ok(row.as_ref().map(row_to_last))
}
#[instrument(skip(self, row), fields(entity_id = %row.entity_id, seq = row.sequence))]
fn insert_row(&mut self, row: &RowInsert) -> StoreResult<i64> {
let client = self.client.get_mut();
let id: i64 = client
.query_one(
SQL_INSERT_ROW,
&[
&row.chain_id,
&row.entity_id,
&row.event_type,
&row.source_url,
&row.detected_at,
&row.received_at,
&row.extraction_method,
&row.prev_hash,
&row.raw_payload,
&row.row_hash,
&row.anchor_hash,
&row.sequence,
],
)
.map_err(backend)?
.get(0);
if let Some(rh) = &row.row_hash {
client
.execute(
SQL_INSERT_HASH_INDEX,
&[
rh,
&row.chain_id,
&row.sequence,
&row.entity_id,
&row.received_at,
],
)
.map_err(backend)?;
}
Ok(id)
}
fn iter_chain<'a>(
&'a self,
entity_id: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>> {
let mut client = self.client.borrow_mut();
let rows = client
.query(SQL_SELECT_CHAIN, &[&entity_id])
.map_err(backend)?;
let mapped: Vec<StoreResult<StoredRow>> =
rows.iter().map(|r| Ok(row_to_stored(r))).collect();
Ok(Box::new(mapped.into_iter()))
}
fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>> {
let mut client = self.client.borrow_mut();
let row = client
.query_opt(SQL_SELECT_TIP_BY_CHAIN, &[&chain_id])
.map_err(backend)?;
Ok(row.as_ref().map(row_to_tip))
}
fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>> {
let mut client = self.client.borrow_mut();
let row = client
.query_opt(SQL_SELECT_TIP_BY_ENTITY, &[&entity_id])
.map_err(backend)?;
Ok(row.as_ref().map(row_to_tip))
}
#[instrument(skip(self, tip), fields(entity_id = %tip.entity_id, chain_id = %tip.chain_id))]
fn set_tip(&mut self, tip: &TipUpsert) -> StoreResult<()> {
let client = self.client.get_mut();
client
.execute(
SQL_UPSERT_TIP,
&[
&tip.entity_id,
&tip.current_tip_hash,
&tip.chain_id,
&now_iso_micros(),
],
)
.map_err(backend)?;
Ok(())
}
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
let mut client = self.client.borrow_mut();
// Count sides.
let kv1_total: i64 = client
.query_one("SELECT COUNT(*) FROM hash_index", &[])
.map_err(backend)?
.get(0);
let kv2_total: i64 = client
.query_one("SELECT COUNT(*) FROM chain_tips", &[])
.map_err(backend)?
.get(0);
// Cross-check: every chain_tip's current_tip_hash must exist in hash_index
// under the same (entity_id, chain_id).
let rows = client
.query(
"SELECT ct.entity_id, ct.chain_id, ct.current_tip_hash
FROM chain_tips ct
WHERE ct.current_tip_hash IS NOT NULL",
&[],
)
.map_err(backend)?;
let mut mismatches: Vec<String> = Vec::new();
let mut entities_checked = 0usize;
for row in rows.iter() {
entities_checked += 1;
let entity_id: String = row.get(0);
let chain_id: Option<String> = row.get(1);
let tip_hash: String = row.get(2);
let chain_id = chain_id.unwrap_or_default();
let ok: Option<i64> = client
.query_opt(
"SELECT 1 FROM hash_index
WHERE row_hash = $1 AND entity_id = $2 AND chain_id = $3",
&[&tip_hash, &entity_id, &chain_id],
)
.map_err(backend)?
.map(|r| r.get(0));
if ok.is_none() {
mismatches.push(format!(
"entity={} chain={} tip={} not in hash_index",
entity_id, chain_id, tip_hash
));
}
}
Ok(KvCrossCheckReport {
ok: mismatches.is_empty(),
kv1_total_hashes: kv1_total as usize,
kv2_total_entities: kv2_total as usize,
entities_checked,
mismatches,
})
}
#[instrument(skip(self, f))]
fn with_txn(
&mut self,
f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>,
) -> StoreResult<()> {
let client = self.client.get_mut();
let mut txn = client.transaction().map_err(backend)?;
let result = {
let mut wrapper = TxnStore {
txn: RefCell::new(&mut txn),
};
f(&mut wrapper)
// `wrapper` dropped here, releasing the &mut txn borrow.
};
match result {
Ok(()) => txn.commit().map_err(backend),
Err(e) => {
let _ = txn.rollback();
Err(e)
}
}
}
}
// ------------------------------------------------------------
// Transaction-scoped Store wrapper (for with_txn)
//
// Two lifetimes: 'a is how long this wrapper holds the &mut, 'c is
// the underlying connection's lifetime (parameter on Transaction).
// Conflating them (impl<'t> with Transaction<'t>) causes the borrow
// checker to think the outer Transaction is still borrowed when we
// try to commit() after f() returns. Splitting them lets the
// wrapper's lifetime end at scope close while Transaction keeps
// living on the outer stack frame.
// ------------------------------------------------------------
struct TxnStore<'t, 'c: 't> {
txn: RefCell<&'t mut Transaction<'c>>,
}
impl<'t, 'c: 't> Store for TxnStore<'t, 'c> {
fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>> {
let mut txn = self.txn.borrow_mut();
let row = txn
.query_opt(SQL_SELECT_LAST_ROW, &[&entity_id])
.map_err(backend)?;
Ok(row.as_ref().map(row_to_last))
}
fn insert_row(&mut self, row: &RowInsert) -> StoreResult<i64> {
let mut txn = self.txn.borrow_mut();
let id: i64 = txn
.query_one(
SQL_INSERT_ROW,
&[
&row.chain_id,
&row.entity_id,
&row.event_type,
&row.source_url,
&row.detected_at,
&row.received_at,
&row.extraction_method,
&row.prev_hash,
&row.raw_payload,
&row.row_hash,
&row.anchor_hash,
&row.sequence,
],
)
.map_err(backend)?
.get(0);
if let Some(rh) = &row.row_hash {
txn.execute(
SQL_INSERT_HASH_INDEX,
&[
rh,
&row.chain_id,
&row.sequence,
&row.entity_id,
&row.received_at,
],
)
.map_err(backend)?;
}
Ok(id)
}
fn iter_chain<'a>(
&'a self,
entity_id: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>> {
let mut txn = self.txn.borrow_mut();
let rows = txn
.query(SQL_SELECT_CHAIN, &[&entity_id])
.map_err(backend)?;
let mapped: Vec<StoreResult<StoredRow>> =
rows.iter().map(|r| Ok(row_to_stored(r))).collect();
Ok(Box::new(mapped.into_iter()))
}
fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>> {
let mut txn = self.txn.borrow_mut();
let row = txn
.query_opt(SQL_SELECT_TIP_BY_CHAIN, &[&chain_id])
.map_err(backend)?;
Ok(row.as_ref().map(row_to_tip))
}
fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>> {
let mut txn = self.txn.borrow_mut();
let row = txn
.query_opt(SQL_SELECT_TIP_BY_ENTITY, &[&entity_id])
.map_err(backend)?;
Ok(row.as_ref().map(row_to_tip))
}
fn set_tip(&mut self, tip: &TipUpsert) -> StoreResult<()> {
let mut txn = self.txn.borrow_mut();
txn.execute(
SQL_UPSERT_TIP,
&[
&tip.entity_id,
&tip.current_tip_hash,
&tip.chain_id,
&now_iso_micros(),
],
)
.map_err(backend)?;
Ok(())
}
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
// Inside a txn we skip the cross-check -- the relevant checks happen
// at txn commit time via Postgres constraints. Return an OK stub.
Ok(KvCrossCheckReport {
ok: true,
kv1_total_hashes: 0,
kv2_total_entities: 0,
entities_checked: 0,
mismatches: Vec::new(),
})
}
fn with_txn(
&mut self,
_f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>,
) -> StoreResult<()> {
Err(StoreError::Backend(
"nested with_txn on an already-open transaction is not supported".into(),
))
}
}

File diff suppressed because it is too large Load Diff