Skip to main content

substrate/model/
format.rs

1use anyhow::{anyhow, Result};
2use serde::Serialize;
3use serde_json::{Map, Value};
4
5/// Trait for canonical pretty-printing of CMN protocol types.
6pub trait PrettyJson {
7    fn to_pretty_json(&self) -> Result<String>;
8}
9
10/// Reorder keys in a JSON object map according to a priority list.
11/// Keys in `key_order` appear first (in that order), followed by any
12/// remaining keys in their existing (JCS-alphabetical) order.
13fn order_keys(map: &Map<String, Value>, key_order: &[&str]) -> Map<String, Value> {
14    let mut ordered = Map::with_capacity(map.len());
15    for &key in key_order {
16        if let Some(v) = map.get(key) {
17            ordered.insert(key.to_string(), v.clone());
18        }
19    }
20    for (k, v) in map {
21        if !ordered.contains_key(k) {
22            ordered.insert(k.clone(), v.clone());
23        }
24    }
25    ordered
26}
27
28/// Apply key ordering to a specific JSON pointer path within a value.
29fn order_keys_at(value: &mut Value, pointer: &str, key_order: &[&str]) {
30    if let Some(obj) = value.pointer(pointer).and_then(|v| v.as_object().cloned()) {
31        let ordered = order_keys(&obj, key_order);
32        // Navigate to parent and replace
33        if let Some(last_slash) = pointer.rfind('/') {
34            let parent_path = &pointer[..last_slash];
35            let child_key = &pointer[last_slash + 1..];
36            let parent = if parent_path.is_empty() {
37                Some(value as &mut Value)
38            } else {
39                value.pointer_mut(parent_path)
40            };
41            if let Some(Value::Object(parent_map)) = parent {
42                parent_map.insert(child_key.to_string(), Value::Object(ordered));
43            }
44        } else if pointer.is_empty() {
45            *value = Value::Object(ordered);
46        }
47    }
48}
49
50/// Apply key ordering to each element of a JSON array at the given pointer.
51fn order_array_elements_at(value: &mut Value, pointer: &str, key_order: &[&str]) {
52    if let Some(Value::Array(arr)) = value.pointer_mut(pointer) {
53        for item in arr.iter_mut() {
54            if let Value::Object(map) = item {
55                *map = order_keys(map, key_order);
56            }
57        }
58    }
59}
60
61/// JCS-canonicalize then pretty-print with custom key ordering.
62///
63/// Strategy: serialize to JCS (deterministic nested sorting), re-parse,
64/// then apply manual top-level + known nested object reordering.
65fn format_value(value: &Value, orderings: &[(&str, &[&str])]) -> Result<String> {
66    let canonical =
67        serde_jcs::to_string(value).map_err(|e| anyhow!("JCS serialization failed: {}", e))?;
68    let mut sorted: Value =
69        serde_json::from_str(&canonical).map_err(|e| anyhow!("JCS re-parse failed: {}", e))?;
70
71    for &(pointer, key_order) in orderings {
72        if let Some(array_pointer) = pointer.strip_suffix("[]") {
73            order_array_elements_at(&mut sorted, array_pointer, key_order);
74        } else {
75            order_keys_at(&mut sorted, pointer, key_order);
76        }
77    }
78
79    let pretty =
80        serde_json::to_string_pretty(&sorted).map_err(|e| anyhow!("Pretty-print failed: {}", e))?;
81    Ok(format!("{}\n", pretty))
82}
83
84fn serialize_to_value<T: Serialize>(value: &T, label: &str) -> Result<Value> {
85    serde_json::to_value(value).map_err(|e| anyhow!("{} serialization failed: {}", label, e))
86}
87
88// -- Spore key orders --
89
90const SPORE_TOP_KEY_ORDER: &[&str] = &["$schema", "capsule", "capsule_signature"];
91const SPORE_CAPSULE_KEY_ORDER: &[&str] = &["uri", "core", "core_signature", "dist"];
92/// Shared key order for SporeCore — works for both `capsule.core` (inside
93/// spore.json) and the top-level `spore.core.json` document.  `order_keys`
94/// silently skips keys that aren't present, so `$schema` is a no-op inside
95/// a capsule core and `updated_at_epoch_ms` is a no-op in draft files.
96const SPORE_CORE_KEY_ORDER: &[&str] = &[
97    "$schema",
98    "id",
99    "name",
100    "version",
101    "domain",
102    "key",
103    "synopsis",
104    "intent",
105    "license",
106    "mutations",
107    "updated_at_epoch_ms",
108    "bonds",
109    "tree",
110];
111const BOND_KEY_ORDER: &[&str] = &["relation", "uri", "id", "reason", "with"];
112const SPORE_TREE_KEY_ORDER: &[&str] = &["algorithm", "exclude_names", "follow_rules"];
113
114// -- Mycelium key orders --
115
116const MYCELIUM_TOP_KEY_ORDER: &[&str] = &["$schema", "capsule", "capsule_signature"];
117const MYCELIUM_CAPSULE_KEY_ORDER: &[&str] = &["uri", "core", "core_signature"];
118const MYCELIUM_CORE_KEY_ORDER: &[&str] = &[
119    "domain",
120    "key",
121    "name",
122    "synopsis",
123    "bio",
124    "nutrients",
125    "updated_at_epoch_ms",
126    "spores",
127    "tastes",
128];
129const NUTRIENT_KEY_ORDER: &[&str] = &[
130    "type",
131    "address",
132    "recipient",
133    "url",
134    "label",
135    "chain_id",
136    "token",
137    "asset_id",
138];
139const MYCELIUM_SPORE_KEY_ORDER: &[&str] = &["id", "hash", "name", "synopsis"];
140const MYCELIUM_TASTE_KEY_ORDER: &[&str] = &["hash", "target_uri"];
141
142// -- Taste key orders --
143
144const TASTE_TOP_KEY_ORDER: &[&str] = &["$schema", "capsule", "capsule_signature"];
145const TASTE_CAPSULE_KEY_ORDER: &[&str] = &["uri", "core", "core_signature"];
146const TASTE_CORE_KEY_ORDER: &[&str] = &[
147    "domain",
148    "key",
149    "target_uri",
150    "verdict",
151    "notes",
152    "tasted_at_epoch_ms",
153];
154
155// -- CMN key orders --
156
157const CMN_TOP_KEY_ORDER: &[&str] = &["$schema", "capsules", "capsule_signature"];
158const CMN_CAPSULE_ENTRY_KEY_ORDER: &[&str] = &["uri", "serial", "key", "history", "endpoints"];
159const CMN_ENDPOINT_KEY_ORDER: &[&str] = &["type", "url", "hashes", "format", "delta_url"];
160const KEY_HISTORY_ORDER: &[&str] = &[
161    "key",
162    "status",
163    "retired_at_epoch_ms",
164    "replaced_by",
165    "effective_serial",
166    "rotation_signature",
167    "revoked_at_epoch_ms",
168];
169
170// -- PrettyJson implementations --
171
172impl PrettyJson for super::Spore {
173    fn to_pretty_json(&self) -> Result<String> {
174        let value = serialize_to_value(self, "Spore")?;
175        format_value(
176            &value,
177            &[
178                ("", SPORE_TOP_KEY_ORDER),
179                ("/capsule", SPORE_CAPSULE_KEY_ORDER),
180                ("/capsule/core", SPORE_CORE_KEY_ORDER),
181                ("/capsule/core/bonds[]", BOND_KEY_ORDER),
182                ("/capsule/core/tree", SPORE_TREE_KEY_ORDER),
183            ],
184        )
185    }
186}
187
188impl PrettyJson for super::SporeCoreDocument {
189    fn to_pretty_json(&self) -> Result<String> {
190        let value = serialize_to_value(self, "SporeCoreDocument")?;
191        format_value(
192            &value,
193            &[
194                ("", SPORE_CORE_KEY_ORDER),
195                ("/bonds[]", BOND_KEY_ORDER),
196                ("/tree", SPORE_TREE_KEY_ORDER),
197            ],
198        )
199    }
200}
201
202/// Format a spore core draft value for writing to spore.core.json.
203/// Strips `updated_at_epoch_ms` and applies canonical key ordering.
204pub fn format_spore_core_draft(value: &Value) -> Result<String> {
205    let mut clean = value.clone();
206    if let Some(obj) = clean.as_object_mut() {
207        obj.remove("updated_at_epoch_ms");
208    }
209    format_value(
210        &clean,
211        &[
212            ("", SPORE_CORE_KEY_ORDER),
213            ("/bonds[]", BOND_KEY_ORDER),
214            ("/tree", SPORE_TREE_KEY_ORDER),
215        ],
216    )
217}
218
219impl PrettyJson for super::Mycelium {
220    fn to_pretty_json(&self) -> Result<String> {
221        let value = serialize_to_value(self, "Mycelium")?;
222        format_value(
223            &value,
224            &[
225                ("", MYCELIUM_TOP_KEY_ORDER),
226                ("/capsule", MYCELIUM_CAPSULE_KEY_ORDER),
227                ("/capsule/core", MYCELIUM_CORE_KEY_ORDER),
228                ("/capsule/core/nutrients[]", NUTRIENT_KEY_ORDER),
229                ("/capsule/core/spores[]", MYCELIUM_SPORE_KEY_ORDER),
230                ("/capsule/core/tastes[]", MYCELIUM_TASTE_KEY_ORDER),
231            ],
232        )
233    }
234}
235
236impl PrettyJson for super::Taste {
237    fn to_pretty_json(&self) -> Result<String> {
238        let value = serialize_to_value(self, "Taste")?;
239        format_value(
240            &value,
241            &[
242                ("", TASTE_TOP_KEY_ORDER),
243                ("/capsule", TASTE_CAPSULE_KEY_ORDER),
244                ("/capsule/core", TASTE_CORE_KEY_ORDER),
245            ],
246        )
247    }
248}
249
250impl PrettyJson for super::CmnEntry {
251    fn to_pretty_json(&self) -> Result<String> {
252        let value = serialize_to_value(self, "CmnEntry")?;
253        format_value(
254            &value,
255            &[
256                ("", CMN_TOP_KEY_ORDER),
257                ("/capsules[]", CMN_CAPSULE_ENTRY_KEY_ORDER),
258            ],
259        )
260    }
261}
262
263/// Apply key ordering to CmnEntry after it's already a Value.
264/// Useful when the CmnEntry has already been serialized/validated.
265pub fn format_cmn_entry(value: &Value) -> Result<String> {
266    format_value(
267        value,
268        &[
269            ("", CMN_TOP_KEY_ORDER),
270            ("/capsules[]", CMN_CAPSULE_ENTRY_KEY_ORDER),
271        ],
272    )
273}
274
275impl super::CmnEntry {
276    /// Pretty-print with full deep key ordering including nested endpoints.
277    pub fn to_pretty_json_deep(&self) -> Result<String> {
278        let canonical =
279            serde_jcs::to_string(self).map_err(|e| anyhow!("JCS serialization failed: {}", e))?;
280        let mut sorted: Value =
281            serde_json::from_str(&canonical).map_err(|e| anyhow!("JCS re-parse failed: {}", e))?;
282
283        // Top-level
284        if let Value::Object(ref map) = sorted.clone() {
285            sorted = Value::Object(order_keys(map, CMN_TOP_KEY_ORDER));
286        }
287
288        // Each capsule entry
289        if let Some(Value::Array(capsules)) = sorted.pointer_mut("/capsules") {
290            for capsule in capsules.iter_mut() {
291                if let Value::Object(map) = capsule {
292                    *map = order_keys(map, CMN_CAPSULE_ENTRY_KEY_ORDER);
293
294                    // Endpoints inside each capsule
295                    if let Some(Value::Array(endpoints)) = map.get_mut("endpoints") {
296                        for ep in endpoints.iter_mut() {
297                            if let Value::Object(ep_map) = ep {
298                                *ep_map = order_keys(ep_map, CMN_ENDPOINT_KEY_ORDER);
299                            }
300                        }
301                    }
302
303                    // Key history inside each capsule
304                    if let Some(Value::Array(history)) = map.get_mut("history") {
305                        for item in history.iter_mut() {
306                            if let Value::Object(history_map) = item {
307                                *history_map = order_keys(history_map, KEY_HISTORY_ORDER);
308                            }
309                        }
310                    }
311                }
312            }
313        }
314
315        let pretty = serde_json::to_string_pretty(&sorted)
316            .map_err(|e| anyhow!("Pretty-print failed: {}", e))?;
317        Ok(format!("{}\n", pretty))
318    }
319}
320
321#[cfg(test)]
322#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
323mod tests {
324
325    use super::*;
326    use crate::model::*;
327
328    #[test]
329    fn test_order_keys() {
330        let mut map = Map::new();
331        map.insert("z".to_string(), Value::Null);
332        map.insert("a".to_string(), Value::Null);
333        map.insert("m".to_string(), Value::Null);
334
335        let ordered = order_keys(&map, &["m", "a"]);
336        let keys: Vec<&String> = ordered.keys().collect();
337        assert_eq!(keys, vec!["m", "a", "z"]);
338    }
339
340    #[test]
341    fn test_spore_to_pretty_json_key_order() {
342        let spore = Spore::new(
343            "example.com",
344            "test",
345            "A test",
346            vec!["v1".to_string()],
347            "MIT",
348        );
349        let json = spore.to_pretty_json().unwrap();
350
351        // Verify top-level key order: $schema before capsule before capsule_signature
352        let schema_pos = json.find("\"$schema\"").unwrap();
353        let capsule_pos = json.find("\"capsule\"").unwrap();
354        let capsule_sig_pos = json.find("\"capsule_signature\"").unwrap();
355        assert!(schema_pos < capsule_pos);
356        assert!(capsule_pos < capsule_sig_pos);
357
358        // Verify core key order: name before domain before synopsis
359        let name_pos = json.find("\"name\"").unwrap();
360        let domain_pos = json.find("\"domain\"").unwrap();
361        let synopsis_pos = json.find("\"synopsis\"").unwrap();
362        assert!(name_pos < domain_pos);
363        assert!(domain_pos < synopsis_pos);
364    }
365
366    #[test]
367    fn test_spore_core_document_key_order() {
368        let doc = SporeCoreDocument {
369            schema: SPORE_CORE_SCHEMA.to_string(),
370            core: SporeCore {
371                id: String::new(),
372                name: "test".to_string(),
373                version: String::new(),
374                domain: "example.com".to_string(),
375                key: String::new(),
376                synopsis: "A test".to_string(),
377                intent: vec![],
378                license: "MIT".to_string(),
379                mutations: vec![],
380                size_bytes: 0,
381                updated_at_epoch_ms: 0,
382                bonds: vec![],
383                tree: SporeTree::default(),
384            },
385        };
386        let json = doc.to_pretty_json().unwrap();
387
388        let schema_pos = json.find("\"$schema\"").unwrap();
389        let name_pos = json.find("\"name\"").unwrap();
390        let domain_pos = json.find("\"domain\"").unwrap();
391        assert!(schema_pos < name_pos);
392        assert!(name_pos < domain_pos);
393    }
394
395    #[test]
396    fn test_format_spore_core_draft_strips_updated_at() {
397        let value = serde_json::json!({
398            "$schema": SPORE_CORE_SCHEMA,
399            "name": "test",
400            "domain": "example.com",
401            "synopsis": "A test",
402            "intent": [],
403            "license": "MIT",
404            "updated_at_epoch_ms": 12345,
405            "tree": {
406                "algorithm": "blob_tree_blake3_nfc",
407                "exclude_names": [],
408                "follow_rules": []
409            }
410        });
411        let json = format_spore_core_draft(&value).unwrap();
412        assert!(!json.contains("updated_at_epoch_ms"));
413    }
414
415    #[test]
416    fn test_mycelium_to_pretty_json_core_key_order() {
417        let mycelium = Mycelium::new("example.com", "Example", "A test", 123);
418        let json = mycelium.to_pretty_json().unwrap();
419
420        // Verify core key order: domain before name before synopsis
421        let domain_pos = json.find("\"domain\"").unwrap();
422        let name_pos = json.find("\"name\"").unwrap();
423        let synopsis_pos = json.find("\"synopsis\"").unwrap();
424        assert!(domain_pos < name_pos);
425        assert!(name_pos < synopsis_pos);
426    }
427
428    #[test]
429    fn test_cmn_entry_to_pretty_json_deep() {
430        let entry = CmnEntry::new(vec![CmnCapsuleEntry {
431            uri: "cmn://example.com".to_string(),
432            serial: 1,
433            key: "ed25519.abc".to_string(),
434            history: vec![],
435            endpoints: vec![CmnEndpoint {
436                kind: "mycelium".to_string(),
437                url: "https://example.com/cmn/mycelium/{hash}.json".to_string(),
438                hash: "b3.abc".to_string(),
439                hashes: vec![],
440                format: None,
441                delta_url: None,
442            }],
443        }]);
444        let json = entry.to_pretty_json_deep().unwrap();
445
446        let schema_pos = json.find("\"$schema\"").unwrap();
447        let capsules_pos = json.find("\"capsules\"").unwrap();
448        let sig_pos = json.find("\"capsule_signature\"").unwrap();
449        assert!(schema_pos < capsules_pos);
450        assert!(capsules_pos < sig_pos);
451    }
452
453    #[test]
454    fn test_taste_to_pretty_json_key_order() {
455        let taste = Taste {
456            schema: TASTE_SCHEMA.to_string(),
457            capsule: TasteCapsule {
458                uri: "cmn://example.com/taste/b3.abc".to_string(),
459                core: TasteCore {
460                    target_uri: "cmn://other.com/b3.xyz".to_string(),
461                    domain: "example.com".to_string(),
462                    key: "ed25519.abc".to_string(),
463                    verdict: TasteVerdict::Safe,
464                    notes: vec![],
465                    tasted_at_epoch_ms: 123,
466                },
467                core_signature: "ed25519.sig".to_string(),
468            },
469            capsule_signature: "ed25519.capsig".to_string(),
470        };
471        let json = taste.to_pretty_json().unwrap();
472
473        let schema_pos = json.find("\"$schema\"").unwrap();
474        let capsule_pos = json.find("\"capsule\"").unwrap();
475        assert!(schema_pos < capsule_pos);
476    }
477}