Skip to main content

substrate/uri/
cmn.rs

1use anyhow::{anyhow, bail, Result};
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use std::fmt::{Display, Formatter};
4use std::str::FromStr;
5
6use super::validate_domain;
7
8/// The kind of entity a CMN URI identifies.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum CmnUriKind {
11    /// Domain root: `cmn://domain`
12    Domain,
13    /// Content-addressed spore: `cmn://domain/hash`
14    Spore,
15    /// Content-addressed mycelium: `cmn://domain/mycelium/hash`
16    Mycelium,
17    /// Content-addressed taste report: `cmn://domain/taste/hash`
18    Taste,
19}
20
21impl CmnUriKind {
22    pub fn as_str(self) -> &'static str {
23        match self {
24            Self::Domain => "domain",
25            Self::Spore => "spore",
26            Self::Mycelium => "mycelium",
27            Self::Taste => "taste",
28        }
29    }
30}
31
32impl Display for CmnUriKind {
33    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
34        f.write_str(self.as_str())
35    }
36}
37
38impl FromStr for CmnUriKind {
39    type Err = anyhow::Error;
40
41    fn from_str(value: &str) -> Result<Self> {
42        match value {
43            "domain" => Ok(Self::Domain),
44            "spore" => Ok(Self::Spore),
45            "mycelium" => Ok(Self::Mycelium),
46            "taste" => Ok(Self::Taste),
47            _ => Err(anyhow!(
48                "Invalid CMN URI kind '{}'. Must be one of: domain, spore, mycelium, taste",
49                value
50            )),
51        }
52    }
53}
54
55impl Serialize for CmnUriKind {
56    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
57    where
58        S: Serializer,
59    {
60        serializer.serialize_str(self.as_str())
61    }
62}
63
64impl<'de> Deserialize<'de> for CmnUriKind {
65    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
66    where
67        D: Deserializer<'de>,
68    {
69        let value = String::deserialize(deserializer)?;
70        Self::from_str(&value).map_err(serde::de::Error::custom)
71    }
72}
73
74/// Parsed CMN URI
75#[derive(Debug, Clone, PartialEq)]
76pub struct CmnUri {
77    pub domain: String,
78    pub hash: Option<String>,
79    pub kind: CmnUriKind,
80}
81
82impl CmnUri {
83    /// Parse a CMN URI string into a CmnUri struct
84    ///
85    /// # Examples
86    /// ```
87    /// use substrate::CmnUri;
88    ///
89    /// let uri = CmnUri::parse("cmn://example.com/b3.3yMR7vZQ9hL").unwrap();
90    /// assert_eq!(uri.domain, "example.com");
91    /// assert_eq!(uri.hash, Some("b3.3yMR7vZQ9hL".to_string()));
92    ///
93    /// let taste = CmnUri::parse("cmn://alice.dev/taste/b3.7tRkW2x").unwrap();
94    /// assert_eq!(taste.domain, "alice.dev");
95    /// assert_eq!(taste.hash, Some("b3.7tRkW2x".to_string()));
96    /// assert!(taste.is_taste());
97    /// ```
98    pub fn parse(uri: &str) -> Result<Self, String> {
99        parse_uri(uri).map_err(|e| e.to_string())
100    }
101
102    /// Get hash formatted for use in filenames.
103    pub fn hash_filename(&self) -> Option<String> {
104        self.hash.clone()
105    }
106
107    /// Returns true if this is a spore URI (has a hash, not a taste).
108    pub fn is_spore(&self) -> bool {
109        self.kind == CmnUriKind::Spore
110    }
111
112    /// Returns true if this is a domain root URI (no hash).
113    pub fn is_domain(&self) -> bool {
114        self.kind == CmnUriKind::Domain
115    }
116
117    /// Returns true if this is a taste report URI.
118    pub fn is_taste(&self) -> bool {
119        self.kind == CmnUriKind::Taste
120    }
121
122    /// Returns true if this is a mycelium URI.
123    pub fn is_mycelium(&self) -> bool {
124        self.kind == CmnUriKind::Mycelium
125    }
126}
127
128/// Parse a CMN URI into its components
129///
130/// Four forms:
131/// - `cmn://domain` → domain root
132/// - `cmn://domain/hash` → content-addressed spore
133/// - `cmn://domain/mycelium/hash` → content-addressed mycelium
134/// - `cmn://domain/taste/hash` → content-addressed taste report
135///
136/// # Examples
137/// ```
138/// use substrate::uri::parse_uri;
139///
140/// let spore = parse_uri("cmn://example.com/b3.3yMR7vZQ9hL").unwrap();
141/// assert_eq!(spore.domain, "example.com");
142/// assert_eq!(spore.hash, Some("b3.3yMR7vZQ9hL".to_string()));
143/// assert!(spore.is_spore());
144///
145/// let domain = parse_uri("cmn://example.com").unwrap();
146/// assert_eq!(domain.domain, "example.com");
147/// assert_eq!(domain.hash, None);
148/// assert!(domain.is_domain());
149///
150/// let mycelium = parse_uri("cmn://example.com/mycelium/b3.7tRk").unwrap();
151/// assert_eq!(mycelium.domain, "example.com");
152/// assert_eq!(mycelium.hash, Some("b3.7tRk".to_string()));
153/// assert!(mycelium.is_mycelium());
154///
155/// let taste = parse_uri("cmn://alice.dev/taste/b3.7tRkW2x").unwrap();
156/// assert_eq!(taste.domain, "alice.dev");
157/// assert_eq!(taste.hash, Some("b3.7tRkW2x".to_string()));
158/// assert!(taste.is_taste());
159/// ```
160pub fn parse_uri(uri: &str) -> Result<CmnUri> {
161    let rest = uri
162        .strip_prefix("cmn://")
163        .ok_or_else(|| anyhow!("URI must start with 'cmn://'"))?;
164
165    let trimmed = rest.trim_end_matches('/');
166    if trimmed.is_empty() {
167        return Err(anyhow!("Missing domain in URI"));
168    }
169
170    let (domain, path) = match trimmed.split_once('/') {
171        Some((domain, path)) if !path.is_empty() => (domain.to_string(), Some(path.to_string())),
172        Some((domain, _)) => (domain.to_string(), None),
173        None => (trimmed.to_string(), None),
174    };
175
176    validate_domain(&domain)?;
177
178    let (kind, hash) = match path {
179        None => (CmnUriKind::Domain, None),
180        Some(path) => {
181            if path == "taste" {
182                return Err(anyhow!("Taste URI missing hash after /taste/"));
183            } else if path == "mycelium" {
184                return Err(anyhow!("Mycelium URI missing hash after /mycelium/"));
185            } else if let Some(taste_hash) = path.strip_prefix("taste/") {
186                if taste_hash.is_empty() {
187                    return Err(anyhow!("Taste URI missing hash after /taste/"));
188                }
189                let normalized = crate::crypto::parse_hash(taste_hash)
190                    .map(|hash| crate::crypto::format_hash(hash.algorithm, &hash.bytes))
191                    .map_err(|e| anyhow!("Invalid taste hash '{}': {}", taste_hash, e))?;
192                (CmnUriKind::Taste, Some(normalized))
193            } else if let Some(mycelium_hash) = path.strip_prefix("mycelium/") {
194                if mycelium_hash.is_empty() {
195                    return Err(anyhow!("Mycelium URI missing hash after /mycelium/"));
196                }
197                let normalized = crate::crypto::parse_hash(mycelium_hash)
198                    .map(|hash| crate::crypto::format_hash(hash.algorithm, &hash.bytes))
199                    .map_err(|e| anyhow!("Invalid mycelium hash '{}': {}", mycelium_hash, e))?;
200                (CmnUriKind::Mycelium, Some(normalized))
201            } else {
202                let normalized = crate::crypto::parse_hash(&path)
203                    .map(|hash| crate::crypto::format_hash(hash.algorithm, &hash.bytes))
204                    .map_err(|e| anyhow!("Invalid spore hash '{}': {}", path, e))?;
205                (CmnUriKind::Spore, Some(normalized))
206            }
207        }
208    };
209
210    Ok(CmnUri { domain, hash, kind })
211}
212
213/// Normalize and validate a taste target URI.
214///
215/// Allowed target kinds:
216/// - `cmn://{domain}`
217/// - `cmn://{domain}/{hash}`
218/// - `cmn://{domain}/mycelium/{hash}`
219///
220/// Taste report URIs (`cmn://.../taste/{hash}`) are not valid targets.
221pub fn normalize_taste_target_uri(uri: &str) -> Result<String> {
222    let parsed = parse_uri(uri)?;
223    match parsed.kind {
224        CmnUriKind::Domain => Ok(build_domain_uri(&parsed.domain)),
225        CmnUriKind::Spore => {
226            let hash = parsed
227                .hash
228                .ok_or_else(|| anyhow!("Spore target URI is missing hash"))?;
229            Ok(build_spore_uri(&parsed.domain, &hash))
230        }
231        CmnUriKind::Mycelium => {
232            let hash = parsed
233                .hash
234                .ok_or_else(|| anyhow!("Mycelium target URI is missing hash"))?;
235            Ok(build_mycelium_uri(&parsed.domain, &hash))
236        }
237        CmnUriKind::Taste => {
238            bail!("Taste target_uri must be one of: domain URI, spore URI, mycelium URI")
239        }
240    }
241}
242
243/// Build a spore URI
244pub fn build_spore_uri(domain: &str, hash: &str) -> String {
245    format!("cmn://{}/{}", domain, hash)
246}
247
248/// Build a domain root URI
249pub fn build_domain_uri(domain: &str) -> String {
250    format!("cmn://{}", domain)
251}
252
253/// Build a taste report URI
254pub fn build_taste_uri(domain: &str, hash: &str) -> String {
255    format!("cmn://{}/taste/{}", domain, hash)
256}
257
258/// Build a mycelium URI
259pub fn build_mycelium_uri(domain: &str, hash: &str) -> String {
260    format!("cmn://{}/mycelium/{}", domain, hash)
261}
262
263#[cfg(test)]
264mod tests {
265    #![allow(clippy::expect_used, clippy::unwrap_used)]
266
267    use super::*;
268
269    #[test]
270    fn test_parse_spore_uri() {
271        let uri = parse_uri("cmn://example.com/b3.3yMR7vZQ9hL").unwrap();
272        assert_eq!(uri.domain, "example.com");
273        assert_eq!(uri.hash, Some("b3.3yMR7vZQ9hL".to_string()));
274        assert!(uri.is_spore());
275        assert_eq!(uri.kind, CmnUriKind::Spore);
276    }
277
278    #[test]
279    fn test_parse_domain_uri() {
280        let uri = parse_uri("cmn://example.com").unwrap();
281        assert_eq!(uri.domain, "example.com");
282        assert_eq!(uri.hash, None);
283        assert!(uri.is_domain());
284        assert_eq!(uri.kind, CmnUriKind::Domain);
285    }
286
287    #[test]
288    fn test_parse_domain_uri_trailing_slash() {
289        let uri = parse_uri("cmn://example.com/").unwrap();
290        assert_eq!(uri.domain, "example.com");
291        assert_eq!(uri.hash, None);
292    }
293
294    #[test]
295    fn test_parse_taste_uri() {
296        let uri = parse_uri("cmn://alice.dev/taste/b3.7tRkW2xPqL9nH").unwrap();
297        assert_eq!(uri.domain, "alice.dev");
298        assert_eq!(uri.hash, Some("b3.7tRkW2xPqL9nH".to_string()));
299        assert!(uri.is_taste());
300        assert_eq!(uri.kind, CmnUriKind::Taste);
301    }
302
303    #[test]
304    fn test_parse_mycelium_uri() {
305        let uri = parse_uri("cmn://example.com/mycelium/b3.7tRkW2xPqL9nH").unwrap();
306        assert_eq!(uri.domain, "example.com");
307        assert_eq!(uri.hash, Some("b3.7tRkW2xPqL9nH".to_string()));
308        assert!(uri.is_mycelium());
309        assert_eq!(uri.kind, CmnUriKind::Mycelium);
310    }
311
312    #[test]
313    fn test_parse_mycelium_uri_missing_hash() {
314        assert!(parse_uri("cmn://example.com/mycelium/").is_err());
315        assert!(parse_uri("cmn://example.com/mycelium").is_err());
316    }
317
318    #[test]
319    fn test_parse_taste_uri_missing_hash() {
320        assert!(parse_uri("cmn://alice.dev/taste/").is_err());
321        assert!(parse_uri("cmn://alice.dev/taste").is_err());
322    }
323
324    #[test]
325    fn test_parse_invalid_uri() {
326        assert!(parse_uri("http://example.com/spore").is_err());
327        assert!(parse_uri("cmn://").is_err());
328        assert!(parse_uri("cmn:///spore").is_err());
329        assert!(parse_uri("cmn://example.com/not-a-hash").is_err());
330    }
331
332    #[test]
333    fn test_build_spore_uri() {
334        let uri = build_spore_uri("example.com", "b3.3yMR7vZQ9hL");
335        assert_eq!(uri, "cmn://example.com/b3.3yMR7vZQ9hL");
336    }
337
338    #[test]
339    fn test_build_domain_uri() {
340        let uri = build_domain_uri("example.com");
341        assert_eq!(uri, "cmn://example.com");
342    }
343
344    #[test]
345    fn test_build_taste_uri() {
346        let uri = build_taste_uri("alice.dev", "b3.7tRkW2xPqL9nH");
347        assert_eq!(uri, "cmn://alice.dev/taste/b3.7tRkW2xPqL9nH");
348    }
349
350    #[test]
351    fn test_build_mycelium_uri() {
352        let uri = build_mycelium_uri("example.com", "b3.7tRkW2xPqL9nH");
353        assert_eq!(uri, "cmn://example.com/mycelium/b3.7tRkW2xPqL9nH");
354    }
355
356    #[test]
357    fn test_normalize_taste_target_uri_domain() {
358        let uri = normalize_taste_target_uri("cmn://example.com/").unwrap();
359        assert_eq!(uri, "cmn://example.com");
360    }
361
362    #[test]
363    fn test_normalize_taste_target_uri_spore() {
364        let uri = normalize_taste_target_uri("cmn://example.com/b3.3yMR7vZQ9hL").unwrap();
365        assert_eq!(uri, "cmn://example.com/b3.3yMR7vZQ9hL");
366    }
367
368    #[test]
369    fn test_normalize_taste_target_uri_mycelium() {
370        let uri = normalize_taste_target_uri("cmn://example.com/mycelium/b3.3yMR7vZQ9hL").unwrap();
371        assert_eq!(uri, "cmn://example.com/mycelium/b3.3yMR7vZQ9hL");
372    }
373
374    #[test]
375    fn test_normalize_taste_target_uri_rejects_taste_uri() {
376        assert!(normalize_taste_target_uri("cmn://example.com/taste/b3.3yMR7vZQ9hL").is_err());
377    }
378}