//! Canonical encoding: how an object becomes its id.
//!
//! Every id is `blake3(tag byte || length-prefixed fields)` via
//! [`jac_core::DigestHasher`]. The tag byte separates kinds; length prefixes
//! make field boundaries unambiguous. Because [`crate::Tree`] is sorted at
//! construction, equal directories encode equal — canonicalisation happens in
//! the type, not the encoder.
use jac_core::{BlobId, DigestHasher, ObjectTag, SnapshotId, TreeId};
use crate::object::Blob;
use crate::snapshot::Snapshot;
use crate::tree::{Tree, TreeNode};
/// Computes a blob's content address.
#[must_use]
pub fn blob_id(blob: &Blob) -> BlobId {
let mut h = DigestHasher::new();
h.tag(ObjectTag::Blob.as_byte()).field(blob.as_bytes());
BlobId::from_digest(h.finish())
}
/// Computes a tree's content address.
#[must_use]
pub fn tree_id(tree: &Tree) -> TreeId {
let mut h = DigestHasher::new();
h.tag(ObjectTag::Tree.as_byte());
for entry in tree.entries() {
h.field(entry.name.as_str().as_bytes());
match entry.node {
TreeNode::Blob(id) => {
h.u64(0).digest(id.digest());
}
TreeNode::Tree(id) => {
h.u64(1).digest(id.digest());
}
}
}
TreeId::from_digest(h.finish())
}
/// Computes a snapshot's content address.
///
/// The provenance byte is absorbed alongside the tree: this line is where
/// "provenance is identity" physically happens.
#[must_use]
pub fn snapshot_id(snapshot: &Snapshot) -> SnapshotId {
let mut h = DigestHasher::new();
h.tag(ObjectTag::Snapshot.as_byte());
h.digest(snapshot.tree.digest());
h.u64(u64::try_from(snapshot.parents.len()).unwrap_or(u64::MAX));
for parent in &snapshot.parents {
h.digest(parent.digest());
}
match snapshot.author {
jac_core::Author::Human(id) => h.u64(0).u64(id.get()),
jac_core::Author::Agent(id) => h.u64(1).u64(id.get()),
_ => h.u64(u64::MAX),
};
h.u64(u64::from(snapshot.provenance.as_byte()));
h.field(snapshot.message.as_bytes());
h.i64(snapshot.at.as_millis());
SnapshotId::from_digest(h.finish())
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "test assertions read better with unwrap"
)]
mod tests {
use jac_core::{AgentId, Author, Digest, HumanId, Provenance, Timestamp};
use smallvec::SmallVec;
use super::*;
use crate::tree::{Segment, TreeEntry};
fn sample_tree() -> Tree {
Tree::new(vec![TreeEntry {
name: Segment::parse("lib.rs").unwrap(),
node: TreeNode::Blob(blob_id(&Blob::new(&b"fn main() {}"[..]))),
}])
.unwrap()
}
fn snapshot(author: Author, provenance: Provenance) -> Snapshot {
Snapshot {
tree: tree_id(&sample_tree()),
parents: SmallVec::new(),
author,
provenance,
message: "initial weave".to_owned(),
at: Timestamp::from_millis(1_000),
}
}
#[test]
fn same_content_same_id() {
let a = Blob::new(&b"hello"[..]);
let b = Blob::new(&b"hello"[..]);
assert_eq!(blob_id(&a), blob_id(&b));
assert_eq!(tree_id(&sample_tree()), tree_id(&sample_tree()));
}
#[test]
fn blob_and_tree_of_same_bytes_do_not_collide() {
// A blob whose bytes happen to be empty and the empty tree: without
// the tag byte these could hash identically.
let blob = Blob::new(&b""[..]);
assert_ne!(blob_id(&blob).digest(), tree_id(&Tree::empty()).digest());
}
#[test]
fn provenance_changes_identity() {
let author = Author::Agent(AgentId::new(7).unwrap());
let as_agent = snapshot(author, Provenance::Agent);
let relabelled = snapshot(author, Provenance::Human);
assert_ne!(
snapshot_id(&as_agent),
snapshot_id(&relabelled),
"the same tree under different provenance must be a different snapshot"
);
}
#[test]
fn author_changes_identity() {
let s1 = snapshot(Author::Human(HumanId::new(1).unwrap()), Provenance::Human);
let s2 = snapshot(Author::Human(HumanId::new(2).unwrap()), Provenance::Human);
assert_ne!(snapshot_id(&s1), snapshot_id(&s2));
}
#[test]
fn ids_are_stable_across_runs() {
// Pin one id so an accidental encoding change cannot slip through as
// "all tests still pass relative to each other".
let id = blob_id(&Blob::new(&b"jacquard"[..]));
let again = blob_id(&Blob::new(&b"jacquard"[..]));
assert_eq!(id, again);
assert_ne!(id.digest(), Digest::ZERO);
}
}