1use anyhow::anyhow;
2use serde::{Deserialize, Serialize};
3
4pub const CMN_SCHEMA: &str = "https://cmn.dev/schemas/v1/cmn.json";
5
6#[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#[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#[derive(Serialize, Deserialize, Debug, Clone)]
30pub struct PreviousKey {
31 pub key: String,
32 pub retired_at_epoch_ms: u64,
33}
34
35#[derive(Serialize, Deserialize, Debug, Clone)]
37pub struct CmnEndpoint {
38 #[serde(rename = "type")]
39 pub kind: String,
40 pub url: String,
41 #[serde(default, skip_serializing_if = "String::is_empty")]
43 pub hash: String,
44 #[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 #[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 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 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}