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