1use anyhow::{anyhow, Result};
7use serde_json::Value;
8
9use crate::model::{CMN_SCHEMA, MYCELIUM_SCHEMA, SPORE_CORE_SCHEMA, SPORE_SCHEMA, TASTE_SCHEMA};
10
11pub const SPORE_SCHEMA_JSON: &str = include_str!("spore.json");
13pub const MYCELIUM_SCHEMA_JSON: &str = include_str!("mycelium.json");
14pub const CMN_SCHEMA_JSON: &str = include_str!("cmn.json");
15pub const SPORE_CORE_SCHEMA_JSON: &str = include_str!("spore-core.json");
16pub const TASTE_SCHEMA_JSON: &str = include_str!("taste.json");
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SchemaType {
21 Spore,
22 SporeCore,
23 Mycelium,
24 Cmn,
25 Taste,
26}
27
28#[derive(Clone, Copy)]
29struct SchemaDescriptor {
30 schema_type: SchemaType,
31 schema_json: &'static str,
32}
33
34#[derive(Debug)]
36pub struct ValidationError {
37 pub message: String,
38 pub path: String,
39}
40
41impl std::fmt::Display for ValidationError {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 write!(f, "{} at {}", self.message, self.path)
44 }
45}
46
47fn extract_schema_url(doc: &Value) -> Result<&str> {
48 doc.get("$schema")
49 .and_then(Value::as_str)
50 .ok_or_else(|| anyhow!("Missing $schema field"))
51}
52
53fn describe_schema(schema_url: &str) -> Option<SchemaDescriptor> {
54 match schema_url {
55 s if s == SPORE_SCHEMA || s.ends_with("/spore.json") => Some(SchemaDescriptor {
56 schema_type: SchemaType::Spore,
57 schema_json: SPORE_SCHEMA_JSON,
58 }),
59 s if s == SPORE_CORE_SCHEMA || s.ends_with("/spore-core.json") => Some(SchemaDescriptor {
60 schema_type: SchemaType::SporeCore,
61 schema_json: SPORE_CORE_SCHEMA_JSON,
62 }),
63 s if s == MYCELIUM_SCHEMA || s.ends_with("/mycelium.json") => Some(SchemaDescriptor {
64 schema_type: SchemaType::Mycelium,
65 schema_json: MYCELIUM_SCHEMA_JSON,
66 }),
67 s if s == CMN_SCHEMA || s.ends_with("/cmn.json") => Some(SchemaDescriptor {
68 schema_type: SchemaType::Cmn,
69 schema_json: CMN_SCHEMA_JSON,
70 }),
71 s if s == TASTE_SCHEMA || s.ends_with("/taste.json") => Some(SchemaDescriptor {
72 schema_type: SchemaType::Taste,
73 schema_json: TASTE_SCHEMA_JSON,
74 }),
75 _ => None,
76 }
77}
78
79pub fn get_schema(schema_url: &str) -> Option<&'static str> {
92 describe_schema(schema_url).map(|descriptor| descriptor.schema_json)
93}
94
95pub fn detect_schema_type(doc: &Value) -> Result<SchemaType> {
111 let schema_url = extract_schema_url(doc)?;
112 describe_schema(schema_url)
113 .map(|descriptor| descriptor.schema_type)
114 .ok_or_else(|| anyhow!("Unknown schema: {}", schema_url))
115}
116
117pub fn validate(doc: &Value) -> Result<SchemaType> {
154 let schema_url = extract_schema_url(doc)?;
156 let descriptor =
157 describe_schema(schema_url).ok_or_else(|| anyhow!("Unknown schema: {}", schema_url))?;
158
159 let schema: Value = serde_json::from_str(descriptor.schema_json)
161 .map_err(|e| anyhow!("Failed to parse schema: {}", e))?;
162
163 let compiled = jsonschema::validator_for(&schema)
165 .map_err(|e| anyhow!("Failed to compile schema: {}", e))?;
166
167 if let Err(e) = compiled.validate(doc) {
169 let errors: Vec<String> = compiled
170 .iter_errors(doc)
171 .map(|e| format!("{} at {}", e, e.instance_path()))
172 .collect();
173 if errors.is_empty() {
174 return Err(anyhow!("Validation failed: {}", e));
175 }
176 return Err(anyhow!("Validation failed: {}", errors.join("; ")));
177 }
178
179 Ok(descriptor.schema_type)
180}
181
182pub fn validate_detailed(doc: &Value) -> Result<(SchemaType, Vec<ValidationError>)> {
187 let schema_url = extract_schema_url(doc)?;
189 let descriptor =
190 describe_schema(schema_url).ok_or_else(|| anyhow!("Unknown schema: {}", schema_url))?;
191
192 let schema: Value = serde_json::from_str(descriptor.schema_json)
194 .map_err(|e| anyhow!("Failed to parse schema: {}", e))?;
195
196 let compiled = jsonschema::validator_for(&schema)
198 .map_err(|e| anyhow!("Failed to compile schema: {}", e))?;
199
200 let errors: Vec<ValidationError> = compiled
202 .iter_errors(doc)
203 .map(|e| ValidationError {
204 message: e.to_string(),
205 path: e.instance_path().to_string(),
206 })
207 .collect();
208
209 Ok((descriptor.schema_type, errors))
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use serde_json::{json, Value};
216
217 #[test]
218 fn test_get_schema_spore() {
219 let schema = get_schema(SPORE_SCHEMA);
220 assert!(schema.is_some());
221 assert!(schema.map(|s| s.contains("spore_core")).unwrap_or(false));
222 }
223
224 #[test]
225 fn test_get_schema_mycelium() {
226 let schema = get_schema(MYCELIUM_SCHEMA);
227 assert!(schema.is_some());
228 assert!(schema.map(|s| s.contains("mycelium_core")).unwrap_or(false));
229 }
230
231 #[test]
232 fn test_get_schema_cmn() {
233 let schema = get_schema(CMN_SCHEMA);
234 assert!(schema.is_some());
235 assert!(schema.map(|s| s.contains("endpoints")).unwrap_or(false));
236 }
237
238 #[test]
239 fn test_get_schema_unknown() {
240 let schema = get_schema("https://example.com/unknown.json");
241 assert!(schema.is_none());
242 }
243
244 #[test]
245 fn test_detect_schema_type_spore() {
246 let doc = json!({
247 "$schema": SPORE_SCHEMA
248 });
249 assert_eq!(detect_schema_type(&doc).ok(), Some(SchemaType::Spore));
250 }
251
252 #[test]
253 fn test_detect_schema_type_mycelium() {
254 let doc = json!({
255 "$schema": MYCELIUM_SCHEMA
256 });
257 assert_eq!(detect_schema_type(&doc).ok(), Some(SchemaType::Mycelium));
258 }
259
260 #[test]
261 fn test_detect_schema_type_cmn() {
262 let doc = json!({
263 "$schema": CMN_SCHEMA
264 });
265 assert_eq!(detect_schema_type(&doc).ok(), Some(SchemaType::Cmn));
266 }
267
268 #[test]
269 fn test_validate_valid_spore() {
270 let doc = json!({
271 "$schema": SPORE_SCHEMA,
272 "capsule": {
273 "uri": "cmn://example.com/b3.3yMR7vZQ9hL",
274 "core": {
275 "id": "test-spore",
276 "name": "test-spore",
277 "domain": "example.com",
278 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
279 "synopsis": "A test spore",
280 "intent": ["Testing"],
281 "license": "MIT",
282 "mutations": [],
283 "bonds": [],
284 "size_bytes": 0,
285 "tree": { "algorithm": "blob_tree_blake3_nfc", "exclude_names": [], "follow_rules": [] },
286 "updated_at_epoch_ms": 1700000000000_u64
287 },
288 "core_signature": "ed25519.5XmkQ9vZP8nL",
289 "dist": [{"type":"archive"}]
290 },
291 "capsule_signature": "ed25519.3yMR7vZQ9hL"
292 });
293
294 let result = validate(&doc);
295 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
296 assert_eq!(result.ok(), Some(SchemaType::Spore));
297 }
298
299 #[test]
300 fn test_validate_valid_mycelium() {
301 let doc = json!({
302 "$schema": MYCELIUM_SCHEMA,
303 "capsule": {
304 "uri": "cmn://example.com/mycelium/b3.3yMR7vZQ9hL",
305 "core": {
306 "name": "Test User",
307 "domain": "example.com",
308 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
309 "synopsis": "A test user",
310 "updated_at_epoch_ms": 1234567890000_u64,
311 "spores": [],
312 "nutrients": [],
313 "tastes": []
314 },
315 "core_signature": "ed25519.5XmkQ9vZP8nL"
316 },
317 "capsule_signature": "ed25519.3yMR7vZQ9hL"
318 });
319
320 let result = validate(&doc);
321 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
322 assert_eq!(result.ok(), Some(SchemaType::Mycelium));
323 }
324
325 fn valid_cmn_doc() -> Value {
326 json!({
327 "$schema": CMN_SCHEMA,
328 "capsules": [{
329 "uri": "cmn://example.com",
330 "serial": 1,
331 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
332 "history": [],
333 "endpoints": [
334 {
335 "type": "mycelium",
336 "url": "https://example.com/cmn/mycelium/{hash}.json",
337 "hash": "b3.3yMR7vZQ9hL"
338 },
339 {
340 "type": "spore",
341 "url": "https://example.com/cmn/spore/{hash}.json"
342 },
343 {
344 "type": "archive",
345 "url": "https://example.com/cmn/archive/{hash}.tar.zst",
346 "format": "tar+zstd"
347 }
348 ]
349 }],
350 "capsule_signature": "ed25519.3yMR7vZQ9hL"
351 })
352 }
353
354 #[test]
355 fn test_validate_valid_cmn() {
356 let doc = valid_cmn_doc();
357
358 let result = validate(&doc);
359 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
360 assert_eq!(result.ok(), Some(SchemaType::Cmn));
361 }
362
363 #[test]
364 fn test_validate_valid_cmn_history_entry() {
365 let mut doc = valid_cmn_doc();
366 doc["capsules"][0]["serial"] = json!(2);
367 doc["capsules"][0]["history"] = json!([{
368 "key": "ed25519.CJfRUQxyonG6B5mnztsNUqxknbFT89DJdrdrzV9F96mU",
369 "status": "retired",
370 "retired_at_epoch_ms": 1710000000000_u64,
371 "replaced_by": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
372 "rotation_signature": "ed25519.3yMR7vZQ9hL"
373 }]);
374
375 let result = validate(&doc);
376 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
377 }
378
379 #[test]
380 fn test_validate_valid_cmn_taste_only() {
381 let doc = json!({
382 "$schema": CMN_SCHEMA,
383 "capsules": [{
384 "uri": "cmn://taster.example.com",
385 "serial": 1,
386 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
387 "history": [],
388 "endpoints": [{
389 "type": "taste",
390 "url": "https://taster.example.com/cmn/taste/{hash}.json"
391 }]
392 }],
393 "capsule_signature": "ed25519.3yMR7vZQ9hL"
394 });
395
396 let result = validate(&doc);
397 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
398 }
399
400 #[test]
401 fn test_validate_valid_cmn_no_endpoints() {
402 let doc = json!({
403 "$schema": CMN_SCHEMA,
404 "capsules": [{
405 "uri": "cmn://minimal.example.com",
406 "serial": 1,
407 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
408 "history": [],
409 "endpoints": []
410 }],
411 "capsule_signature": "ed25519.3yMR7vZQ9hL"
412 });
413
414 let result = validate(&doc);
415 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
416 }
417
418 #[test]
419 fn test_validate_cmn_missing_key() {
420 let mut doc = valid_cmn_doc();
421 let Some(capsule) = doc["capsules"][0].as_object_mut() else {
422 assert!(
423 doc["capsules"][0].is_object(),
424 "valid_cmn_doc should contain an object capsule"
425 );
426 return;
427 };
428 capsule.remove("key");
429
430 let result = validate(&doc);
431 assert!(result.is_err(), "Expected validation to fail without key");
432 }
433
434 #[test]
435 fn test_validate_cmn_missing_serial() {
436 let mut doc = valid_cmn_doc();
437 let Some(capsule) = doc["capsules"][0].as_object_mut() else {
438 assert!(
439 doc["capsules"][0].is_object(),
440 "valid_cmn_doc should contain an object capsule"
441 );
442 return;
443 };
444 capsule.remove("serial");
445
446 let result = validate(&doc);
447 assert!(
448 result.is_err(),
449 "Expected validation to fail without serial"
450 );
451 }
452
453 #[test]
454 fn test_validate_cmn_rejects_removed_protocol_versions() {
455 let mut doc = valid_cmn_doc();
456 let Some(doc_object) = doc.as_object_mut() else {
457 assert!(doc.is_object(), "valid_cmn_doc should be an object");
458 return;
459 };
460 doc_object.insert("protocol_versions".to_string(), json!(["v1"]));
461
462 let result = validate(&doc);
463 assert!(
464 result.is_err(),
465 "Expected validation to reject top-level protocol_versions"
466 );
467 }
468
469 #[test]
470 fn test_validate_cmn_rejects_removed_endpoint_protocol_version() {
471 let mut doc = valid_cmn_doc();
472 doc["capsules"][0]["endpoints"][0]["protocol_version"] = json!("v1");
473
474 let result = validate(&doc);
475 assert!(
476 result.is_err(),
477 "Expected validation to reject endpoint protocol_version"
478 );
479 }
480
481 #[test]
482 fn test_validate_cmn_rejects_previous_keys() {
483 let mut doc = valid_cmn_doc();
484 doc["capsules"][0]["previous_keys"] = json!([]);
485
486 let result = validate(&doc);
487 assert!(
488 result.is_err(),
489 "Expected validation to reject previous_keys"
490 );
491 }
492
493 #[test]
494 fn test_validate_cmn_retired_history_requires_rotation_signature() {
495 let mut doc = valid_cmn_doc();
496 doc["capsules"][0]["history"] = json!([{
497 "key": "ed25519.CJfRUQxyonG6B5mnztsNUqxknbFT89DJdrdrzV9F96mU",
498 "status": "retired",
499 "retired_at_epoch_ms": 1710000000000_u64,
500 "replaced_by": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4"
501 }]);
502
503 let result = validate(&doc);
504 assert!(
505 result.is_err(),
506 "Expected retired history to require rotation_signature"
507 );
508 }
509
510 #[test]
511 fn test_validate_missing_schema() {
512 let doc = json!({
513 "capsule": {}
514 });
515
516 let result = validate(&doc);
517 assert!(result.is_err());
518 assert!(result
519 .err()
520 .map(|e| e.to_string().contains("Missing $schema"))
521 .unwrap_or(false));
522 }
523
524 #[test]
525 fn test_validate_invalid_spore_missing_required() {
526 let doc = json!({
527 "$schema": SPORE_SCHEMA,
528 "capsule": {
529 "uri": "cmn://example.com/b3.3yMR7vZQ9hL",
530 "core": {
531 "name": "test"
532 },
534 "core_signature": "ed25519.5XmkQ9vZP8nL",
535 "dist": [{"type":"archive","filename":"test.tar.zst"}]
536 },
537 "capsule_signature": "ed25519.3yMR7vZQ9hL"
538 });
539
540 let result = validate(&doc);
541 assert!(result.is_err());
542 }
543
544 #[test]
545 fn test_validate_detailed_returns_all_errors() {
546 let doc = json!({
547 "$schema": SPORE_SCHEMA,
548 "capsule": {
549 "uri": "invalid-uri", "core": {
551 "name": "" },
554 "core_signature": "invalid", "dist": [{"type":"archive","filename":"test.tar.zst"}]
556 },
557 "capsule_signature": "invalid" });
559
560 let result = validate_detailed(&doc);
561 assert!(result.is_ok());
562 let (_, errors) = result.ok().unwrap_or((SchemaType::Spore, vec![]));
563 assert!(!errors.is_empty(), "Expected validation errors");
564 }
565
566 #[test]
567 fn test_get_schema_spore_core() {
568 let schema = get_schema(SPORE_CORE_SCHEMA);
569 assert!(schema.is_some());
570 assert!(schema.map(|s| s.contains("bonds")).unwrap_or(false));
571 }
572
573 #[test]
574 fn test_detect_schema_type_spore_core() {
575 let doc = json!({
576 "$schema": SPORE_CORE_SCHEMA
577 });
578 assert_eq!(detect_schema_type(&doc).ok(), Some(SchemaType::SporeCore));
579 }
580
581 #[test]
582 fn test_validate_valid_spore_core() {
583 let doc = json!({
585 "$schema": SPORE_CORE_SCHEMA,
586 "id": "my-tool",
587 "name": "my-tool",
588 "domain": "example.com",
589 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
590 "synopsis": "A useful tool",
591 "intent": ["v1.0.0"],
592 "license": "MIT",
593 "mutations": [],
594 "bonds": [],
595 "tree": { "algorithm": "blob_tree_blake3_nfc", "exclude_names": [], "follow_rules": [] }
596 });
597
598 let result = validate(&doc);
599 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
600 assert_eq!(result.ok(), Some(SchemaType::SporeCore));
601 }
602
603 #[test]
604 fn test_validate_spore_core_with_optional_fields() {
605 let doc = json!({
606 "$schema": SPORE_CORE_SCHEMA,
607 "id": "my-tool",
608 "name": "my-tool",
609 "domain": "example.com",
610 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
611 "synopsis": "A useful tool",
612 "intent": ["v1.0.0"],
613 "license": "MIT",
614 "mutations": [],
615 "bonds": [
616 { "uri": "cmn://other.com/b3.3yMR7vZQ9hL", "relation": "depends_on" }
617 ],
618 "tree": {
619 "algorithm": "blob_tree_blake3_nfc",
620 "exclude_names": [".git"],
621 "follow_rules": [".gitignore"]
622 }
623 });
624
625 let result = validate(&doc);
626 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
627 assert_eq!(result.ok(), Some(SchemaType::SporeCore));
628 }
629
630 #[test]
631 fn test_get_schema_taste() {
632 let schema = get_schema(TASTE_SCHEMA);
633 assert!(schema.is_some());
634 assert!(schema.map(|s| s.contains("taste_core")).unwrap_or(false));
635 }
636
637 #[test]
638 fn test_detect_schema_type_taste() {
639 let doc = json!({
640 "$schema": TASTE_SCHEMA
641 });
642 assert_eq!(detect_schema_type(&doc).ok(), Some(SchemaType::Taste));
643 }
644
645 #[test]
646 fn test_validate_valid_taste() {
647 let doc = json!({
648 "$schema": TASTE_SCHEMA,
649 "capsule": {
650 "uri": "cmn://reviewer.com/taste/b3.7tRkW2xPqL9nH",
651 "core": {
652 "target_uri": "cmn://example.com/b3.3yMR7vZQ9hL",
653 "domain": "reviewer.com",
654 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
655 "verdict": "safe",
656 "tasted_at_epoch_ms": 1234567890000_u64
657 },
658 "core_signature": "ed25519.5XmkQ9vZP8nL"
659 },
660 "capsule_signature": "ed25519.3yMR7vZQ9hL"
661 });
662
663 let result = validate(&doc);
664 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
665 assert_eq!(result.ok(), Some(SchemaType::Taste));
666 }
667
668 #[test]
669 fn test_validate_taste_invalid_verdict() {
670 let doc = json!({
671 "$schema": TASTE_SCHEMA,
672 "capsule": {
673 "uri": "cmn://reviewer.com/taste/b3.7tRkW2xPqL9nH",
674 "core": {
675 "target_uri": "cmn://example.com/b3.3yMR7vZQ9hL",
676 "domain": "reviewer.com",
677 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
678 "verdict": "unknown_taste",
679 "tasted_at_epoch_ms": 1234567890000_u64
680 },
681 "core_signature": "ed25519.5XmkQ9vZP8nL"
682 },
683 "capsule_signature": "ed25519.3yMR7vZQ9hL"
684 });
685
686 let result = validate(&doc);
687 assert!(
688 result.is_err(),
689 "Expected validation to fail with invalid taste"
690 );
691 }
692
693 #[test]
694 fn test_validate_taste_mycelium_target_uri() {
695 let doc = json!({
696 "$schema": TASTE_SCHEMA,
697 "capsule": {
698 "uri": "cmn://reviewer.com/taste/b3.7tRkW2xPqL9nH",
699 "core": {
700 "target_uri": "cmn://example.com/mycelium/b3.3yMR7vZQ9hL",
701 "domain": "reviewer.com",
702 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
703 "verdict": "safe",
704 "tasted_at_epoch_ms": 1234567890000_u64
705 },
706 "core_signature": "ed25519.5XmkQ9vZP8nL"
707 },
708 "capsule_signature": "ed25519.3yMR7vZQ9hL"
709 });
710
711 let result = validate(&doc);
712 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
713 }
714
715 #[test]
716 fn test_validate_taste_rejects_taste_target_uri() {
717 let doc = json!({
718 "$schema": TASTE_SCHEMA,
719 "capsule": {
720 "uri": "cmn://reviewer.com/taste/b3.7tRkW2xPqL9nH",
721 "core": {
722 "target_uri": "cmn://someone.dev/taste/b3.3yMR7vZQ9hL",
723 "domain": "reviewer.com",
724 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
725 "verdict": "safe",
726 "tasted_at_epoch_ms": 1234567890000_u64
727 },
728 "core_signature": "ed25519.5XmkQ9vZP8nL"
729 },
730 "capsule_signature": "ed25519.3yMR7vZQ9hL"
731 });
732
733 let result = validate(&doc);
734 assert!(result.is_err(), "Expected taste target_uri to be rejected");
735 }
736
737 #[test]
738 fn test_validate_invalid_spore_core_missing_required() {
739 let doc = json!({
740 "$schema": SPORE_CORE_SCHEMA,
741 "name": "my-tool"
742 });
744
745 let result = validate(&doc);
746 assert!(result.is_err());
747 }
748}