317 lines
11 KiB
Rust
317 lines
11 KiB
Rust
//! Content hashing and the content-address newtypes.
|
|
//!
|
|
//! Epiphany uses BLAKE3-256 as its single content-hashing algorithm
|
|
//! (Chapter 8 §"Content Hashing"). All hashes in canonical state are 32-byte
|
|
//! BLAKE3 outputs over a *domain-separated preimage*: the 8-byte
|
|
//! [`DomainTag`] is always the first bytes hashed, so two semantically
|
|
//! different objects with identical raw bytes never collide.
|
|
//!
|
|
//! Two derived integer widths recur in the spec:
|
|
//!
|
|
//! * `trunc64(BLAKE3(...))` — the 64-bit counter of a system-derived
|
|
//! identifier (Chapter 5) and similar. [`trunc64`].
|
|
//! * `trunc128(BLAKE3(...))` — content-derived 128-bit identifiers such as
|
|
//! `ConflictId` and `ManifestId` (Chapters 6, 8). [`trunc128`].
|
|
//!
|
|
//! Both truncations take the **leading** bytes of the digest in **big-endian**
|
|
//! order, matching the spec's reference code (`u64::from_be_bytes(hash[0..8])`,
|
|
//! `u128::from_be_bytes(hash[0..16])`).
|
|
|
|
use crate::domain::{DomainTag, SystemDomainTag};
|
|
use crate::float::CanonicalF64;
|
|
|
|
/// Computes the raw BLAKE3-256 digest of `data` (32 bytes). This is the one
|
|
/// content-hashing primitive; no other algorithm appears in this format
|
|
/// version (Chapter 8 §"Content Hashing").
|
|
#[inline]
|
|
pub fn blake3_256(data: &[u8]) -> [u8; 32] {
|
|
*blake3::hash(data).as_bytes()
|
|
}
|
|
|
|
/// Truncates a 256-bit digest to 64 bits by taking the leading 8 bytes as a
|
|
/// big-endian integer (Chapter 5 `derive_system_counter`).
|
|
#[inline]
|
|
pub fn trunc64(digest: &[u8; 32]) -> u64 {
|
|
let mut head = [0u8; 8];
|
|
head.copy_from_slice(&digest[0..8]);
|
|
u64::from_be_bytes(head)
|
|
}
|
|
|
|
/// Truncates a 256-bit digest to 128 bits by taking the leading 16 bytes as a
|
|
/// big-endian integer (Chapter 6 `derive_conflict_id`, Chapter 8 `ManifestId`).
|
|
#[inline]
|
|
pub fn trunc128(digest: &[u8; 32]) -> u128 {
|
|
let mut head = [0u8; 16];
|
|
head.copy_from_slice(&digest[0..16]);
|
|
u128::from_be_bytes(head)
|
|
}
|
|
|
|
/// Derives the 64-bit counter portion of a system-derived identifier
|
|
/// (Chapter 5 §"System-Derived Identifiers"). The preimage is the domain tag
|
|
/// followed by the canonical input bytes; the digest is truncated to 64 bits.
|
|
///
|
|
/// The typed identifiers that wrap this counter live in `epiphany-core`; this
|
|
/// crate owns only the deterministic derivation primitive so every replica
|
|
/// derives byte-identical system identifiers from identical canonical inputs.
|
|
///
|
|
/// The `domain` is a [`SystemDomainTag`], so the spec's precondition — only
|
|
/// `MUSCSVCE`, `MUSCSPCH`, or an extension `MUSCS` tag may seed a system
|
|
/// identifier (Chapter 5) — is enforced by the type, in every build profile.
|
|
/// A non-system tag such as [`DomainTag::CHUNK`] simply cannot be passed.
|
|
#[inline]
|
|
pub fn derive_system_counter(domain: SystemDomainTag, canonical_inputs: &[u8]) -> u64 {
|
|
let mut p = Preimage::new(domain.tag());
|
|
p.push_bytes(canonical_inputs);
|
|
p.finish_trunc64()
|
|
}
|
|
|
|
/// A BLAKE3-256 content hash: the canonical hash for every content-addressed
|
|
/// object in the bundle (Chapter 8). Ordering is lexicographic on the 32
|
|
/// bytes, which is exactly the order Appendix D mandates for chunk references.
|
|
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
|
pub struct ContentHash(pub [u8; 32]);
|
|
|
|
impl ContentHash {
|
|
/// The all-zero hash. Useful as a sentinel; never a real content address.
|
|
pub const ZERO: ContentHash = ContentHash([0u8; 32]);
|
|
|
|
/// Hashes a blob payload: `BLAKE3(MUSCBLOB || payload)`. This is the
|
|
/// `BlobId` construction — the only spec content hash that is a bare
|
|
/// `domain || payload`. RATIFIED by Pass 11 (item 3.1, P11-D3, a spec-bug
|
|
/// fix): core_spec §"Blobs", Requirement `req:format:blob-hash-shape` now
|
|
/// states the bare form explicitly and deletes the contradictory
|
|
/// "identically to chunks" phrasing.
|
|
///
|
|
/// Other content hashes are *not* this shape: a chunk hash also commits to
|
|
/// kind, schema version, and uncompressed length (Chapter 8
|
|
/// §"Domain-Separated Preimages"), and the manifest/snapshot hashes have
|
|
/// their own structured preimages. Build those with [`Preimage`] in
|
|
/// `epiphany-bundle`; there is deliberately no arbitrary-domain
|
|
/// single-payload constructor here, so a chunk hash can't accidentally be
|
|
/// computed as `BLAKE3(MUSCCHNK || payload)`.
|
|
#[inline]
|
|
pub fn of_blob(payload: &[u8]) -> Self {
|
|
Preimage::new(DomainTag::BLOB).push_bytes(payload).finish()
|
|
}
|
|
|
|
/// The raw 32 bytes.
|
|
#[inline]
|
|
pub const fn as_bytes(&self) -> &[u8; 32] {
|
|
&self.0
|
|
}
|
|
|
|
/// Lowercase hex rendering (64 characters). Deterministic and
|
|
/// locale-independent (Appendix D §"Text and Unicode").
|
|
pub fn to_hex(&self) -> String {
|
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
|
let mut s = String::with_capacity(64);
|
|
for &b in &self.0 {
|
|
s.push(HEX[(b >> 4) as usize] as char);
|
|
s.push(HEX[(b & 0x0f) as usize] as char);
|
|
}
|
|
s
|
|
}
|
|
}
|
|
|
|
impl core::fmt::Debug for ContentHash {
|
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
|
// Hashes are long; show the first 8 hex chars, like git short hashes.
|
|
write!(f, "ContentHash({}…)", &self.to_hex()[..8])
|
|
}
|
|
}
|
|
|
|
/// A chunk identifier: a newtype around [`ContentHash`]. The chunk's
|
|
/// identifier *is* its content hash; the distinct type makes the role visible
|
|
/// at use sites (a `ChunkId` is what you store in a `ChunkRef` and look up; a
|
|
/// `ContentHash` is what you compute by hashing). Both occupy the same 32
|
|
/// bytes (Chapter 8 §"Chunks").
|
|
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
|
|
pub struct ChunkId(pub ContentHash);
|
|
|
|
impl ChunkId {
|
|
/// The underlying content hash.
|
|
#[inline]
|
|
pub const fn content_hash(&self) -> ContentHash {
|
|
self.0
|
|
}
|
|
|
|
/// The raw 32 bytes.
|
|
#[inline]
|
|
pub const fn as_bytes(&self) -> &[u8; 32] {
|
|
self.0.as_bytes()
|
|
}
|
|
}
|
|
|
|
impl From<ContentHash> for ChunkId {
|
|
#[inline]
|
|
fn from(h: ContentHash) -> Self {
|
|
ChunkId(h)
|
|
}
|
|
}
|
|
|
|
/// A builder for domain-separated hash preimages.
|
|
///
|
|
/// Every canonical hash starts with an 8-byte [`DomainTag`] and then appends
|
|
/// canonical field bytes in a fixed order. `Preimage` enforces the
|
|
/// tag-goes-first discipline and offers little-endian integer and canonical
|
|
/// `f64` pushes so callers never hand-roll byte orders. Floating-point fields
|
|
/// are pushed as [`CanonicalF64`], so a NaN/inf can never enter a preimage and
|
|
/// `-0.0` is already normalized to `+0.0` (Appendix D §Serialization).
|
|
#[derive(Clone, Debug)]
|
|
pub struct Preimage {
|
|
buf: Vec<u8>,
|
|
}
|
|
|
|
impl Preimage {
|
|
/// Starts a preimage with its domain tag.
|
|
#[inline]
|
|
pub fn new(domain: DomainTag) -> Self {
|
|
let mut buf = Vec::with_capacity(DomainTag::LEN + 32);
|
|
buf.extend_from_slice(domain.as_bytes());
|
|
Preimage { buf }
|
|
}
|
|
|
|
/// Appends raw bytes.
|
|
#[inline]
|
|
pub fn push_bytes(&mut self, bytes: &[u8]) -> &mut Self {
|
|
self.buf.extend_from_slice(bytes);
|
|
self
|
|
}
|
|
|
|
/// Appends a `u64` in little-endian order (the spec's convention for
|
|
/// length and counter fields in preimages, e.g. `uncompressed_length`).
|
|
#[inline]
|
|
pub fn push_u64_le(&mut self, value: u64) -> &mut Self {
|
|
self.buf.extend_from_slice(&value.to_le_bytes());
|
|
self
|
|
}
|
|
|
|
/// Appends a canonical `f64` (8 little-endian bytes). The argument is a
|
|
/// [`CanonicalF64`], so a NaN/inf can never reach a hash preimage: it was
|
|
/// rejected at [`CanonicalF64::new`]. `-0.0` is already normalized to
|
|
/// `+0.0` by that type.
|
|
#[inline]
|
|
pub fn push_f64(&mut self, value: CanonicalF64) -> &mut Self {
|
|
self.buf.extend_from_slice(&value.to_le_bytes());
|
|
self
|
|
}
|
|
|
|
/// The accumulated preimage bytes.
|
|
#[inline]
|
|
pub fn as_bytes(&self) -> &[u8] {
|
|
&self.buf
|
|
}
|
|
|
|
/// Finishes as a [`ContentHash`] (full 256-bit digest).
|
|
#[inline]
|
|
pub fn finish(&self) -> ContentHash {
|
|
ContentHash(blake3_256(&self.buf))
|
|
}
|
|
|
|
/// Finishes as a [`ChunkId`].
|
|
#[inline]
|
|
pub fn finish_chunk_id(&self) -> ChunkId {
|
|
ChunkId(self.finish())
|
|
}
|
|
|
|
/// Finishes as a 64-bit big-endian truncation (system-derived counters).
|
|
#[inline]
|
|
pub fn finish_trunc64(&self) -> u64 {
|
|
trunc64(&blake3_256(&self.buf))
|
|
}
|
|
|
|
/// Finishes as a 128-bit big-endian truncation (content-derived ids).
|
|
#[inline]
|
|
pub fn finish_trunc128(&self) -> u128 {
|
|
trunc128(&blake3_256(&self.buf))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// BLAKE3 of the empty input — the canonical published test vector. Locking
|
|
// it down proves we are hashing the bytes we think we are.
|
|
const EMPTY_BLAKE3_HEX: &str =
|
|
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
|
|
|
|
#[test]
|
|
fn empty_digest_matches_published_vector() {
|
|
let h = ContentHash(blake3_256(b""));
|
|
assert_eq!(h.to_hex(), EMPTY_BLAKE3_HEX);
|
|
}
|
|
|
|
#[test]
|
|
fn truncations_are_big_endian_leading_bytes() {
|
|
let d = blake3_256(b"");
|
|
assert_eq!(trunc64(&d), 0xaf13_49b9_f5f9_a1a6);
|
|
assert_eq!(trunc128(&d), 0xaf13_49b9_f5f9_a1a6_a040_4dea_36dc_c949);
|
|
}
|
|
|
|
#[test]
|
|
fn domain_separation_changes_the_hash() {
|
|
let a = Preimage::new(DomainTag::BLOB)
|
|
.push_bytes(b"payload")
|
|
.finish();
|
|
let b = Preimage::new(DomainTag::CHUNK)
|
|
.push_bytes(b"payload")
|
|
.finish();
|
|
assert_ne!(a, b, "same payload under different tags must differ");
|
|
|
|
// `of_blob` really is BLAKE3(MUSCBLOB || payload).
|
|
let mut manual = Vec::new();
|
|
manual.extend_from_slice(DomainTag::BLOB.as_bytes());
|
|
manual.extend_from_slice(b"payload");
|
|
assert_eq!(
|
|
ContentHash::of_blob(b"payload"),
|
|
ContentHash(blake3_256(&manual))
|
|
);
|
|
assert_eq!(a, ContentHash::of_blob(b"payload"));
|
|
}
|
|
|
|
#[test]
|
|
fn preimage_field_order_is_significant() {
|
|
let mut p1 = Preimage::new(DomainTag::CONFLICT);
|
|
p1.push_u64_le(1).push_u64_le(2);
|
|
let mut p2 = Preimage::new(DomainTag::CONFLICT);
|
|
p2.push_u64_le(2).push_u64_le(1);
|
|
assert_ne!(p1.finish(), p2.finish());
|
|
}
|
|
|
|
#[test]
|
|
fn preimage_f64_uses_canonical_zero() {
|
|
let mut neg = Preimage::new(DomainTag::CONFLICT);
|
|
neg.push_f64(CanonicalF64::new(-0.0).unwrap());
|
|
let mut pos = Preimage::new(DomainTag::CONFLICT);
|
|
pos.push_f64(CanonicalF64::new(0.0).unwrap());
|
|
assert_eq!(neg.finish(), pos.finish());
|
|
}
|
|
|
|
#[test]
|
|
fn derive_system_counter_is_tag_then_inputs_trunc64() {
|
|
let got = derive_system_counter(SystemDomainTag::VOICE, b"abc");
|
|
let mut manual = Vec::new();
|
|
manual.extend_from_slice(DomainTag::SYSTEM_VOICE.as_bytes());
|
|
manual.extend_from_slice(b"abc");
|
|
assert_eq!(got, trunc64(&blake3_256(&manual)));
|
|
}
|
|
|
|
#[test]
|
|
fn chunk_id_shares_bytes_with_content_hash() {
|
|
let h = ContentHash::of_blob(b"x");
|
|
let id = ChunkId::from(h);
|
|
assert_eq!(id.as_bytes(), h.as_bytes());
|
|
assert_eq!(id.content_hash(), h);
|
|
}
|
|
|
|
#[test]
|
|
fn content_hash_orders_lexicographically() {
|
|
let lo = ContentHash([0u8; 32]);
|
|
let mut hi_bytes = [0u8; 32];
|
|
hi_bytes[0] = 1;
|
|
let hi = ContentHash(hi_bytes);
|
|
assert!(lo < hi);
|
|
}
|
|
}
|