1use crate::bytes::Bytes32;
11use crate::grammar::{DEFAULT_RESOURCE_KEY, SALT_QUERY_MARKER, URN_PREFIX};
12use sha2::{Digest, Sha256};
13
14fn sha256_hex(data: &[u8]) -> Bytes32 {
16 let mut hasher = Sha256::new();
17 hasher.update(data);
18 Bytes32(hasher.finalize().into())
19}
20
21#[derive(Clone, Copy, PartialEq, Eq)]
27pub struct SecretSalt(pub [u8; 32]);
28
29impl core::fmt::Debug for SecretSalt {
30 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
32 f.write_str("SecretSalt(<redacted>)")
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct DigUrn {
43 pub chain: String,
45 pub store_id: Bytes32,
47 pub root_hash: Option<Bytes32>,
50 pub resource_key: Option<String>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
57#[error("invalid DIG URN: {0}")]
58pub struct UrnParseError(pub String);
59
60impl DigUrn {
61 pub fn parse(input: &str) -> Result<DigUrn, UrnParseError> {
64 let rest = input
65 .strip_prefix(URN_PREFIX)
66 .ok_or_else(|| UrnParseError(format!("missing '{URN_PREFIX}' prefix")))?;
67
68 let (head, resource_key) = match rest.split_once('/') {
70 Some((h, r)) => (h, Some(r.to_string())),
71 None => (rest, None),
72 };
73
74 let mut parts = head.split(':');
76 let chain = parts
77 .next()
78 .filter(|c| !c.is_empty())
79 .ok_or_else(|| UrnParseError("missing chain".into()))?
80 .to_string();
81 let store_id_hex = parts
82 .next()
83 .ok_or_else(|| UrnParseError("missing store id".into()))?;
84 let store_id = Bytes32::from_hex(store_id_hex)
85 .map_err(|_| UrnParseError("store id must be 64 hex chars".into()))?;
86 let root_hash = match parts.next() {
87 Some(rh) => Some(
88 Bytes32::from_hex(rh)
89 .map_err(|_| UrnParseError("root hash must be 64 hex chars".into()))?,
90 ),
91 None => None,
92 };
93 if parts.next().is_some() {
94 return Err(UrnParseError("too many ':' segments".into()));
95 }
96
97 Ok(DigUrn {
98 chain,
99 store_id,
100 root_hash,
101 resource_key,
102 })
103 }
104
105 pub fn parse_with_salt(input: &str) -> Result<(DigUrn, Option<String>), UrnParseError> {
112 let trimmed = input.trim();
113 let (core_part, salt) = match trimmed.rsplit_once(SALT_QUERY_MARKER) {
114 Some((head, salt_hex)) => {
115 let salt_hex = salt_hex.trim();
116 if salt_hex.is_empty() || !salt_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
117 return Err(UrnParseError(format!(
118 "{SALT_QUERY_MARKER} must be non-empty hex"
119 )));
120 }
121 (head, Some(salt_hex.to_ascii_lowercase()))
122 }
123 None => (trimmed, None),
124 };
125 Ok((DigUrn::parse(core_part)?, salt))
126 }
127
128 pub fn salt_bytes(salt_hex: &str) -> Result<SecretSalt, UrnParseError> {
130 Bytes32::from_hex(salt_hex.trim())
131 .map(|b| SecretSalt(b.0))
132 .map_err(|_| UrnParseError("secret salt must be 64 hex chars".into()))
133 }
134
135 pub fn canonical(&self) -> String {
137 let mut s = format!("{URN_PREFIX}{}:{}", self.chain, self.store_id.to_hex());
138 if let Some(rh) = &self.root_hash {
139 s.push(':');
140 s.push_str(&rh.to_hex());
141 }
142 if let Some(rk) = &self.resource_key {
143 s.push('/');
144 s.push_str(rk);
145 }
146 s
147 }
148
149 pub fn effective_resource_key(&self) -> &str {
151 match self.resource_key.as_deref() {
152 Some(k) if !k.is_empty() => k,
153 _ => DEFAULT_RESOURCE_KEY,
154 }
155 }
156
157 pub fn canonical_rootless(&self) -> DigUrn {
161 DigUrn {
162 chain: self.chain.clone(),
163 store_id: self.store_id,
164 root_hash: None,
165 resource_key: Some(self.effective_resource_key().to_string()),
166 }
167 }
168
169 pub fn retrieval_key(&self) -> Bytes32 {
173 sha256_hex(self.canonical().as_bytes())
174 }
175
176 pub fn retrieval_key_hex(&self) -> String {
178 self.retrieval_key().to_hex()
179 }
180
181 pub fn content_key(&self) -> Bytes32 {
186 sha256_hex(self.canonical_rootless().canonical().as_bytes())
187 }
188
189 pub fn content_key_hex(&self) -> String {
191 self.content_key().to_hex()
192 }
193
194 pub fn store_id_hex(&self) -> String {
196 self.store_id.to_hex()
197 }
198
199 pub fn root_hex(&self) -> Option<String> {
201 self.root_hash.map(|r| r.to_hex())
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn store() -> String {
210 "11".repeat(32)
211 }
212
213 #[test]
214 fn parses_full_form_and_canonicalises_idempotently() {
215 let input = format!("urn:dig:chia:{}:{}/index.html", store(), "22".repeat(32));
216 let urn = DigUrn::parse(&input).unwrap();
217 assert_eq!(urn.chain, "chia");
218 assert_eq!(urn.root_hash.unwrap().to_hex(), "22".repeat(32));
219 assert_eq!(urn.resource_key.as_deref(), Some("index.html"));
220 assert_eq!(urn.canonical(), input);
221 }
222
223 #[test]
224 fn bare_store_has_no_resource_and_defaults_to_index() {
225 let urn = DigUrn::parse(&format!("urn:dig:chia:{}", store())).unwrap();
226 assert_eq!(urn.resource_key, None);
227 assert_eq!(urn.effective_resource_key(), "index.html");
228 }
229
230 #[test]
231 fn trailing_slash_is_empty_resource_distinct_from_absent() {
232 let urn = DigUrn::parse(&format!("urn:dig:chia:{}/", store())).unwrap();
233 assert_eq!(urn.resource_key.as_deref(), Some(""));
234 assert_eq!(urn.effective_resource_key(), "index.html");
235 }
236
237 #[test]
238 fn resource_split_is_at_first_slash() {
239 let urn = DigUrn::parse(&format!("urn:dig:chia:{}/a/b/c.json", store())).unwrap();
240 assert_eq!(urn.resource_key.as_deref(), Some("a/b/c.json"));
241 }
242
243 #[test]
244 fn retrieval_key_pins_the_root_but_content_key_is_root_independent() {
245 let rootless = DigUrn::parse(&format!("urn:dig:chia:{}/a", store())).unwrap();
246 let rooted =
247 DigUrn::parse(&format!("urn:dig:chia:{}:{}/a", store(), "22".repeat(32))).unwrap();
248 assert_ne!(rootless.retrieval_key(), rooted.retrieval_key());
250 assert_eq!(rootless.content_key(), rooted.content_key());
252 }
253
254 #[test]
255 fn accepts_mainnet_and_testnet_labels_for_backcompat() {
256 assert!(DigUrn::parse(&format!("urn:dig:mainnet:{}/a", store())).is_ok());
257 assert!(DigUrn::parse(&format!("urn:dig:testnet:{}", store())).is_ok());
258 }
259
260 #[test]
261 fn rejects_bad_forms() {
262 assert!(DigUrn::parse("urn:other:chia:00").is_err());
263 assert!(DigUrn::parse("not-a-urn").is_err());
264 assert!(DigUrn::parse("urn:dig:chia").is_err());
265 assert!(DigUrn::parse(&format!("urn:dig::{}", store())).is_err());
266 assert!(DigUrn::parse("urn:dig:chia:zzzz").is_err());
267 assert!(DigUrn::parse(&format!(
268 "urn:dig:chia:{}:{}:{}",
269 store(),
270 "22".repeat(32),
271 "33".repeat(32)
272 ))
273 .is_err());
274 }
275
276 #[test]
277 fn peels_salt_suffix_and_leaves_it_out_of_identity() {
278 let with_salt = format!("urn:dig:chia:{}/index.html?salt=DEADBEEF", store());
279 let (urn, salt) = DigUrn::parse_with_salt(&with_salt).unwrap();
280 assert_eq!(salt.as_deref(), Some("deadbeef")); assert_eq!(urn.resource_key.as_deref(), Some("index.html"));
282 }
283
284 #[test]
285 fn core_parser_leaves_salt_query_inside_resource() {
286 let urn = DigUrn::parse(&format!(
288 "urn:dig:chia:{}/index.html?salt=deadbeef",
289 store()
290 ))
291 .unwrap();
292 assert_eq!(
293 urn.resource_key.as_deref(),
294 Some("index.html?salt=deadbeef")
295 );
296 }
297
298 #[test]
299 fn salt_bytes_requires_64_hex() {
300 assert!(DigUrn::salt_bytes(&"ab".repeat(32)).is_ok());
301 assert!(DigUrn::salt_bytes("deadbeef").is_err());
302 }
303
304 #[test]
305 fn empty_salt_query_rejected() {
306 assert!(DigUrn::parse_with_salt(&format!("urn:dig:chia:{}/a?salt=", store())).is_err());
307 }
308}