190 lines
7.1 KiB
Rust
190 lines
7.1 KiB
Rust
|
|
//! Cross-project witness fetch — Hard constraint of ADR-028.
|
||
|
|
//!
|
||
|
|
//! Sets up two foreign ontologies (X and Y) on disk, registers them in a
|
||
|
|
//! local ontology registry, then demonstrates that
|
||
|
|
//! [`fetch_cell_witness`] returns ONLY the witness + state root for the
|
||
|
|
//! requested cell. The caller never gains access to the foreign
|
||
|
|
//! ontology's full state — the `CellWitnessResponse` type structurally
|
||
|
|
//! precludes it.
|
||
|
|
//!
|
||
|
|
//! The test also verifies the witness round-trip: the returned witness
|
||
|
|
//! verifies under the returned root, but does NOT verify under a
|
||
|
|
//! different ontology's root (negative case).
|
||
|
|
|
||
|
|
use ed25519_dalek::SigningKey;
|
||
|
|
use ontoref_commit::BinaryMerkle;
|
||
|
|
use ontoref_oplog::OpLog;
|
||
|
|
use ontoref_types::{
|
||
|
|
operation::{OpBody, OpPayload, Operation},
|
||
|
|
AttrId, EntityId, Hlc, PublicKey, Value,
|
||
|
|
};
|
||
|
|
|
||
|
|
use ontoref_ontology_content::{
|
||
|
|
compute_ontology_id, fetch_cell_witness, verify_cell, CellQuery, Error, OntologyRegistry,
|
||
|
|
OntologyType, RegistryEntry,
|
||
|
|
};
|
||
|
|
|
||
|
|
fn signing_key(seed: u8) -> SigningKey {
|
||
|
|
SigningKey::from_bytes(&[seed; 32])
|
||
|
|
}
|
||
|
|
|
||
|
|
fn assert_op(entity: &str, attr: &str, value: Value, ts_logical: u64, seed: u8) -> Operation {
|
||
|
|
let key = signing_key(seed);
|
||
|
|
let body = OpBody {
|
||
|
|
actor: PublicKey::from_signing_key(&key),
|
||
|
|
parents: vec![],
|
||
|
|
payload: OpPayload::EntityAssert {
|
||
|
|
attrs: vec![(AttrId::new(attr), value)],
|
||
|
|
entity: EntityId::new(entity),
|
||
|
|
valid_from: None,
|
||
|
|
},
|
||
|
|
timestamp: Hlc::new(ts_logical, 0),
|
||
|
|
};
|
||
|
|
Operation::sign(body, &key)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Append `ops` to a fresh oplog at `path` and return the resulting
|
||
|
|
/// [`OntologyId`] computed from the oplog's heads.
|
||
|
|
fn build_ontology(path: &std::path::Path, ops: &[Operation]) -> ontoref_ontology_content::OntologyId {
|
||
|
|
let log = OpLog::open(path).expect("open oplog");
|
||
|
|
for op in ops {
|
||
|
|
log.append(op).expect("append op");
|
||
|
|
}
|
||
|
|
let heads = log.heads().expect("heads ok");
|
||
|
|
compute_ontology_id(&heads)
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn fetch_cell_witness_returns_only_witness_and_root() {
|
||
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
||
|
|
|
||
|
|
// ── Foreign ontology X — carries an "x-entity" cell. ────────────────────
|
||
|
|
let x_oplog = tmp.path().join("x.oplog");
|
||
|
|
let x_ops = vec![
|
||
|
|
assert_op(
|
||
|
|
"x-entity",
|
||
|
|
"x-attr",
|
||
|
|
Value::Str("x-value".into()),
|
||
|
|
1,
|
||
|
|
0xAA,
|
||
|
|
),
|
||
|
|
assert_op(
|
||
|
|
"x-entity-other",
|
||
|
|
"x-attr-other",
|
||
|
|
Value::Int(42),
|
||
|
|
2,
|
||
|
|
0xAA,
|
||
|
|
),
|
||
|
|
];
|
||
|
|
let x_id = build_ontology(&x_oplog, &x_ops);
|
||
|
|
|
||
|
|
// ── Foreign ontology Y — carries a "y-entity" cell. ─────────────────────
|
||
|
|
let y_oplog = tmp.path().join("y.oplog");
|
||
|
|
let y_ops = vec![assert_op(
|
||
|
|
"y-entity",
|
||
|
|
"y-attr",
|
||
|
|
Value::Str("y-value".into()),
|
||
|
|
1,
|
||
|
|
0xBB,
|
||
|
|
)];
|
||
|
|
let y_id = build_ontology(&y_oplog, &y_ops);
|
||
|
|
assert_ne!(x_id, y_id, "different ontologies must have different ids");
|
||
|
|
|
||
|
|
// ── Project A's local registry — knows X and Y. ─────────────────────────
|
||
|
|
let mut registry = OntologyRegistry::empty();
|
||
|
|
registry.register(RegistryEntry {
|
||
|
|
ontology_id: x_id,
|
||
|
|
oplog_path: x_oplog.clone(),
|
||
|
|
classification: OntologyType::Type2aFederable,
|
||
|
|
verified_state_root_hex: None,
|
||
|
|
});
|
||
|
|
registry.register(RegistryEntry {
|
||
|
|
ontology_id: y_id,
|
||
|
|
oplog_path: y_oplog,
|
||
|
|
classification: OntologyType::Type3ProjectSpecific,
|
||
|
|
verified_state_root_hex: None,
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── Project A fetches a witness for X's x-entity/x-attr cell. ──────────
|
||
|
|
let query = CellQuery::new("x-entity", "x-attr");
|
||
|
|
let response = fetch_cell_witness(®istry, &x_id, &query).expect("fetch ok");
|
||
|
|
|
||
|
|
// Witness verifies against the returned state root.
|
||
|
|
assert!(
|
||
|
|
verify_cell(&response.witness, &response.state_root),
|
||
|
|
"witness must verify against its own state root"
|
||
|
|
);
|
||
|
|
|
||
|
|
// The witness contains the canonical encoding of "x-value".
|
||
|
|
let expected = ontoref_types::canonical::encode(&Value::Str("x-value".into()))
|
||
|
|
.expect("encode value");
|
||
|
|
assert_eq!(response.witness.value, expected);
|
||
|
|
|
||
|
|
// ── Negative: the witness must NOT verify under Y's state root. ────────
|
||
|
|
// Rebuild Y's commit layer and compute its root WITHOUT going
|
||
|
|
// through fetch_ontology — this proves the negative case is real.
|
||
|
|
let y_log = OpLog::open(x_oplog.with_file_name("y.oplog")).expect("open y oplog");
|
||
|
|
let y_state_ops = y_log.all_ops_in_hlc_order().expect("y ops");
|
||
|
|
let mut y_commit = BinaryMerkle::new();
|
||
|
|
for op in &y_state_ops {
|
||
|
|
y_commit.apply(op);
|
||
|
|
}
|
||
|
|
let y_root = y_commit.root();
|
||
|
|
assert_ne!(response.state_root, y_root, "X and Y must have different roots");
|
||
|
|
assert!(
|
||
|
|
!verify_cell(&response.witness, &y_root),
|
||
|
|
"witness from X must not verify under Y's state root"
|
||
|
|
);
|
||
|
|
|
||
|
|
// ── Hard ADR-028 surface check: the response type is narrow. ───────────
|
||
|
|
// CellWitnessResponse carries exactly { witness, state_root } — no
|
||
|
|
// Vec<Operation>, no full commit layer, no cell map. The size of the
|
||
|
|
// struct is bounded by the witness's Merkle path depth.
|
||
|
|
let response_size = std::mem::size_of_val(&response);
|
||
|
|
let full_oplog_size_bound = x_ops.len() * 1024; // pessimistic per-op size
|
||
|
|
assert!(
|
||
|
|
response_size < full_oplog_size_bound,
|
||
|
|
"response must be bounded — full oplog leakage would exceed {full_oplog_size_bound} bytes; got {response_size}"
|
||
|
|
);
|
||
|
|
|
||
|
|
// Fetching a cell that does not exist in X returns CellMissing,
|
||
|
|
// NOT a leak of Y's content.
|
||
|
|
let unknown_query = CellQuery::new("y-entity", "y-attr");
|
||
|
|
let err = fetch_cell_witness(®istry, &x_id, &unknown_query)
|
||
|
|
.expect_err("y-entity must not exist in X");
|
||
|
|
assert!(matches!(err, Error::CellMissing(_)));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn fetch_unknown_ontology_id_returns_error() {
|
||
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
||
|
|
let oplog = tmp.path().join("x.oplog");
|
||
|
|
let _ = build_ontology(
|
||
|
|
&oplog,
|
||
|
|
&[assert_op("e", "a", Value::Int(1), 1, 0xCC)],
|
||
|
|
);
|
||
|
|
|
||
|
|
let registry = OntologyRegistry::empty();
|
||
|
|
let unknown_id = ontoref_ontology_content::OntologyId([0xFFu8; 32]);
|
||
|
|
let err = fetch_cell_witness(®istry, &unknown_id, &CellQuery::new("e", "a"))
|
||
|
|
.expect_err("unknown id must error");
|
||
|
|
assert!(matches!(err, Error::UnknownOntology(_)));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn ontology_id_is_deterministic_across_independent_rebuilds() {
|
||
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
||
|
|
let path_a = tmp.path().join("a.oplog");
|
||
|
|
let path_b = tmp.path().join("b.oplog");
|
||
|
|
let ops = vec![
|
||
|
|
assert_op("e1", "a1", Value::Str("v1".into()), 1, 0xDD),
|
||
|
|
assert_op("e2", "a2", Value::Int(7), 2, 0xDD),
|
||
|
|
];
|
||
|
|
let id_a = build_ontology(&path_a, &ops);
|
||
|
|
let id_b = build_ontology(&path_b, &ops);
|
||
|
|
assert_eq!(
|
||
|
|
id_a, id_b,
|
||
|
|
"two oplogs with identical op sequence must produce the same OntologyId"
|
||
|
|
);
|
||
|
|
}
|