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;
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 #[test]
326 fn test_validate_valid_cmn() {
327 let doc = json!({
328 "$schema": CMN_SCHEMA,
329 "protocol_versions": ["v1"],
330 "capsules": [{
331 "uri": "cmn://example.com",
332 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
333 "previous_keys": [],
334 "endpoints": [
335 {
336 "type": "mycelium",
337 "url": "https://example.com/cmn/mycelium/{hash}.json",
338 "hash": "b3.3yMR7vZQ9hL"
339 },
340 {
341 "type": "spore",
342 "url": "https://example.com/cmn/spore/{hash}.json"
343 },
344 {
345 "type": "archive",
346 "url": "https://example.com/cmn/archive/{hash}.tar.zst",
347 "format": "tar+zstd"
348 }
349 ]
350 }],
351 "capsule_signature": "ed25519.3yMR7vZQ9hL"
352 });
353
354 let result = validate(&doc);
355 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
356 assert_eq!(result.ok(), Some(SchemaType::Cmn));
357 }
358
359 #[test]
360 fn test_validate_valid_cmn_taste_only() {
361 let doc = json!({
363 "$schema": CMN_SCHEMA,
364 "protocol_versions": ["v1"],
365 "capsules": [{
366 "uri": "cmn://taster.example.com",
367 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
368 "previous_keys": [],
369 "endpoints": [{
370 "type": "taste",
371 "url": "https://taster.example.com/cmn/taste/{hash}.json"
372 }]
373 }],
374 "capsule_signature": "ed25519.3yMR7vZQ9hL"
375 });
376
377 let result = validate(&doc);
378 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
379 }
380
381 #[test]
382 fn test_validate_valid_cmn_no_endpoints() {
383 let doc = json!({
385 "$schema": CMN_SCHEMA,
386 "protocol_versions": ["v1"],
387 "capsules": [{
388 "uri": "cmn://minimal.example.com",
389 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
390 "previous_keys": [],
391 "endpoints": []
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_cmn_missing_key() {
402 let doc = json!({
403 "$schema": CMN_SCHEMA,
404 "protocol_versions": ["v1"],
405 "capsules": [{
406 "uri": "cmn://example.com",
407 "previous_keys": [],
408 "endpoints": []
409 }],
410 "capsule_signature": "ed25519.3yMR7vZQ9hL"
411 });
412
413 let result = validate(&doc);
414 assert!(result.is_err(), "Expected validation to fail without key");
415 }
416
417 #[test]
418 fn test_validate_missing_schema() {
419 let doc = json!({
420 "capsule": {}
421 });
422
423 let result = validate(&doc);
424 assert!(result.is_err());
425 assert!(result
426 .err()
427 .map(|e| e.to_string().contains("Missing $schema"))
428 .unwrap_or(false));
429 }
430
431 #[test]
432 fn test_validate_invalid_spore_missing_required() {
433 let doc = json!({
434 "$schema": SPORE_SCHEMA,
435 "capsule": {
436 "uri": "cmn://example.com/b3.3yMR7vZQ9hL",
437 "core": {
438 "name": "test"
439 },
441 "core_signature": "ed25519.5XmkQ9vZP8nL",
442 "dist": [{"type":"archive","filename":"test.tar.zst"}]
443 },
444 "capsule_signature": "ed25519.3yMR7vZQ9hL"
445 });
446
447 let result = validate(&doc);
448 assert!(result.is_err());
449 }
450
451 #[test]
452 fn test_validate_detailed_returns_all_errors() {
453 let doc = json!({
454 "$schema": SPORE_SCHEMA,
455 "capsule": {
456 "uri": "invalid-uri", "core": {
458 "name": "" },
461 "core_signature": "invalid", "dist": [{"type":"archive","filename":"test.tar.zst"}]
463 },
464 "capsule_signature": "invalid" });
466
467 let result = validate_detailed(&doc);
468 assert!(result.is_ok());
469 let (_, errors) = result.ok().unwrap_or((SchemaType::Spore, vec![]));
470 assert!(!errors.is_empty(), "Expected validation errors");
471 }
472
473 #[test]
474 fn test_get_schema_spore_core() {
475 let schema = get_schema(SPORE_CORE_SCHEMA);
476 assert!(schema.is_some());
477 assert!(schema.map(|s| s.contains("bonds")).unwrap_or(false));
478 }
479
480 #[test]
481 fn test_detect_schema_type_spore_core() {
482 let doc = json!({
483 "$schema": SPORE_CORE_SCHEMA
484 });
485 assert_eq!(detect_schema_type(&doc).ok(), Some(SchemaType::SporeCore));
486 }
487
488 #[test]
489 fn test_validate_valid_spore_core() {
490 let doc = json!({
492 "$schema": SPORE_CORE_SCHEMA,
493 "id": "my-tool",
494 "name": "my-tool",
495 "domain": "example.com",
496 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
497 "synopsis": "A useful tool",
498 "intent": ["v1.0.0"],
499 "license": "MIT",
500 "mutations": [],
501 "bonds": [],
502 "tree": { "algorithm": "blob_tree_blake3_nfc", "exclude_names": [], "follow_rules": [] }
503 });
504
505 let result = validate(&doc);
506 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
507 assert_eq!(result.ok(), Some(SchemaType::SporeCore));
508 }
509
510 #[test]
511 fn test_validate_spore_core_with_optional_fields() {
512 let doc = json!({
513 "$schema": SPORE_CORE_SCHEMA,
514 "id": "my-tool",
515 "name": "my-tool",
516 "domain": "example.com",
517 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
518 "synopsis": "A useful tool",
519 "intent": ["v1.0.0"],
520 "license": "MIT",
521 "mutations": [],
522 "bonds": [
523 { "uri": "cmn://other.com/b3.3yMR7vZQ9hL", "relation": "depends_on" }
524 ],
525 "tree": {
526 "algorithm": "blob_tree_blake3_nfc",
527 "exclude_names": [".git"],
528 "follow_rules": [".gitignore"]
529 }
530 });
531
532 let result = validate(&doc);
533 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
534 assert_eq!(result.ok(), Some(SchemaType::SporeCore));
535 }
536
537 #[test]
538 fn test_get_schema_taste() {
539 let schema = get_schema(TASTE_SCHEMA);
540 assert!(schema.is_some());
541 assert!(schema.map(|s| s.contains("taste_core")).unwrap_or(false));
542 }
543
544 #[test]
545 fn test_detect_schema_type_taste() {
546 let doc = json!({
547 "$schema": TASTE_SCHEMA
548 });
549 assert_eq!(detect_schema_type(&doc).ok(), Some(SchemaType::Taste));
550 }
551
552 #[test]
553 fn test_validate_valid_taste() {
554 let doc = json!({
555 "$schema": TASTE_SCHEMA,
556 "capsule": {
557 "uri": "cmn://reviewer.com/taste/b3.7tRkW2xPqL9nH",
558 "core": {
559 "target_uri": "cmn://example.com/b3.3yMR7vZQ9hL",
560 "domain": "reviewer.com",
561 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
562 "verdict": "safe",
563 "tasted_at_epoch_ms": 1234567890000_u64
564 },
565 "core_signature": "ed25519.5XmkQ9vZP8nL"
566 },
567 "capsule_signature": "ed25519.3yMR7vZQ9hL"
568 });
569
570 let result = validate(&doc);
571 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
572 assert_eq!(result.ok(), Some(SchemaType::Taste));
573 }
574
575 #[test]
576 fn test_validate_taste_invalid_verdict() {
577 let doc = json!({
578 "$schema": TASTE_SCHEMA,
579 "capsule": {
580 "uri": "cmn://reviewer.com/taste/b3.7tRkW2xPqL9nH",
581 "core": {
582 "target_uri": "cmn://example.com/b3.3yMR7vZQ9hL",
583 "domain": "reviewer.com",
584 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
585 "verdict": "unknown_taste",
586 "tasted_at_epoch_ms": 1234567890000_u64
587 },
588 "core_signature": "ed25519.5XmkQ9vZP8nL"
589 },
590 "capsule_signature": "ed25519.3yMR7vZQ9hL"
591 });
592
593 let result = validate(&doc);
594 assert!(
595 result.is_err(),
596 "Expected validation to fail with invalid taste"
597 );
598 }
599
600 #[test]
601 fn test_validate_taste_mycelium_target_uri() {
602 let doc = json!({
603 "$schema": TASTE_SCHEMA,
604 "capsule": {
605 "uri": "cmn://reviewer.com/taste/b3.7tRkW2xPqL9nH",
606 "core": {
607 "target_uri": "cmn://example.com/mycelium/b3.3yMR7vZQ9hL",
608 "domain": "reviewer.com",
609 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
610 "verdict": "safe",
611 "tasted_at_epoch_ms": 1234567890000_u64
612 },
613 "core_signature": "ed25519.5XmkQ9vZP8nL"
614 },
615 "capsule_signature": "ed25519.3yMR7vZQ9hL"
616 });
617
618 let result = validate(&doc);
619 assert!(result.is_ok(), "Validation failed: {:?}", result.err());
620 }
621
622 #[test]
623 fn test_validate_taste_rejects_taste_target_uri() {
624 let doc = json!({
625 "$schema": TASTE_SCHEMA,
626 "capsule": {
627 "uri": "cmn://reviewer.com/taste/b3.7tRkW2xPqL9nH",
628 "core": {
629 "target_uri": "cmn://someone.dev/taste/b3.3yMR7vZQ9hL",
630 "domain": "reviewer.com",
631 "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
632 "verdict": "safe",
633 "tasted_at_epoch_ms": 1234567890000_u64
634 },
635 "core_signature": "ed25519.5XmkQ9vZP8nL"
636 },
637 "capsule_signature": "ed25519.3yMR7vZQ9hL"
638 });
639
640 let result = validate(&doc);
641 assert!(result.is_err(), "Expected taste target_uri to be rejected");
642 }
643
644 #[test]
645 fn test_validate_invalid_spore_core_missing_required() {
646 let doc = json!({
647 "$schema": SPORE_CORE_SCHEMA,
648 "name": "my-tool"
649 });
651
652 let result = validate(&doc);
653 assert!(result.is_err());
654 }
655}