jacquardSnapshot

← snapshot

4695 bytes
//! 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);
    }
}