Skip to main content

substrate/schemas/
mod.rs

1//! JSON Schema validation for CMN documents
2//!
3//! Uses embedded schemas for fast, offline validation.
4//! See docs/09_SCHEMA.md for specification.
5
6use anyhow::{anyhow, Result};
7use serde_json::Value;
8
9use crate::model::{CMN_SCHEMA, MYCELIUM_SCHEMA, SPORE_CORE_SCHEMA, SPORE_SCHEMA, TASTE_SCHEMA};
10
11// Embedded schemas - compiled into the binary
12pub 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/// CMN document type
19#[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/// Validation error details
35#[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
79/// Get embedded schema by URL
80///
81/// Returns the embedded schema JSON string for a given schema URL.
82/// Uses suffix matching to handle different schema URL formats.
83///
84/// # Examples
85/// ```
86/// use substrate::schemas::get_schema;
87///
88/// let schema = get_schema("https://cmn.dev/schemas/v1/spore.json");
89/// assert!(schema.is_some());
90/// ```
91pub fn get_schema(schema_url: &str) -> Option<&'static str> {
92    describe_schema(schema_url).map(|descriptor| descriptor.schema_json)
93}
94
95/// Detect document type from $schema field
96///
97/// # Examples
98/// ```
99/// use substrate::schemas::detect_schema_type;
100/// use serde_json::json;
101///
102/// let doc = json!({
103///     "$schema": "https://cmn.dev/schemas/v1/spore.json",
104///     "capsule": {},
105///     "capsule_signature": ""
106/// });
107/// let schema_type = detect_schema_type(&doc).unwrap();
108/// assert!(matches!(schema_type, substrate::schemas::SchemaType::Spore));
109/// ```
110pub 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
117/// Validate a CMN document against its schema
118///
119/// Automatically detects the document type from `$schema` and validates
120/// against the embedded schema.
121///
122/// # Examples
123/// ```
124/// use substrate::schemas::validate;
125/// use serde_json::json;
126///
127/// let doc = json!({
128///     "$schema": "https://cmn.dev/schemas/v1/spore.json",
129///     "capsule": {
130///         "uri": "cmn://example.com/b3.3yMR7vZQ9hL",
131///         "core": {
132///             "id": "test",
133///             "name": "test",
134///             "domain": "example.com",
135///             "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
136///             "synopsis": "A test",
137///             "intent": ["Testing"],
138///             "license": "MIT",
139///             "mutations": [],
140///             "bonds": [],
141///             "size_bytes": 0,
142///             "tree": { "algorithm": "blob_tree_blake3_nfc", "exclude_names": [], "follow_rules": [] },
143///             "updated_at_epoch_ms": 1700000000000_u64
144///         },
145///         "core_signature": "ed25519.5XmkQ9vZP8nL",
146///         "dist": [{"type":"archive"}]
147///     },
148///     "capsule_signature": "ed25519.3yMR7vZQ9hL"
149/// });
150///
151/// assert!(validate(&doc).is_ok());
152/// ```
153pub fn validate(doc: &Value) -> Result<SchemaType> {
154    // 1. Extract schema URL and schema metadata
155    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    // 2. Get schema
160    let schema: Value = serde_json::from_str(descriptor.schema_json)
161        .map_err(|e| anyhow!("Failed to parse schema: {}", e))?;
162
163    // 3. Compile schema
164    let compiled = jsonschema::validator_for(&schema)
165        .map_err(|e| anyhow!("Failed to compile schema: {}", e))?;
166
167    // 4. Validate
168    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
182/// Validate a document and return detailed errors
183///
184/// Unlike `validate()`, this returns a list of all validation errors
185/// instead of failing on the first one.
186pub fn validate_detailed(doc: &Value) -> Result<(SchemaType, Vec<ValidationError>)> {
187    // 1. Extract schema URL and schema metadata
188    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    // 2. Get schema
193    let schema: Value = serde_json::from_str(descriptor.schema_json)
194        .map_err(|e| anyhow!("Failed to parse schema: {}", e))?;
195
196    // 3. Compile schema
197    let compiled = jsonschema::validator_for(&schema)
198        .map_err(|e| anyhow!("Failed to compile schema: {}", e))?;
199
200    // 4. Validate and collect errors
201    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        // Taste-only domain: uri + key + taste endpoint only
362        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        // Minimal domain: uri + key + empty endpoints
384        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                    // missing domain, synopsis, intent, license
440                },
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",  // Invalid format
457                "core": {
458                    "name": ""  // Empty name (minLength: 1)
459                    // missing required fields
460                },
461                "core_signature": "invalid",  // Invalid format
462                "dist": [{"type":"archive","filename":"test.tar.zst"}]
463            },
464            "capsule_signature": "invalid"  // Invalid format
465        });
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        // This is the format of spore.core.json
491        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            // missing domain, synopsis, intent, license
650        });
651
652        let result = validate(&doc);
653        assert!(result.is_err());
654    }
655}