Skip to main content

dig_dht/
content.rs

1//! [`ContentId`] — what a provider record is keyed by, at the granularities the L7
2//! `dig.getAvailability` shapes use, mapped consistently into the [`crate::Key`] keyspace.
3//!
4//! A DIG Node advertises the content it holds so others can find it. The unit of advertisement is a
5//! `ContentId`, which matches the availability granularities of the peer network (L7 spec §9):
6//!
7//! | Granularity | Fields | Answers |
8//! |---|---|---|
9//! | [`ContentId::Store`] | `store_id` | "does a peer serve this store at all?" |
10//! | [`ContentId::Root`] | `store_id` + `root` | "does a peer have this generation `(store_id, root)`?" |
11//! | [`ContentId::capsule`] | `store_id` + `root` | the immutable capsule `store_id:root` (alias of Root) |
12//! | [`ContentId::Resource`] | `store_id` + `root` + `retrieval_key` | "does a peer have this resource within the capsule?" |
13//!
14//! **Keyspace mapping.** Each `ContentId` hashes to a 256-bit [`Key`] via SHA-256 over a
15//! canonical, domain-separated byte encoding ([`ContentId::to_key`]). The domain separation (a
16//! one-byte tag per granularity) guarantees that a store key, a root key, and a resource key are
17//! distinct points even when they share the same `store_id` — so a store-level provider record and a
18//! resource-level provider record for the same store never collide in the DHT. The encoding is
19//! canonical (fixed field order, raw 32-byte hashes) so every implementation derives the identical
20//! key for the same content — a frozen wire contract.
21
22use std::fmt;
23
24use sha2::{Digest, Sha256};
25
26use crate::key::Key;
27
28/// A DIG content identifier at store / root(capsule) / resource granularity — the key a provider
29/// record is stored under and a lookup asks for.
30///
31/// All hashes are the raw 32-byte forms (a `store_id`, a generation `root`, and a `retrieval_key`
32/// are each a `Bytes32`). Use the constructors ([`Self::store`], [`Self::root`], [`Self::capsule`],
33/// [`Self::resource`]) rather than building variants directly, and [`Self::to_key`] to map into the
34/// DHT keyspace.
35#[derive(Clone, Copy, PartialEq, Eq, Hash)]
36pub enum ContentId {
37    /// A whole store — "does a peer serve this `store_id`?" (has_store).
38    Store {
39        /// The 32-byte store id.
40        store_id: [u8; 32],
41    },
42    /// A specific generation `(store_id, root)` — the immutable capsule `store_id:root` (has_root).
43    /// [`Self::capsule`] and [`Self::root`] both build this variant; a capsule IS a root generation.
44    Root {
45        /// The 32-byte store id.
46        store_id: [u8; 32],
47        /// The 32-byte generation root.
48        root: [u8; 32],
49    },
50    /// A specific resource within a capsule — `(store_id, root, retrieval_key)` (has_resource).
51    Resource {
52        /// The 32-byte store id.
53        store_id: [u8; 32],
54        /// The 32-byte generation root.
55        root: [u8; 32],
56        /// The 32-byte resource retrieval key.
57        retrieval_key: [u8; 32],
58    },
59}
60
61/// Domain-separation tags so store / root / resource keys are distinct points even when they share a
62/// `store_id`. These bytes are part of the frozen key-derivation contract — never renumber them.
63const TAG_STORE: u8 = 0x01;
64const TAG_ROOT: u8 = 0x02;
65const TAG_RESOURCE: u8 = 0x03;
66
67impl ContentId {
68    /// A store-granularity content id (has_store).
69    pub const fn store(store_id: [u8; 32]) -> Self {
70        ContentId::Store { store_id }
71    }
72
73    /// A root/generation-granularity content id (has_root) — the generation `(store_id, root)`.
74    pub const fn root(store_id: [u8; 32], root: [u8; 32]) -> Self {
75        ContentId::Root { store_id, root }
76    }
77
78    /// The immutable capsule `store_id:root`. Alias of [`Self::root`] — a capsule is a generation.
79    pub const fn capsule(store_id: [u8; 32], root: [u8; 32]) -> Self {
80        ContentId::Root { store_id, root }
81    }
82
83    /// A resource-granularity content id (has_resource) — `(store_id, root, retrieval_key)`.
84    pub const fn resource(store_id: [u8; 32], root: [u8; 32], retrieval_key: [u8; 32]) -> Self {
85        ContentId::Resource {
86            store_id,
87            root,
88            retrieval_key,
89        }
90    }
91
92    /// The store id this content id belongs to (present at every granularity).
93    pub const fn store_id(&self) -> &[u8; 32] {
94        match self {
95            ContentId::Store { store_id }
96            | ContentId::Root { store_id, .. }
97            | ContentId::Resource { store_id, .. } => store_id,
98        }
99    }
100
101    /// The canonical, domain-separated byte encoding this content id hashes over. Fixed field order,
102    /// raw 32-byte hashes, one leading tag byte per granularity — a frozen wire contract so every
103    /// implementation derives the same [`Key`].
104    fn canonical_bytes(&self) -> Vec<u8> {
105        match self {
106            ContentId::Store { store_id } => {
107                let mut v = Vec::with_capacity(1 + 32);
108                v.push(TAG_STORE);
109                v.extend_from_slice(store_id);
110                v
111            }
112            ContentId::Root { store_id, root } => {
113                let mut v = Vec::with_capacity(1 + 64);
114                v.push(TAG_ROOT);
115                v.extend_from_slice(store_id);
116                v.extend_from_slice(root);
117                v
118            }
119            ContentId::Resource {
120                store_id,
121                root,
122                retrieval_key,
123            } => {
124                let mut v = Vec::with_capacity(1 + 96);
125                v.push(TAG_RESOURCE);
126                v.extend_from_slice(store_id);
127                v.extend_from_slice(root);
128                v.extend_from_slice(retrieval_key);
129                v
130            }
131        }
132    }
133
134    /// Map this content id into the 256-bit DHT [`Key`] keyspace: `SHA-256(canonical_bytes)`.
135    ///
136    /// Deterministic + domain-separated: the same content always yields the same key, and different
137    /// granularities of the same store yield different keys (so their provider records do not
138    /// collide). This is how `announce_provider` / `find_providers` agree on where a record lives.
139    pub fn to_key(&self) -> Key {
140        let digest = Sha256::digest(self.canonical_bytes());
141        let bytes: [u8; 32] = digest.into();
142        Key::from_bytes(bytes)
143    }
144}
145
146impl fmt::Debug for ContentId {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        match self {
149            ContentId::Store { store_id } => f
150                .debug_struct("ContentId::Store")
151                .field("store_id", &hex32(store_id))
152                .finish(),
153            ContentId::Root { store_id, root } => f
154                .debug_struct("ContentId::Root")
155                .field("store_id", &hex32(store_id))
156                .field("root", &hex32(root))
157                .finish(),
158            ContentId::Resource {
159                store_id,
160                root,
161                retrieval_key,
162            } => f
163                .debug_struct("ContentId::Resource")
164                .field("store_id", &hex32(store_id))
165                .field("root", &hex32(root))
166                .field("retrieval_key", &hex32(retrieval_key))
167                .finish(),
168        }
169    }
170}
171
172fn hex32(b: &[u8; 32]) -> String {
173    let mut s = String::with_capacity(64);
174    for x in b {
175        s.push(char::from_digit((x >> 4) as u32, 16).unwrap());
176        s.push(char::from_digit((x & 0x0f) as u32, 16).unwrap());
177    }
178    s
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    const S: [u8; 32] = [0x11; 32];
186    const R: [u8; 32] = [0x22; 32];
187    const RK: [u8; 32] = [0x33; 32];
188
189    #[test]
190    fn to_key_is_deterministic() {
191        assert_eq!(ContentId::store(S).to_key(), ContentId::store(S).to_key());
192        assert_eq!(
193            ContentId::resource(S, R, RK).to_key(),
194            ContentId::resource(S, R, RK).to_key()
195        );
196    }
197
198    #[test]
199    fn different_granularities_of_same_store_have_distinct_keys() {
200        let k_store = ContentId::store(S).to_key();
201        let k_root = ContentId::root(S, R).to_key();
202        let k_res = ContentId::resource(S, R, RK).to_key();
203        assert_ne!(k_store, k_root);
204        assert_ne!(k_root, k_res);
205        assert_ne!(k_store, k_res);
206    }
207
208    #[test]
209    fn capsule_is_an_alias_of_root() {
210        assert_eq!(ContentId::capsule(S, R), ContentId::root(S, R));
211        assert_eq!(
212            ContentId::capsule(S, R).to_key(),
213            ContentId::root(S, R).to_key()
214        );
215    }
216
217    #[test]
218    fn store_id_accessor_present_at_every_granularity() {
219        assert_eq!(ContentId::store(S).store_id(), &S);
220        assert_eq!(ContentId::root(S, R).store_id(), &S);
221        assert_eq!(ContentId::resource(S, R, RK).store_id(), &S);
222    }
223
224    #[test]
225    fn distinct_stores_have_distinct_keys() {
226        let other = [0x99; 32];
227        assert_ne!(
228            ContentId::store(S).to_key(),
229            ContentId::store(other).to_key()
230        );
231    }
232
233    #[test]
234    fn tag_prefix_prevents_field_shifting_collision() {
235        // Without a domain tag, root{store=A,root=B} and resource{store=A,root=B,rk=..} could be
236        // confused by a naive concat. The tag byte guarantees distinct preimages.
237        let a = ContentId::root(S, R).to_key();
238        let b = ContentId::resource(S, R, [0u8; 32]).to_key();
239        assert_ne!(a, b);
240    }
241
242    #[test]
243    fn canonical_bytes_have_the_expected_tag_and_length() {
244        // Store: tag + 32; Root: tag + 64; Resource: tag + 96. The leading byte is the domain tag.
245        let store = ContentId::store(S).canonical_bytes();
246        assert_eq!(store.len(), 1 + 32);
247        assert_eq!(store[0], TAG_STORE);
248
249        let root = ContentId::root(S, R).canonical_bytes();
250        assert_eq!(root.len(), 1 + 64);
251        assert_eq!(root[0], TAG_ROOT);
252
253        let res = ContentId::resource(S, R, RK).canonical_bytes();
254        assert_eq!(res.len(), 1 + 96);
255        assert_eq!(res[0], TAG_RESOURCE);
256    }
257
258    #[test]
259    fn debug_renders_hex_for_every_variant() {
260        // Exercises the Debug impl + hex32 helper for all three variants.
261        let store = format!("{:?}", ContentId::store(S));
262        assert!(store.contains("ContentId::Store"));
263        assert!(store.contains(&"11".repeat(32)));
264
265        let root = format!("{:?}", ContentId::root(S, R));
266        assert!(root.contains("ContentId::Root"));
267        assert!(root.contains(&"22".repeat(32)));
268
269        let res = format!("{:?}", ContentId::resource(S, R, RK));
270        assert!(res.contains("ContentId::Resource"));
271        assert!(res.contains(&"33".repeat(32)));
272    }
273}