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}