diff --git a/core/src/schema/ir/frozen/cas/blob.rs b/core/src/schema/ir/frozen/cas/blob.rs index ca40e0e..fdcdc56 100644 --- a/core/src/schema/ir/frozen/cas/blob.rs +++ b/core/src/schema/ir/frozen/cas/blob.rs @@ -7,6 +7,27 @@ use crate::package::build::cas::storage::Hash; use crate::schema::ir::frozen::unit::FrozenUnit; use eyre::{eyre, Result}; +/// A canonical, cross-language-stable digest of one schema's frozen units: +/// BLAKE3 over their `bincode` encoding — the same serialization the CAS uses +/// for blobs (see [`frozen_unit_to_blob`]) — folded to 64 bits for the +/// connection handshake's `ir_hash`. +/// +/// Deterministic for a given frozen IR: independent of the generator, the host, +/// and whether the units were read from the working tree or a committed +/// version. Two ends of a connection generated from the same schema — in any +/// target language — get the same value, so a generator embeds this verbatim +/// rather than hashing its own (language-specific) rendering. +pub fn schema_ir_hash(units: &[FrozenUnit]) -> u64 { + let bytes = bincode::serialize(units) + .expect("FrozenUnit implements Serialize; in-memory encoding cannot fail"); + let digest = blake3::hash(&bytes); + u64::from_le_bytes( + digest.as_bytes()[..8] + .try_into() + .expect("a BLAKE3 digest is 32 bytes"), + ) +} + /// Convert a FrozenUnit to a Blob pub fn frozen_unit_to_blob(unit: &FrozenUnit) -> Result { // Serialize FrozenUnit using bincode diff --git a/core/src/schema/ir/frozen/mod.rs b/core/src/schema/ir/frozen/mod.rs index 8455f03..cec0120 100644 --- a/core/src/schema/ir/frozen/mod.rs +++ b/core/src/schema/ir/frozen/mod.rs @@ -2,3 +2,6 @@ pub mod unit; // Removed: basic_storage - replaced by CAS pub mod cas; // CAS module (public for tests and build) + +/// Canonical frozen-schema digest for the connection handshake's `ir_hash`. +pub use cas::blob::schema_ir_hash; diff --git a/core/tests/cas/mod.rs b/core/tests/cas/mod.rs index a82a554..dc15817 100644 --- a/core/tests/cas/mod.rs +++ b/core/tests/cas/mod.rs @@ -7,3 +7,4 @@ pub mod commit_tests; pub mod config_in_commit_tests; pub mod refs_tests; pub mod span_hash_sensitivity_tests; +pub mod schema_ir_hash_tests; diff --git a/core/tests/cas/schema_ir_hash_tests.rs b/core/tests/cas/schema_ir_hash_tests.rs new file mode 100644 index 0000000..673da47 --- /dev/null +++ b/core/tests/cas/schema_ir_hash_tests.rs @@ -0,0 +1,45 @@ +// `schema_ir_hash` — the canonical frozen-schema digest a generator embeds as +// the connection handshake's `IR_HASH`. It must be deterministic for a given +// frozen IR and move when the schema's meaning (or, like the CAS blob hash, +// its formatting) changes. + +use comline_core::schema::ir::compiler::interpreter::incremental::IncrementalInterpreter; +use comline_core::schema::ir::compiler::Compile; +use comline_core::schema::ir::frozen::schema_ir_hash; + +#[test] +fn identical_source_hashes_identically() { + let source = "struct User {\n id: u64\n name: str\n}\n"; + let a = IncrementalInterpreter::from_source(source); + let b = IncrementalInterpreter::from_source(source); + + assert_eq!(schema_ir_hash(&a), schema_ir_hash(&b)); +} + +#[test] +fn a_semantic_change_moves_the_hash() { + let before = IncrementalInterpreter::from_source("struct User {\n id: u64\n}\n"); + let after = IncrementalInterpreter::from_source("struct User {\n id: u64\n name: str\n}\n"); + + assert_ne!(schema_ir_hash(&before), schema_ir_hash(&after)); +} + +#[test] +fn unit_order_is_significant() { + let ab = IncrementalInterpreter::from_source( + "struct A {\n x: u64\n}\nstruct B {\n y: u64\n}\n", + ); + let ba = IncrementalInterpreter::from_source( + "struct B {\n y: u64\n}\nstruct A {\n x: u64\n}\n", + ); + + // Different declaration order ⇒ different frozen IR ⇒ different identity. + assert_ne!(schema_ir_hash(&ab), schema_ir_hash(&ba)); +} + +#[test] +fn an_empty_schema_still_hashes() { + let empty: Vec = Vec::new(); + // Just needs to not panic and be stable. + assert_eq!(schema_ir_hash(&empty), schema_ir_hash(&empty)); +}