use crate::bytes::Bytes32;
use crate::grammar::{DEFAULT_RESOURCE_KEY, SALT_QUERY_MARKER, URN_PREFIX};
use sha2::{Digest, Sha256};
fn sha256_hex(data: &[u8]) -> Bytes32 {
let mut hasher = Sha256::new();
hasher.update(data);
Bytes32(hasher.finalize().into())
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct SecretSalt(pub [u8; 32]);
impl core::fmt::Debug for SecretSalt {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("SecretSalt(<redacted>)")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DigUrn {
pub chain: String,
pub store_id: Bytes32,
pub root_hash: Option<Bytes32>,
pub resource_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid DIG URN: {0}")]
pub struct UrnParseError(pub String);
impl DigUrn {
pub fn parse(input: &str) -> Result<DigUrn, UrnParseError> {
let rest = input
.strip_prefix(URN_PREFIX)
.ok_or_else(|| UrnParseError(format!("missing '{URN_PREFIX}' prefix")))?;
let (head, resource_key) = match rest.split_once('/') {
Some((h, r)) => (h, Some(r.to_string())),
None => (rest, None),
};
let mut parts = head.split(':');
let chain = parts
.next()
.filter(|c| !c.is_empty())
.ok_or_else(|| UrnParseError("missing chain".into()))?
.to_string();
let store_id_hex = parts
.next()
.ok_or_else(|| UrnParseError("missing store id".into()))?;
let store_id = Bytes32::from_hex(store_id_hex)
.map_err(|_| UrnParseError("store id must be 64 hex chars".into()))?;
let root_hash = match parts.next() {
Some(rh) => Some(
Bytes32::from_hex(rh)
.map_err(|_| UrnParseError("root hash must be 64 hex chars".into()))?,
),
None => None,
};
if parts.next().is_some() {
return Err(UrnParseError("too many ':' segments".into()));
}
Ok(DigUrn {
chain,
store_id,
root_hash,
resource_key,
})
}
pub fn parse_with_salt(input: &str) -> Result<(DigUrn, Option<String>), UrnParseError> {
let trimmed = input.trim();
let (core_part, salt) = match trimmed.rsplit_once(SALT_QUERY_MARKER) {
Some((head, salt_hex)) => {
let salt_hex = salt_hex.trim();
if salt_hex.is_empty() || !salt_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(UrnParseError(format!(
"{SALT_QUERY_MARKER} must be non-empty hex"
)));
}
(head, Some(salt_hex.to_ascii_lowercase()))
}
None => (trimmed, None),
};
Ok((DigUrn::parse(core_part)?, salt))
}
pub fn salt_bytes(salt_hex: &str) -> Result<SecretSalt, UrnParseError> {
Bytes32::from_hex(salt_hex.trim())
.map(|b| SecretSalt(b.0))
.map_err(|_| UrnParseError("secret salt must be 64 hex chars".into()))
}
pub fn canonical(&self) -> String {
let mut s = format!("{URN_PREFIX}{}:{}", self.chain, self.store_id.to_hex());
if let Some(rh) = &self.root_hash {
s.push(':');
s.push_str(&rh.to_hex());
}
if let Some(rk) = &self.resource_key {
s.push('/');
s.push_str(rk);
}
s
}
pub fn effective_resource_key(&self) -> &str {
match self.resource_key.as_deref() {
Some(k) if !k.is_empty() => k,
_ => DEFAULT_RESOURCE_KEY,
}
}
pub fn canonical_rootless(&self) -> DigUrn {
DigUrn {
chain: self.chain.clone(),
store_id: self.store_id,
root_hash: None,
resource_key: Some(self.effective_resource_key().to_string()),
}
}
pub fn retrieval_key(&self) -> Bytes32 {
sha256_hex(self.canonical().as_bytes())
}
pub fn retrieval_key_hex(&self) -> String {
self.retrieval_key().to_hex()
}
pub fn content_key(&self) -> Bytes32 {
sha256_hex(self.canonical_rootless().canonical().as_bytes())
}
pub fn content_key_hex(&self) -> String {
self.content_key().to_hex()
}
pub fn store_id_hex(&self) -> String {
self.store_id.to_hex()
}
pub fn root_hex(&self) -> Option<String> {
self.root_hash.map(|r| r.to_hex())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> String {
"11".repeat(32)
}
#[test]
fn parses_full_form_and_canonicalises_idempotently() {
let input = format!("urn:dig:chia:{}:{}/index.html", store(), "22".repeat(32));
let urn = DigUrn::parse(&input).unwrap();
assert_eq!(urn.chain, "chia");
assert_eq!(urn.root_hash.unwrap().to_hex(), "22".repeat(32));
assert_eq!(urn.resource_key.as_deref(), Some("index.html"));
assert_eq!(urn.canonical(), input);
}
#[test]
fn bare_store_has_no_resource_and_defaults_to_index() {
let urn = DigUrn::parse(&format!("urn:dig:chia:{}", store())).unwrap();
assert_eq!(urn.resource_key, None);
assert_eq!(urn.effective_resource_key(), "index.html");
}
#[test]
fn trailing_slash_is_empty_resource_distinct_from_absent() {
let urn = DigUrn::parse(&format!("urn:dig:chia:{}/", store())).unwrap();
assert_eq!(urn.resource_key.as_deref(), Some(""));
assert_eq!(urn.effective_resource_key(), "index.html");
}
#[test]
fn resource_split_is_at_first_slash() {
let urn = DigUrn::parse(&format!("urn:dig:chia:{}/a/b/c.json", store())).unwrap();
assert_eq!(urn.resource_key.as_deref(), Some("a/b/c.json"));
}
#[test]
fn retrieval_key_pins_the_root_but_content_key_is_root_independent() {
let rootless = DigUrn::parse(&format!("urn:dig:chia:{}/a", store())).unwrap();
let rooted =
DigUrn::parse(&format!("urn:dig:chia:{}:{}/a", store(), "22".repeat(32))).unwrap();
assert_ne!(rootless.retrieval_key(), rooted.retrieval_key());
assert_eq!(rootless.content_key(), rooted.content_key());
}
#[test]
fn accepts_mainnet_and_testnet_labels_for_backcompat() {
assert!(DigUrn::parse(&format!("urn:dig:mainnet:{}/a", store())).is_ok());
assert!(DigUrn::parse(&format!("urn:dig:testnet:{}", store())).is_ok());
}
#[test]
fn rejects_bad_forms() {
assert!(DigUrn::parse("urn:other:chia:00").is_err());
assert!(DigUrn::parse("not-a-urn").is_err());
assert!(DigUrn::parse("urn:dig:chia").is_err());
assert!(DigUrn::parse(&format!("urn:dig::{}", store())).is_err());
assert!(DigUrn::parse("urn:dig:chia:zzzz").is_err());
assert!(DigUrn::parse(&format!(
"urn:dig:chia:{}:{}:{}",
store(),
"22".repeat(32),
"33".repeat(32)
))
.is_err());
}
#[test]
fn peels_salt_suffix_and_leaves_it_out_of_identity() {
let with_salt = format!("urn:dig:chia:{}/index.html?salt=DEADBEEF", store());
let (urn, salt) = DigUrn::parse_with_salt(&with_salt).unwrap();
assert_eq!(salt.as_deref(), Some("deadbeef")); assert_eq!(urn.resource_key.as_deref(), Some("index.html"));
}
#[test]
fn core_parser_leaves_salt_query_inside_resource() {
let urn = DigUrn::parse(&format!(
"urn:dig:chia:{}/index.html?salt=deadbeef",
store()
))
.unwrap();
assert_eq!(
urn.resource_key.as_deref(),
Some("index.html?salt=deadbeef")
);
}
#[test]
fn salt_bytes_requires_64_hex() {
assert!(DigUrn::salt_bytes(&"ab".repeat(32)).is_ok());
assert!(DigUrn::salt_bytes("deadbeef").is_err());
}
#[test]
fn empty_salt_query_rejected() {
assert!(DigUrn::parse_with_salt(&format!("urn:dig:chia:{}/a?salt=", store())).is_err());
}
}