Trusty: provenance library workspace (initial import)
This commit is contained in:
27
crates/adapters/trusty-postgres/Cargo.toml
Normal file
27
crates/adapters/trusty-postgres/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "trusty-postgres"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "PostgreSQL adapter for Trusty. Three-table schema mirroring trusty-sqlite with optional TimescaleDB feature, OID-anchored producer identity, and CHECKPOINT verification primitive."
|
||||
|
||||
[dependencies]
|
||||
trusty-core = { workspace = true }
|
||||
trusty-store = { workspace = true }
|
||||
postgres = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
trusty-store = { workspace = true, features = ["testing"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
timescale = []
|
||||
39
crates/adapters/trusty-postgres/src/error.rs
Normal file
39
crates/adapters/trusty-postgres/src/error.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
31
crates/adapters/trusty-postgres/src/lib.rs
Normal file
31
crates/adapters/trusty-postgres/src/lib.rs
Normal 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,
|
||||
};
|
||||
144
crates/adapters/trusty-postgres/src/schema.rs
Normal file
144
crates/adapters/trusty-postgres/src/schema.rs
Normal 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
|
||||
)
|
||||
"#;
|
||||
463
crates/adapters/trusty-postgres/src/store.rs
Normal file
463
crates/adapters/trusty-postgres/src/store.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
1520
crates/adapters/trusty-postgres/src/trusty.rs
Normal file
1520
crates/adapters/trusty-postgres/src/trusty.rs
Normal file
File diff suppressed because it is too large
Load Diff
55
crates/adapters/trusty-postgres/tests/conformance.rs
Normal file
55
crates/adapters/trusty-postgres/tests/conformance.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! Runs the shared `Store` conformance suite against a live Postgres.
|
||||
//!
|
||||
//! Gated behind the TRUSTY_PG_URL env var. Set it to a libpq connection
|
||||
//! string that points at a disposable test DB, e.g.:
|
||||
//!
|
||||
//! TRUSTY_PG_URL="host=localhost user=postgres password=postgres dbname=trusty_test"
|
||||
//! cargo test -p trusty-postgres --test conformance -- --nocapture
|
||||
//!
|
||||
//! If TRUSTY_PG_URL is not set, the test prints a skip notice and exits
|
||||
//! clean. This keeps the default `cargo test --workspace` hermetic.
|
||||
|
||||
use trusty_postgres::TrustyPostgres;
|
||||
use trusty_store::testing::run_conformance;
|
||||
|
||||
const ENV_URL: &str = "TRUSTY_PG_URL";
|
||||
|
||||
fn reset_schema(store: &mut TrustyPostgres) {
|
||||
// Drop-and-recreate so the suite starts from a known-empty state.
|
||||
// Done by connecting to the same DB and issuing the drops directly.
|
||||
let url = std::env::var(ENV_URL).expect("TRUSTY_PG_URL must be set");
|
||||
let mut client = postgres::Client::connect(&url, postgres::NoTls).expect("connect");
|
||||
client
|
||||
.batch_execute(
|
||||
"DROP TABLE IF EXISTS chain_checkpoint CASCADE;
|
||||
DROP TABLE IF EXISTS producer_identity CASCADE;
|
||||
DROP TABLE IF EXISTS chain_tips CASCADE;
|
||||
DROP TABLE IF EXISTS hash_index CASCADE;
|
||||
DROP TABLE IF EXISTS provenance_envelope CASCADE;
|
||||
DROP TABLE IF EXISTS schema_meta CASCADE;",
|
||||
)
|
||||
.expect("drop tables");
|
||||
let _ = store; // store gets recreated below
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_passes_store_conformance() {
|
||||
let url = match std::env::var(ENV_URL) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"SKIP: set {} to run the conformance suite against a live Postgres",
|
||||
ENV_URL
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial open to ensure the tables exist.
|
||||
let mut store = TrustyPostgres::open(&url).expect("open");
|
||||
reset_schema(&mut store);
|
||||
// Re-open so apply_schema runs against a clean DB.
|
||||
let mut store = TrustyPostgres::open(&url).expect("re-open");
|
||||
|
||||
run_conformance(&mut store);
|
||||
}
|
||||
705
crates/adapters/trusty-postgres/tests/integration.rs
Normal file
705
crates/adapters/trusty-postgres/tests/integration.rs
Normal file
@@ -0,0 +1,705 @@
|
||||
//! TRUSTY_PG_URL-gated integration tests for the higher-level
|
||||
//! `Trusty` surface on Postgres. Pays down the test debt accumulated
|
||||
//! across S16 cycles 4-7 (append_signed, append_batch + advisory
|
||||
//! lock, anchor, anchors_since, export_bundle, checkpoint,
|
||||
//! latest_checkpoint, verify_since_checkpoint).
|
||||
//!
|
||||
//! Setup mirrors `tests/conformance.rs`: when TRUSTY_PG_URL is unset
|
||||
//! the tests print SKIP and exit clean so `cargo test --workspace`
|
||||
//! stays hermetic. Set it to a libpq connection string pointing at a
|
||||
//! disposable test DB to actually run them.
|
||||
|
||||
use serde_json::json;
|
||||
use trusty_core::{Ed25519Signer, ProofBundle, SecretKey, Signer};
|
||||
use trusty_postgres::{RowSpec, Trusty};
|
||||
|
||||
const ENV_URL: &str = "TRUSTY_PG_URL";
|
||||
|
||||
fn maybe_url() -> Option<String> {
|
||||
std::env::var(ENV_URL).ok()
|
||||
}
|
||||
|
||||
fn drop_tables(url: &str) {
|
||||
let mut client = postgres::Client::connect(url, postgres::NoTls).expect("connect");
|
||||
client
|
||||
.batch_execute(
|
||||
"DROP TABLE IF EXISTS chain_checkpoint CASCADE;
|
||||
DROP TABLE IF EXISTS producer_revocation CASCADE;
|
||||
DROP TABLE IF EXISTS producer_identity CASCADE;
|
||||
DROP TABLE IF EXISTS chain_tips CASCADE;
|
||||
DROP TABLE IF EXISTS hash_index CASCADE;
|
||||
DROP TABLE IF EXISTS provenance_envelope CASCADE;
|
||||
DROP TABLE IF EXISTS schema_meta CASCADE;",
|
||||
)
|
||||
.expect("drop tables");
|
||||
}
|
||||
|
||||
fn open_clean(url: &str) -> Trusty {
|
||||
drop_tables(url);
|
||||
let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key");
|
||||
Trusty::open(url, key).expect("open Trusty")
|
||||
}
|
||||
|
||||
fn signer() -> Ed25519Signer {
|
||||
let seed = [9u8; 32];
|
||||
Ed25519Signer::from_secret_bytes(&seed).expect("signer")
|
||||
}
|
||||
|
||||
fn skip_if_no_url() -> Option<String> {
|
||||
let url = maybe_url();
|
||||
if url.is_none() {
|
||||
eprintln!(
|
||||
"SKIP: set {} to a Postgres connection string to run integration tests",
|
||||
ENV_URL
|
||||
);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_signed_writes_v2_payload_and_verifies() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let s = signer();
|
||||
|
||||
let r = t
|
||||
.append_signed("dev1", &json!({"v": 1}), "src", &s)
|
||||
.expect("append_signed");
|
||||
assert!(!r.chain_break);
|
||||
assert_eq!(r.sequence, 0);
|
||||
assert!(r.is_genesis);
|
||||
|
||||
// Payload should be v2 with a populated signature object. Read
|
||||
// raw_payload back from the row directly to confirm.
|
||||
let mut client = postgres::Client::connect(&url, postgres::NoTls).expect("connect");
|
||||
let row = client
|
||||
.query_one(
|
||||
"SELECT raw_payload FROM provenance_envelope
|
||||
WHERE entity_id = $1 ORDER BY id ASC LIMIT 1",
|
||||
&[&"dev1"],
|
||||
)
|
||||
.expect("read row");
|
||||
let raw: String = row.get(0);
|
||||
assert!(raw.contains("\"payload_version\":2"), "raw = {}", raw);
|
||||
assert!(raw.contains("\"alg\":\"ed25519\""), "raw = {}", raw);
|
||||
|
||||
let report = t.verify("dev1").expect("verify");
|
||||
assert!(report.rows_valid, "{:?}", report.errors);
|
||||
assert_eq!(report.row_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_batch_is_all_or_nothing_and_advisory_lock_present() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
|
||||
let d0 = json!({"v": 0});
|
||||
let d1 = json!({"v": 1});
|
||||
let d2 = json!({"v": 2});
|
||||
let specs = vec![
|
||||
RowSpec {
|
||||
entity_id: "fleet-a",
|
||||
data: &d0,
|
||||
source_url: "src",
|
||||
},
|
||||
RowSpec {
|
||||
entity_id: "fleet-a",
|
||||
data: &d1,
|
||||
source_url: "src",
|
||||
},
|
||||
RowSpec {
|
||||
entity_id: "fleet-b",
|
||||
data: &d2,
|
||||
source_url: "src",
|
||||
},
|
||||
];
|
||||
let results = t.append_batch(&specs).expect("append_batch");
|
||||
assert_eq!(results.len(), 3);
|
||||
assert!(t.verify("fleet-a").expect("verify a").rows_valid);
|
||||
assert!(t.verify("fleet-b").expect("verify b").rows_valid);
|
||||
|
||||
// Empty batch is a no-op.
|
||||
let empty: Vec<RowSpec> = Vec::new();
|
||||
let none = t.append_batch(&empty).expect("empty batch");
|
||||
assert!(none.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_and_anchors_since_round_trip() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
|
||||
for i in 0..3 {
|
||||
t.append("dev2", &json!({"i": i}), "src").expect("append");
|
||||
}
|
||||
|
||||
let anchor = t.anchor("dev2").expect("anchor").expect("some");
|
||||
assert_eq!(anchor.entity_id, "dev2");
|
||||
assert!(anchor.chain_id.starts_with("chain_dev2_"));
|
||||
assert!(anchor.tip_hash.is_some());
|
||||
|
||||
let entries = t.anchors_since("dev2", None).expect("anchors_since");
|
||||
// Three appends -> three rows -> three anchor commitments.
|
||||
assert_eq!(entries.len(), 3);
|
||||
// Each entry has a distinct sequence and non-empty hash.
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
assert_eq!(e.sequence, i as i64);
|
||||
assert!(!e.anchor_hash.is_empty());
|
||||
}
|
||||
|
||||
// since-filter uses strict-greater-than on detected_at; a value
|
||||
// beyond all rows yields the empty list.
|
||||
let none = t
|
||||
.anchors_since("dev2", Some("9999-01-01T00:00:00Z"))
|
||||
.expect("anchors_since since=future");
|
||||
assert!(none.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_bundle_round_trips_through_proof_bundle_verify() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key");
|
||||
|
||||
for i in 0..4 {
|
||||
t.append("dev3", &json!({"i": i}), "src").expect("append");
|
||||
}
|
||||
|
||||
let bundle: ProofBundle = t.export_bundle("dev3", None, None).expect("export_bundle");
|
||||
let report = bundle.verify(&key);
|
||||
assert!(
|
||||
report.rows_valid,
|
||||
"bundle verify failed: {:?}",
|
||||
report.errors
|
||||
);
|
||||
assert_eq!(report.row_count, 4);
|
||||
|
||||
// Sequence-bounded slice still verifies.
|
||||
let slice = t
|
||||
.export_bundle("dev3", Some(1), Some(2))
|
||||
.expect("export_bundle slice");
|
||||
let slice_report = slice.verify(&key);
|
||||
assert!(slice_report.rows_valid, "{:?}", slice_report.errors);
|
||||
assert_eq!(slice_report.row_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_writes_then_latest_returns_it() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let s = signer();
|
||||
|
||||
for i in 0..3 {
|
||||
t.append("dev4", &json!({"i": i}), "src").expect("append");
|
||||
}
|
||||
|
||||
let ckpt = t.checkpoint("dev4", "oid:1.2.3", &s).expect("checkpoint");
|
||||
assert_eq!(ckpt.attested_by, "oid:1.2.3");
|
||||
assert_eq!(ckpt.sequence_int, 2);
|
||||
assert!(!ckpt.row_hash.is_empty());
|
||||
assert!(!ckpt.attestation_sig.is_empty());
|
||||
|
||||
let latest = t
|
||||
.latest_checkpoint(&ckpt.chain_id)
|
||||
.expect("latest_checkpoint")
|
||||
.expect("some");
|
||||
assert_eq!(latest.id, ckpt.id);
|
||||
assert_eq!(latest.sequence_int, ckpt.sequence_int);
|
||||
assert_eq!(latest.row_hash, ckpt.row_hash);
|
||||
assert_eq!(latest.attestation_sig, ckpt.attestation_sig);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_since_checkpoint_falls_back_when_no_checkpoint() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
|
||||
for i in 0..2 {
|
||||
t.append("dev5", &json!({"i": i}), "src").expect("append");
|
||||
}
|
||||
|
||||
// No checkpoint written -> verify_since_checkpoint must return
|
||||
// the same shape as a full verify.
|
||||
let full = t.verify("dev5").expect("verify");
|
||||
let pruned = t.verify_since_checkpoint("dev5").expect("vsc");
|
||||
assert_eq!(full.rows_valid, pruned.rows_valid);
|
||||
assert_eq!(full.row_count, pruned.row_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_since_checkpoint_after_checkpoint_still_passes() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let s = signer();
|
||||
|
||||
// S16 cycle 9: verify_since_checkpoint authenticates the
|
||||
// checkpoint against producer_identity before trusting the
|
||||
// window-prune. Register the attestor first.
|
||||
t.register_producer(
|
||||
"oid:test",
|
||||
&s.public_key_bytes(),
|
||||
b"siphash_key_bytes_16",
|
||||
Some("test attestor"),
|
||||
)
|
||||
.expect("register producer");
|
||||
|
||||
for i in 0..3 {
|
||||
t.append("dev6", &json!({"i": i}), "src").expect("append");
|
||||
}
|
||||
t.checkpoint("dev6", "oid:test", &s).expect("checkpoint");
|
||||
// Append more rows after the checkpoint.
|
||||
for i in 3..5 {
|
||||
t.append("dev6", &json!({"i": i}), "src").expect("append");
|
||||
}
|
||||
|
||||
let report = t.verify_since_checkpoint("dev6").expect("vsc");
|
||||
assert!(report.rows_valid, "{:?}", report.errors);
|
||||
// Verify covers checkpoint.sequence (=2) forward -> rows 2,3,4
|
||||
// -> 3 rows.
|
||||
assert_eq!(report.row_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn producer_identity_register_and_lookup_round_trip() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let s = signer();
|
||||
|
||||
let pk = s.public_key_bytes();
|
||||
t.register_producer("oid:1.2.3", &pk, b"sip-key-1", Some("agent-a"))
|
||||
.expect("register");
|
||||
|
||||
let got = t
|
||||
.lookup_producer("oid:1.2.3")
|
||||
.expect("lookup")
|
||||
.expect("some");
|
||||
assert_eq!(got.producer_oid, "oid:1.2.3");
|
||||
assert_eq!(got.ed25519_pubkey, pk);
|
||||
assert_eq!(got.siphash_key, b"sip-key-1");
|
||||
assert_eq!(got.display_name.as_deref(), Some("agent-a"));
|
||||
|
||||
// Re-register same OID is an UPSERT.
|
||||
let s2 = trusty_core::Ed25519Signer::from_secret_bytes(&[3u8; 32]).expect("s2");
|
||||
let pk2 = s2.public_key_bytes();
|
||||
t.register_producer("oid:1.2.3", &pk2, b"sip-key-2", None)
|
||||
.expect("re-register");
|
||||
let updated = t
|
||||
.lookup_producer("oid:1.2.3")
|
||||
.expect("lookup2")
|
||||
.expect("some");
|
||||
assert_eq!(updated.ed25519_pubkey, pk2);
|
||||
assert_eq!(updated.siphash_key, b"sip-key-2");
|
||||
assert!(updated.display_name.is_none());
|
||||
|
||||
// Unknown OID yields None.
|
||||
assert!(t.lookup_producer("oid:nope").expect("lookup3").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_since_checkpoint_rejects_unknown_attestor() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let s = signer();
|
||||
|
||||
// Append rows + write checkpoint WITHOUT registering the
|
||||
// attestor first. The checkpoint row is in the table but
|
||||
// attested_by has no producer_identity row.
|
||||
for i in 0..2 {
|
||||
t.append("dev7", &json!({"i": i}), "src").expect("append");
|
||||
}
|
||||
t.checkpoint("dev7", "oid:ghost", &s)
|
||||
.expect("checkpoint write succeeds (lookup happens at verify)");
|
||||
|
||||
let report = t.verify_since_checkpoint("dev7").expect("vsc");
|
||||
assert!(
|
||||
!report.rows_valid,
|
||||
"expected failure for unregistered attestor"
|
||||
);
|
||||
assert_eq!(report.errors.len(), 1);
|
||||
let detail = match &report.errors[0].kind {
|
||||
trusty_core::VerifyErrorType::SignatureInvalid { detail } => detail.clone(),
|
||||
other => panic!("expected SignatureInvalid, got {:?}", other),
|
||||
};
|
||||
assert!(
|
||||
detail.contains("not in producer_identity"),
|
||||
"detail = {}",
|
||||
detail
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_with_producer_accepts_signed_rows_by_registered_producer() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let s = signer();
|
||||
|
||||
t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None)
|
||||
.expect("register");
|
||||
|
||||
// Three signed rows by the registered producer.
|
||||
for i in 0..3 {
|
||||
t.append_signed("dev-rsa1", &json!({"i": i}), "src", &s)
|
||||
.expect("append_signed");
|
||||
}
|
||||
|
||||
let report = t
|
||||
.verify_with_producer("dev-rsa1", "oid:test")
|
||||
.expect("verify_with_producer");
|
||||
assert!(report.rows_valid, "{:?}", report.errors);
|
||||
assert_eq!(report.row_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_with_producer_rejects_row_signed_by_unauthorized_pubkey() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let registered = signer();
|
||||
let imposter = trusty_core::Ed25519Signer::from_secret_bytes(&[77u8; 32]).expect("imp");
|
||||
|
||||
t.register_producer("oid:test", ®istered.public_key_bytes(), b"sip-key", None)
|
||||
.expect("register");
|
||||
|
||||
// Row 0 signed by the registered producer (legitimate).
|
||||
t.append_signed("dev-rsa2", &json!({"i": 0}), "src", ®istered)
|
||||
.expect("append_signed legit");
|
||||
// Row 1 signed by the imposter -- chain math still verifies, but
|
||||
// verify_with_producer must flag it because the pk doesn't match
|
||||
// producer_identity for oid:test.
|
||||
t.append_signed("dev-rsa2", &json!({"i": 1}), "src", &imposter)
|
||||
.expect("append_signed imposter");
|
||||
|
||||
let report = t
|
||||
.verify_with_producer("dev-rsa2", "oid:test")
|
||||
.expect("verify_with_producer");
|
||||
assert!(!report.rows_valid, "expected rejection of unauthorized pk");
|
||||
let unauthorized = report
|
||||
.errors
|
||||
.iter()
|
||||
.find(|e| match &e.kind {
|
||||
trusty_core::VerifyErrorType::SignatureInvalid { detail } => {
|
||||
detail.contains("unauthorized pubkey")
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.expect("expected an 'unauthorized pubkey' SignatureInvalid");
|
||||
assert_eq!(unauthorized.sequence, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_with_producer_rejects_row_after_revocation() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let s = signer();
|
||||
|
||||
t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None)
|
||||
.expect("register");
|
||||
|
||||
// Revoke FIRST, then append. The row's detected_at will be
|
||||
// strictly greater than revoked_at, so it must reject.
|
||||
t.revoke_producer("oid:test", "compromise", &s)
|
||||
.expect("revoke");
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
t.append_signed("dev-rsa3", &json!({"i": 0}), "src", &s)
|
||||
.expect("append_signed after revoke");
|
||||
|
||||
let report = t
|
||||
.verify_with_producer("dev-rsa3", "oid:test")
|
||||
.expect("verify_with_producer");
|
||||
assert!(!report.rows_valid);
|
||||
let after_rev = report.errors.iter().find(|e| match &e.kind {
|
||||
trusty_core::VerifyErrorType::SignatureInvalid { detail } => {
|
||||
detail.contains("AT or AFTER revocation")
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
assert!(
|
||||
after_rev.is_some(),
|
||||
"expected post-revocation rejection; errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_with_producer_accepts_rows_signed_before_revocation() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let s = signer();
|
||||
|
||||
t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None)
|
||||
.expect("register");
|
||||
|
||||
// Append FIRST, then revoke. CT stance: past rows stay valid.
|
||||
for i in 0..2 {
|
||||
t.append_signed("dev-rsa4", &json!({"i": i}), "src", &s)
|
||||
.expect("append_signed");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
t.revoke_producer("oid:test", "later compromise", &s)
|
||||
.expect("revoke");
|
||||
|
||||
let report = t
|
||||
.verify_with_producer("dev-rsa4", "oid:test")
|
||||
.expect("verify_with_producer");
|
||||
assert!(
|
||||
report.rows_valid,
|
||||
"expected pre-revocation rows to remain valid; errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
assert_eq!(report.row_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_producer_round_trip_and_lookup() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let s = signer();
|
||||
|
||||
t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None)
|
||||
.expect("register");
|
||||
|
||||
// Not revoked yet.
|
||||
assert!(t.lookup_revocation("oid:test").expect("lookup").is_none());
|
||||
|
||||
// Revoke. Returns RevocationRecord; producer_revocation row exists.
|
||||
let rec = t
|
||||
.revoke_producer("oid:test", "key compromise", &s)
|
||||
.expect("revoke");
|
||||
assert_eq!(rec.producer_oid, "oid:test");
|
||||
assert_eq!(rec.reason, "key compromise");
|
||||
assert_eq!(rec.revoked_pubkey, s.public_key_bytes());
|
||||
assert_eq!(rec.attested_by_pubkey, s.public_key_bytes());
|
||||
assert!(!rec.revocation_row_hash.is_empty());
|
||||
|
||||
// Lookup returns the same record.
|
||||
let got = t
|
||||
.lookup_revocation("oid:test")
|
||||
.expect("lookup")
|
||||
.expect("some");
|
||||
assert_eq!(got.producer_oid, rec.producer_oid);
|
||||
assert_eq!(got.revocation_row_hash, rec.revocation_row_hash);
|
||||
|
||||
// The REVOCATION envelope is on the per-user 'revocations' chain
|
||||
// and verifies via the standard verify path.
|
||||
let report = t.verify("revocations").expect("verify revocations chain");
|
||||
assert!(report.rows_valid, "{:?}", report.errors);
|
||||
assert_eq!(report.row_count, 1);
|
||||
|
||||
// Re-revoking the same OID UPSERTs (single row per OID).
|
||||
let rec2 = t
|
||||
.revoke_producer("oid:test", "second reason", &s)
|
||||
.expect("re-revoke");
|
||||
assert_eq!(rec2.reason, "second reason");
|
||||
// Two REVOCATION envelopes on chain (append-only); one
|
||||
// producer_revocation index row (UPSERT).
|
||||
let report2 = t
|
||||
.verify("revocations")
|
||||
.expect("verify revocations chain v2");
|
||||
assert!(report2.rows_valid);
|
||||
assert_eq!(report2.row_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_producer_unknown_oid_errors() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let s = signer();
|
||||
let err = t.revoke_producer("oid:ghost", "no such producer", &s);
|
||||
assert!(err.is_err(), "expected NotFound for unregistered OID");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_since_checkpoint_rejects_revoked_attestor_after_revocation() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let s = signer();
|
||||
|
||||
t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None)
|
||||
.expect("register");
|
||||
|
||||
for i in 0..3 {
|
||||
t.append("dev-rev1", &json!({"i": i}), "src")
|
||||
.expect("append");
|
||||
}
|
||||
|
||||
// Revoke FIRST, then write checkpoint. Per CT stance, this
|
||||
// checkpoint's checkpoint_at >= revoked_at, so it must reject.
|
||||
t.revoke_producer("oid:test", "compromise", &s)
|
||||
.expect("revoke");
|
||||
t.checkpoint("dev-rev1", "oid:test", &s)
|
||||
.expect("checkpoint write succeeds (verify happens later)");
|
||||
|
||||
let report = t.verify_since_checkpoint("dev-rev1").expect("vsc");
|
||||
assert!(!report.rows_valid, "expected rejection of revoked attestor");
|
||||
let detail = match &report.errors[0].kind {
|
||||
trusty_core::VerifyErrorType::SignatureInvalid { detail } => detail.clone(),
|
||||
other => panic!("expected SignatureInvalid, got {:?}", other),
|
||||
};
|
||||
assert!(detail.contains("revoked at"), "detail = {}", detail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_since_checkpoint_accepts_attestor_revoked_after_checkpoint() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let s = signer();
|
||||
|
||||
t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None)
|
||||
.expect("register");
|
||||
|
||||
for i in 0..3 {
|
||||
t.append("dev-rev2", &json!({"i": i}), "src")
|
||||
.expect("append");
|
||||
}
|
||||
|
||||
// Checkpoint FIRST, then revoke. Per CT stance, this checkpoint's
|
||||
// checkpoint_at < revoked_at, so it must still accept.
|
||||
t.checkpoint("dev-rev2", "oid:test", &s)
|
||||
.expect("checkpoint");
|
||||
// Sleep briefly to ensure a strictly-greater revoked_at.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
t.revoke_producer("oid:test", "later compromise", &s)
|
||||
.expect("revoke");
|
||||
|
||||
let report = t.verify_since_checkpoint("dev-rev2").expect("vsc");
|
||||
assert!(
|
||||
report.rows_valid,
|
||||
"expected past checkpoint to remain valid; errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_since_checkpoint_rejects_tampered_attestation_sig() {
|
||||
let Some(url) = skip_if_no_url() else { return };
|
||||
let mut t = open_clean(&url);
|
||||
let attestor = signer();
|
||||
let imposter = trusty_core::Ed25519Signer::from_secret_bytes(&[42u8; 32]).expect("imp");
|
||||
|
||||
// Register the *attestor*'s pubkey under the OID, but write the
|
||||
// checkpoint signed by the *imposter*. Sig verification must
|
||||
// fail.
|
||||
t.register_producer(
|
||||
"oid:test",
|
||||
&attestor.public_key_bytes(),
|
||||
b"siphash-key-XYZ",
|
||||
None,
|
||||
)
|
||||
.expect("register");
|
||||
|
||||
for i in 0..2 {
|
||||
t.append("dev8", &json!({"i": i}), "src").expect("append");
|
||||
}
|
||||
t.checkpoint("dev8", "oid:test", &imposter)
|
||||
.expect("checkpoint with imposter signer");
|
||||
|
||||
let report = t.verify_since_checkpoint("dev8").expect("vsc");
|
||||
assert!(!report.rows_valid, "expected failure for tampered sig");
|
||||
let detail = match &report.errors[0].kind {
|
||||
trusty_core::VerifyErrorType::SignatureInvalid { detail } => detail.clone(),
|
||||
other => panic!("expected SignatureInvalid, got {:?}", other),
|
||||
};
|
||||
assert!(
|
||||
detail.contains("attestation_sig invalid"),
|
||||
"detail = {}",
|
||||
detail
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Per-entity lock on the single-append path.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// Two writers, two connections, one entity, at the same time.
|
||||
///
|
||||
/// Appending is read-tip, compute, insert, and those three steps are not
|
||||
/// atomic. Without a per-entity lock both writers read the same tip and
|
||||
/// both build on it, producing two rows that claim the same predecessor
|
||||
/// -- a fork in a structure whose whole promise is that there is one
|
||||
/// line.
|
||||
///
|
||||
/// The batch path has held a lock since it was written. The single
|
||||
/// path never did; the module header said "add before any multi-writer
|
||||
/// deployment", and nothing was enforcing it.
|
||||
#[test]
|
||||
fn concurrent_appends_to_one_entity_do_not_fork_the_chain() {
|
||||
let Some(url) = maybe_url() else {
|
||||
eprintln!("SKIP: {ENV_URL} unset");
|
||||
return;
|
||||
};
|
||||
let entity = "concurrency-probe";
|
||||
|
||||
// A unique entity per run, so a previous run cannot make this pass
|
||||
// and no cleanup accessor is needed on the public surface.
|
||||
let entity = &format!(
|
||||
"{entity}-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
|
||||
// Apply the schema ONCE. Four threads each running
|
||||
// CREATE TABLE IF NOT EXISTS collide in the catalog with "tuple
|
||||
// concurrently updated" -- a real property of concurrent DDL, and
|
||||
// not what this test is about.
|
||||
{
|
||||
let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key");
|
||||
Trusty::open(&url, key).expect("apply schema");
|
||||
}
|
||||
|
||||
const WRITERS: usize = 4;
|
||||
const EACH: usize = 6;
|
||||
|
||||
let handles: Vec<_> = (0..WRITERS)
|
||||
.map(|w| {
|
||||
let url = url.clone();
|
||||
let entity = entity.clone();
|
||||
std::thread::spawn(move || {
|
||||
let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key");
|
||||
let mut t = Trusty::open(&url, key).expect("open");
|
||||
for i in 0..EACH {
|
||||
t.append(&entity, &json!({ "writer": w, "i": i }), "test://")
|
||||
.expect("append");
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for h in handles {
|
||||
h.join().expect("writer panicked");
|
||||
}
|
||||
|
||||
let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key");
|
||||
let mut t = Trusty::open(&url, key).expect("open");
|
||||
let bundle = t.export_bundle(entity, None, None).expect("export");
|
||||
let rows: Vec<_> = bundle
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|r| r.event_type != "BREAK")
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
WRITERS * EACH,
|
||||
"every append must land exactly once"
|
||||
);
|
||||
|
||||
// The real assertion: no two rows may claim the same predecessor.
|
||||
// That is what a lost update looks like from the outside.
|
||||
let mut prevs: Vec<&str> = rows.iter().map(|r| r.prev_hash.as_str()).collect();
|
||||
prevs.sort_unstable();
|
||||
let before = prevs.len();
|
||||
prevs.dedup();
|
||||
assert_eq!(
|
||||
prevs.len(),
|
||||
before,
|
||||
"two rows claim the same prev_hash, so two writers read the same tip \
|
||||
and both built on it -- the chain forked, which is exactly what the \
|
||||
per-entity lock exists to prevent"
|
||||
);
|
||||
|
||||
let report = t.verify(entity).expect("verify");
|
||||
assert!(
|
||||
report.rows_valid,
|
||||
"and the chain must still verify end to end: {report:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user