Skip to main content

heddle_pack/store/pack/
pack_identity.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Distinct logical and physical identities for native packs.
3
4use std::fmt;
5
6use super::{ObjectType, PackObjectId};
7
8/// BLAKE3 derive-key context for the canonical logical pack inventory.
9pub const PACK_LOGICAL_ID_CONTEXT: &str = "heddle.pack.logical-id.v1";
10const LOGICAL_ENTRY_LEN: usize = 1 + 32 + 1 + 32;
11
12/// Root-spool-scoped identity of the logical objects in a pack.
13///
14/// This identity is stable across compression, record ordering, and delta-base
15/// selection. Hosted storage must scope it to the root spool: identical object
16/// inventories in unrelated root spools intentionally have the same value.
17///
18/// The canonical inventory is the sorted multiset of fixed-width
19/// `(id-kind, id, object-type, blake3(uncompressed-content))` records, prefixed
20/// by its big-endian `u64` record count and hashed with
21/// [`PACK_LOGICAL_ID_CONTEXT`] as the BLAKE3 derive-key context.
22#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
23pub struct PackLogicalId([u8; 32]);
24
25impl PackLogicalId {
26    /// Construct an id from its canonical 32-byte representation.
27    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
28        Self(bytes)
29    }
30
31    /// Return the canonical 32-byte representation.
32    pub const fn as_bytes(&self) -> &[u8; 32] {
33        &self.0
34    }
35
36    /// Return the full lowercase hexadecimal representation.
37    pub fn to_hex(self) -> String {
38        blake3::Hash::from_bytes(self.0).to_hex().to_string()
39    }
40}
41
42/// Hash of one finalized physical pack representation.
43///
44/// This is exactly `blake3(pack_bytes)`, including the pack's checksum trailer.
45/// It is suitable for integrity checks and physical location, but not logical
46/// pack equality or cross-machine deduplication.
47#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
48pub struct PackRepresentationHash([u8; 32]);
49
50impl PackRepresentationHash {
51    /// Hash one complete finalized pack representation.
52    pub fn compute(pack_bytes: &[u8]) -> Self {
53        Self(*blake3::hash(pack_bytes).as_bytes())
54    }
55
56    /// Construct a representation hash from its canonical 32-byte form.
57    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
58        Self(bytes)
59    }
60
61    /// Return the canonical 32-byte representation.
62    pub const fn as_bytes(&self) -> &[u8; 32] {
63        &self.0
64    }
65
66    /// Return the full lowercase hexadecimal representation.
67    pub fn to_hex(self) -> String {
68        blake3::Hash::from_bytes(self.0).to_hex().to_string()
69    }
70}
71
72#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
73struct LogicalInventoryEntry([u8; LOGICAL_ENTRY_LEN]);
74
75impl LogicalInventoryEntry {
76    fn new(id: PackObjectId, object_type: ObjectType, data: &[u8]) -> Self {
77        let mut canonical = [0; LOGICAL_ENTRY_LEN];
78        let (id_tag, id_bytes) = match &id {
79            PackObjectId::Hash(hash) => (0, hash.as_bytes()),
80            PackObjectId::StateId(state_id) => (1, state_id.as_bytes()),
81            PackObjectId::AnnotatedTag(hash) => (2, hash.as_bytes()),
82        };
83        canonical[0] = id_tag;
84        canonical[1..33].copy_from_slice(id_bytes);
85        canonical[33] = object_type as u8;
86        canonical[34..].copy_from_slice(blake3::hash(data).as_bytes());
87        Self(canonical)
88    }
89}
90
91pub(super) struct LogicalIdBuilder {
92    inventory: Vec<LogicalInventoryEntry>,
93}
94
95impl LogicalIdBuilder {
96    pub(super) fn new() -> Self {
97        Self {
98            inventory: Vec::new(),
99        }
100    }
101
102    pub(super) fn push(&mut self, id: PackObjectId, object_type: ObjectType, data: &[u8]) {
103        self.inventory
104            .push(LogicalInventoryEntry::new(id, object_type, data));
105    }
106
107    pub(super) fn finish(mut self) -> PackLogicalId {
108        self.inventory.sort_unstable();
109
110        let mut hasher = blake3::Hasher::new_derive_key(PACK_LOGICAL_ID_CONTEXT);
111        let count = u64::try_from(self.inventory.len()).expect("pack inventory length fits in u64");
112        hasher.update(&count.to_be_bytes());
113        for entry in self.inventory {
114            hasher.update(&entry.0);
115        }
116        PackLogicalId(*hasher.finalize().as_bytes())
117    }
118}
119
120pub(super) fn logical_id_from_objects<'a>(
121    objects: impl IntoIterator<Item = (PackObjectId, ObjectType, &'a [u8])>,
122) -> PackLogicalId {
123    let mut builder = LogicalIdBuilder::new();
124    for (id, object_type, data) in objects {
125        builder.push(id, object_type, data);
126    }
127    builder.finish()
128}
129
130fn fmt_hash(name: &str, bytes: &[u8; 32], f: &mut fmt::Formatter<'_>) -> fmt::Result {
131    let hash = blake3::Hash::from_bytes(*bytes);
132    write!(f, "{name}({})", &hash.to_hex().as_str()[..8])
133}
134
135impl fmt::Debug for PackLogicalId {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        fmt_hash("PackLogicalId", &self.0, f)
138    }
139}
140
141impl fmt::Display for PackLogicalId {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        write!(f, "{}", self.to_hex())
144    }
145}
146
147impl fmt::Debug for PackRepresentationHash {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        fmt_hash("PackRepresentationHash", &self.0, f)
150    }
151}
152
153impl fmt::Display for PackRepresentationHash {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        write!(f, "{}", self.to_hex())
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::object::ContentHash;
163
164    #[test]
165    fn logical_id_distinguishes_annotated_tag_and_hash_id_kinds() {
166        let bytes = [7; 32];
167        let data = b"same object bytes";
168        let hash = ContentHash::from_bytes(bytes);
169
170        let content_id = logical_id_from_objects([(
171            PackObjectId::Hash(hash),
172            ObjectType::Blob,
173            data.as_slice(),
174        )]);
175        let annotated_tag_id = logical_id_from_objects([(
176            PackObjectId::AnnotatedTag(hash),
177            ObjectType::Blob,
178            data.as_slice(),
179        )]);
180
181        assert_ne!(content_id, annotated_tag_id);
182    }
183}