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 #[serde(default)]
25 pub previous_keys: Vec<PreviousKey>,
26 #[serde(default)]
27 pub endpoints: Vec<CmnEndpoint>,
28}
29
30#[derive(Serialize, Deserialize, Debug, Clone)]
32pub struct PreviousKey {
33 pub key: String,
34 pub retired_at_epoch_ms: u64,
35}
36
37#[derive(Serialize, Deserialize, Debug, Clone)]
39pub struct CmnEndpoint {
40 #[serde(rename = "type")]
41 pub kind: String,
42 pub url: String,
43 #[serde(default, skip_serializing_if = "String::is_empty")]
45 pub hash: String,
46 #[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 #[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 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 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}