Skip to main content

substrate/model/
cmn.rs

1use anyhow::anyhow;
2use serde::{Deserialize, Serialize};
3
4pub const CMN_SCHEMA: &str = "https://cmn.dev/schemas/v1/cmn.json";
5
6/// CMN Entry - the cmn.json file at /.well-known/cmn.json
7///
8/// Contains an array of capsules, each with a URI, public key, and typed
9/// endpoints for resolving all resource types.
10#[derive(Serialize, Deserialize, Debug, Clone)]
11pub struct CmnEntry {
12    #[serde(rename = "$schema")]
13    pub schema: String,
14    pub protocol_versions: Vec<String>,
15    pub capsules: Vec<CmnCapsuleEntry>,
16    pub capsule_signature: String,
17}
18
19/// A single capsule entry in cmn.json
20#[derive(Serialize, Deserialize, Debug, Clone)]
21pub struct CmnCapsuleEntry {
22    pub uri: String,
23    pub key: String,
24    #[serde(default)]
25    pub previous_keys: Vec<PreviousKey>,
26    #[serde(default)]
27    pub endpoints: Vec<CmnEndpoint>,
28}
29
30/// A retired public key, kept for verifying historical content
31#[derive(Serialize, Deserialize, Debug, Clone)]
32pub struct PreviousKey {
33    pub key: String,
34    pub retired_at_epoch_ms: u64,
35}
36
37/// A single typed endpoint entry in cmn.json.
38#[derive(Serialize, Deserialize, Debug, Clone)]
39pub struct CmnEndpoint {
40    #[serde(rename = "type")]
41    pub kind: String,
42    pub url: String,
43    /// Primary mycelium content hash (authoritative metadata and featured spores).
44    #[serde(default, skip_serializing_if = "String::is_empty")]
45    pub hash: String,
46    /// Optional overflow shard hashes for large domains (spore lists merged, metadata ignored).
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub hashes: Vec<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub format: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub delta_url: Option<String>,
53    /// Protocol version this endpoint serves (e.g. "v1"). Defaults to "v1" when absent.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub protocol_version: Option<String>,
56}
57
58impl CmnCapsuleEntry {
59    pub fn confirms_key(&self, key: &str) -> bool {
60        self.key == key
61            || self
62                .previous_keys
63                .iter()
64                .any(|previous| previous.key == key)
65    }
66
67    pub fn find_endpoint(&self, kind: &str) -> Option<&CmnEndpoint> {
68        self.endpoints.iter().find(|endpoint| endpoint.kind == kind)
69    }
70
71    pub fn find_endpoints(&self, kind: &str) -> Vec<&CmnEndpoint> {
72        self.endpoints
73            .iter()
74            .filter(|endpoint| endpoint.kind == kind)
75            .collect()
76    }
77
78    pub fn find_archive_endpoint(&self, format: Option<&str>) -> Option<&CmnEndpoint> {
79        match format {
80            Some(expected) => self.endpoints.iter().find(|endpoint| {
81                endpoint.kind == "archive" && endpoint.format.as_deref() == Some(expected)
82            }),
83            None => self.find_endpoint("archive"),
84        }
85    }
86
87    /// Primary mycelium content hash (authoritative metadata).
88    pub fn mycelium_hash(&self) -> Option<&str> {
89        self.find_endpoint("mycelium")
90            .map(|endpoint| endpoint.hash.as_str())
91            .filter(|h| !h.is_empty())
92    }
93
94    /// Overflow shard hashes for large domains (spore lists only, metadata ignored).
95    pub fn mycelium_hashes(&self) -> &[String] {
96        self.find_endpoint("mycelium")
97            .map(|endpoint| endpoint.hashes.as_slice())
98            .unwrap_or(&[])
99    }
100
101    fn require_endpoint(&self, kind: &str) -> anyhow::Result<&CmnEndpoint> {
102        self.find_endpoint(kind)
103            .ok_or_else(|| anyhow!("No '{}' endpoint configured", kind))
104    }
105
106    fn require_archive_endpoint(&self, format: Option<&str>) -> anyhow::Result<&CmnEndpoint> {
107        match format {
108            Some(expected) => self
109                .find_archive_endpoint(Some(expected))
110                .ok_or_else(|| anyhow!("No archive endpoint configured for format '{}'", expected)),
111            None => self
112                .find_archive_endpoint(None)
113                .ok_or_else(|| anyhow!("No 'archive' endpoint configured")),
114        }
115    }
116
117    pub fn mycelium_url(&self, hash: &str) -> anyhow::Result<String> {
118        self.require_endpoint("mycelium")?.resolve_url(hash)
119    }
120
121    pub fn spore_url(&self, hash: &str) -> anyhow::Result<String> {
122        self.require_endpoint("spore")?.resolve_url(hash)
123    }
124
125    pub fn archive_url(&self, hash: &str) -> anyhow::Result<String> {
126        self.require_archive_endpoint(None)?.resolve_url(hash)
127    }
128
129    pub fn archive_url_for_format(&self, hash: &str, format: &str) -> anyhow::Result<String> {
130        self.require_archive_endpoint(Some(format))?
131            .resolve_url(hash)
132    }
133
134    pub fn archive_delta_url(
135        &self,
136        hash: &str,
137        old_hash: &str,
138        format: Option<&str>,
139    ) -> anyhow::Result<Option<String>> {
140        self.require_archive_endpoint(format)?
141            .resolve_delta_url(hash, old_hash)
142    }
143
144    pub fn taste_url(&self, hash: &str) -> anyhow::Result<String> {
145        self.require_endpoint("taste")?.resolve_url(hash)
146    }
147}
148
149impl CmnEntry {
150    pub fn new(capsules: Vec<CmnCapsuleEntry>) -> Self {
151        Self {
152            schema: CMN_SCHEMA.to_string(),
153            protocol_versions: vec!["v1".to_string()],
154            capsules,
155            capsule_signature: String::new(),
156        }
157    }
158
159    pub fn primary_capsule(&self) -> anyhow::Result<&CmnCapsuleEntry> {
160        self.capsules
161            .first()
162            .ok_or_else(|| anyhow!("Invalid cmn.json: capsules must contain at least one entry"))
163    }
164
165    pub fn uri(&self) -> anyhow::Result<&str> {
166        self.primary_capsule().map(|capsule| capsule.uri.as_str())
167    }
168
169    pub fn primary_key(&self) -> anyhow::Result<&str> {
170        self.primary_capsule().map(|capsule| capsule.key.as_str())
171    }
172
173    pub fn primary_confirms_key(&self, key: &str) -> anyhow::Result<bool> {
174        self.primary_capsule()
175            .map(|capsule| capsule.confirms_key(key))
176    }
177
178    pub fn effective_protocol_versions(&self) -> Vec<&str> {
179        if self.protocol_versions.is_empty() {
180            vec!["v1"]
181        } else {
182            self.protocol_versions.iter().map(String::as_str).collect()
183        }
184    }
185
186    pub fn supports_protocol_version(&self, version: &str) -> bool {
187        if self.protocol_versions.is_empty() {
188            version == "v1"
189        } else {
190            self.protocol_versions
191                .iter()
192                .any(|candidate| candidate == version)
193        }
194    }
195
196    pub fn verify_signature(&self, host_key: &str) -> anyhow::Result<()> {
197        crate::verify_json_signature(&self.capsules, &self.capsule_signature, host_key)
198    }
199}
200
201impl CmnEndpoint {
202    pub fn resolve_url(&self, hash: &str) -> anyhow::Result<String> {
203        let url = self.url.replace("{hash}", hash);
204        crate::uri::normalize_and_validate_url(&url)
205            .map_err(|e| anyhow!("Invalid {} endpoint: {}", self.kind, e))
206    }
207
208    pub fn resolve_delta_url(&self, hash: &str, old_hash: &str) -> anyhow::Result<Option<String>> {
209        let Some(template) = &self.delta_url else {
210            return Ok(None);
211        };
212
213        let url = template
214            .replace("{hash}", hash)
215            .replace("{old_hash}", old_hash);
216        crate::uri::normalize_and_validate_url(&url)
217            .map_err(|e| anyhow!("Invalid {} delta endpoint: {}", self.kind, e))
218            .map(Some)
219    }
220}
221
222#[cfg(test)]
223#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
224mod tests {
225
226    use super::*;
227    fn sample_cmn_endpoints() -> Vec<CmnEndpoint> {
228        vec![
229            CmnEndpoint {
230                kind: "mycelium".to_string(),
231                url: "https://example.com/cmn/mycelium/{hash}.json".to_string(),
232                hash: "b3.abc123def456".to_string(),
233                hashes: vec![],
234                format: None,
235                delta_url: None,
236                protocol_version: None,
237            },
238            CmnEndpoint {
239                kind: "spore".to_string(),
240                url: "https://example.com/cmn/spore/{hash}.json".to_string(),
241                hash: String::new(),
242                hashes: vec![],
243                format: None,
244                delta_url: None,
245                protocol_version: None,
246            },
247            CmnEndpoint {
248                kind: "archive".to_string(),
249                url: "https://example.com/cmn/archive/{hash}.tar.zst".to_string(),
250                hash: String::new(),
251                hashes: vec![],
252                format: Some("tar+zstd".to_string()),
253                delta_url: Some(
254                    "https://example.com/cmn/archive/{hash}.from.{old_hash}.tar.zst".to_string(),
255                ),
256                protocol_version: None,
257            },
258        ]
259    }
260
261    #[test]
262    fn test_cmn_entry_serialization() {
263        let entry = CmnEntry::new(vec![CmnCapsuleEntry {
264            uri: "cmn://example.com".to_string(),
265            key: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
266            previous_keys: vec![],
267            endpoints: sample_cmn_endpoints(),
268        }]);
269
270        let json = serde_json::to_string(&entry).unwrap_or_default();
271        assert!(json.contains("\"$schema\""));
272        assert!(json.contains(CMN_SCHEMA));
273        assert!(json.contains("b3.abc123def456"));
274        assert!(json.contains("\"endpoints\""));
275        assert!(json.contains("\"key\""));
276
277        let parsed: CmnEntry = serde_json::from_str(&json).unwrap();
278        let capsule = parsed.primary_capsule().unwrap();
279        assert_eq!(parsed.schema, CMN_SCHEMA);
280        assert_eq!(capsule.mycelium_hash(), Some("b3.abc123def456"));
281        assert_eq!(
282            capsule.key,
283            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
284        );
285        assert_eq!(parsed.effective_protocol_versions(), vec!["v1"]);
286    }
287
288    #[test]
289    fn test_capsule_build_mycelium_url() {
290        let capsule = CmnCapsuleEntry {
291            uri: "cmn://example.com".to_string(),
292            key: "host-key".to_string(),
293            previous_keys: vec![],
294            endpoints: sample_cmn_endpoints(),
295        };
296        let url = capsule.mycelium_url("b3.abc123").unwrap();
297        assert_eq!(url, "https://example.com/cmn/mycelium/b3.abc123.json");
298    }
299
300    #[test]
301    fn test_capsule_build_spore_url() {
302        let capsule = CmnCapsuleEntry {
303            uri: "cmn://example.com".to_string(),
304            key: "host-key".to_string(),
305            previous_keys: vec![],
306            endpoints: sample_cmn_endpoints(),
307        };
308        let url = capsule.spore_url("b3.abc123").unwrap();
309        assert_eq!(url, "https://example.com/cmn/spore/b3.abc123.json");
310    }
311
312    #[test]
313    fn test_capsule_build_archive_url() {
314        let capsule = CmnCapsuleEntry {
315            uri: "cmn://example.com".to_string(),
316            key: "host-key".to_string(),
317            previous_keys: vec![],
318            endpoints: sample_cmn_endpoints(),
319        };
320        let url = capsule.archive_url("b3.abc123").unwrap();
321        assert_eq!(url, "https://example.com/cmn/archive/b3.abc123.tar.zst");
322    }
323
324    #[test]
325    fn test_capsule_build_archive_url_for_format() {
326        let capsule = CmnCapsuleEntry {
327            uri: "cmn://example.com".to_string(),
328            key: "host-key".to_string(),
329            previous_keys: vec![],
330            endpoints: sample_cmn_endpoints(),
331        };
332        let url = capsule
333            .archive_url_for_format("b3.abc123", "tar+zstd")
334            .unwrap();
335        assert_eq!(url, "https://example.com/cmn/archive/b3.abc123.tar.zst");
336    }
337
338    #[test]
339    fn test_capsule_build_archive_delta_url() {
340        let capsule = CmnCapsuleEntry {
341            uri: "cmn://example.com".to_string(),
342            key: "host-key".to_string(),
343            previous_keys: vec![],
344            endpoints: sample_cmn_endpoints(),
345        };
346        let url = capsule
347            .archive_delta_url("b3.new", "b3.old", Some("tar+zstd"))
348            .unwrap()
349            .unwrap();
350        assert_eq!(
351            url,
352            "https://example.com/cmn/archive/b3.new.from.b3.old.tar.zst"
353        );
354    }
355
356    #[test]
357    fn test_capsule_build_taste_url() {
358        let mut endpoints = sample_cmn_endpoints();
359        endpoints.push(CmnEndpoint {
360            kind: "taste".to_string(),
361            url: "https://example.com/cmn/taste/{hash}.json".to_string(),
362            hash: String::new(),
363            hashes: vec![],
364            format: None,
365            delta_url: None,
366            protocol_version: None,
367        });
368        let capsule = CmnCapsuleEntry {
369            uri: "cmn://example.com".to_string(),
370            key: "host-key".to_string(),
371            previous_keys: vec![],
372            endpoints,
373        };
374        let url = capsule.taste_url("b3.7tRkW2x").unwrap();
375        assert_eq!(url, "https://example.com/cmn/taste/b3.7tRkW2x.json");
376    }
377
378    #[test]
379    fn test_capsule_build_taste_url_not_configured() {
380        let capsule = CmnCapsuleEntry {
381            uri: "cmn://example.com".to_string(),
382            key: "host-key".to_string(),
383            previous_keys: vec![],
384            endpoints: sample_cmn_endpoints(),
385        };
386        assert!(capsule.taste_url("b3.7tRkW2x").is_err());
387    }
388
389    #[test]
390    fn test_capsule_build_url_rejects_malicious_template() {
391        let capsule = CmnCapsuleEntry {
392            uri: "cmn://example.com".to_string(),
393            key: "host-key".to_string(),
394            previous_keys: vec![],
395            endpoints: vec![
396                CmnEndpoint {
397                    kind: "mycelium".to_string(),
398                    url: "file:///etc/passwd?{hash}".to_string(),
399                    hash: String::new(),
400                    hashes: vec![],
401                    format: None,
402                    delta_url: None,
403                    protocol_version: None,
404                },
405                CmnEndpoint {
406                    kind: "spore".to_string(),
407                    url: "gopher://internal/{hash}".to_string(),
408                    hash: String::new(),
409                    hashes: vec![],
410                    format: None,
411                    delta_url: None,
412                    protocol_version: None,
413                },
414                CmnEndpoint {
415                    kind: "archive".to_string(),
416                    url: "http://localhost:9090/{hash}".to_string(),
417                    hash: String::new(),
418                    hashes: vec![],
419                    format: Some("tar+zstd".to_string()),
420                    delta_url: None,
421                    protocol_version: None,
422                },
423            ],
424        };
425        assert!(capsule.mycelium_url("b3.abc").is_err());
426        assert!(capsule.spore_url("b3.abc").is_err());
427        assert!(capsule.archive_url("b3.abc").is_err());
428    }
429
430    #[test]
431    fn test_capsule_build_url_rejects_ssrf_template() {
432        let capsule = CmnCapsuleEntry {
433            uri: "cmn://example.com".to_string(),
434            key: "host-key".to_string(),
435            previous_keys: vec![],
436            endpoints: vec![
437                CmnEndpoint {
438                    kind: "mycelium".to_string(),
439                    url: "https://10.0.0.1/cmn/{hash}.json".to_string(),
440                    hash: String::new(),
441                    hashes: vec![],
442                    format: None,
443                    delta_url: None,
444                    protocol_version: None,
445                },
446                CmnEndpoint {
447                    kind: "spore".to_string(),
448                    url: "https://192.168.1.1/cmn/{hash}.json".to_string(),
449                    hash: String::new(),
450                    hashes: vec![],
451                    format: None,
452                    delta_url: None,
453                    protocol_version: None,
454                },
455                CmnEndpoint {
456                    kind: "archive".to_string(),
457                    url: "https://169.254.169.254/cmn/{hash}".to_string(),
458                    hash: String::new(),
459                    hashes: vec![],
460                    format: Some("tar+zstd".to_string()),
461                    delta_url: None,
462                    protocol_version: None,
463                },
464            ],
465        };
466        assert!(capsule.mycelium_url("b3.abc").is_err());
467        assert!(capsule.spore_url("b3.abc").is_err());
468        assert!(capsule.archive_url("b3.abc").is_err());
469    }
470
471    #[test]
472    fn test_capsule_confirms_previous_key() {
473        let capsule = CmnCapsuleEntry {
474            uri: "cmn://example.com".to_string(),
475            key: "ed25519.current".to_string(),
476            previous_keys: vec![PreviousKey {
477                key: "ed25519.previous".to_string(),
478                retired_at_epoch_ms: 1710000000000,
479            }],
480            endpoints: vec![],
481        };
482
483        assert!(capsule.confirms_key("ed25519.current"));
484        assert!(capsule.confirms_key("ed25519.previous"));
485        assert!(!capsule.confirms_key("ed25519.other"));
486    }
487
488    #[test]
489    fn test_effective_protocol_versions_default_to_v1() {
490        let entry = CmnEntry::new(vec![CmnCapsuleEntry {
491            uri: "cmn://example.com".to_string(),
492            key: "host-key".to_string(),
493            previous_keys: vec![],
494            endpoints: vec![],
495        }]);
496
497        assert_eq!(entry.effective_protocol_versions(), vec!["v1"]);
498        assert!(entry.supports_protocol_version("v1"));
499        assert!(!entry.supports_protocol_version("v2"));
500    }
501
502    #[test]
503    fn test_effective_protocol_versions_use_advertised_versions() {
504        let mut entry = CmnEntry::new(vec![CmnCapsuleEntry {
505            uri: "cmn://example.com".to_string(),
506            key: "host-key".to_string(),
507            previous_keys: vec![],
508            endpoints: vec![],
509        }]);
510        entry.protocol_versions = vec!["v1".to_string(), "v2".to_string()];
511
512        assert_eq!(entry.effective_protocol_versions(), vec!["v1", "v2"]);
513        assert!(entry.supports_protocol_version("v2"));
514        assert!(!entry.supports_protocol_version("v3"));
515    }
516}