706 lines
24 KiB
Rust
706 lines
24 KiB
Rust
//! 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:?}"
|
|
);
|
|
}
|