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] = &[
158    "$schema",
159    "protocol_versions",
160    "capsules",
161    "capsule_signature",
162];
163const CMN_CAPSULE_ENTRY_KEY_ORDER: &[&str] = &["uri", "key", "previous_keys", "endpoints"];
164const CMN_ENDPOINT_KEY_ORDER: &[&str] = &["type", "url", "hashes", "format", "delta_url"];
165const PREVIOUS_KEY_ORDER: &[&str] = &["key", "retired_at_epoch_ms"];
166
167// -- PrettyJson implementations --
168
169impl PrettyJson for super::Spore {
170    fn to_pretty_json(&self) -> Result<String> {
171        let value = serialize_to_value(self, "Spore")?;
172        format_value(
173            &value,
174            &[
175                ("", SPORE_TOP_KEY_ORDER),
176                ("/capsule", SPORE_CAPSULE_KEY_ORDER),
177                ("/capsule/core", SPORE_CORE_KEY_ORDER),
178                ("/capsule/core/bonds[]", BOND_KEY_ORDER),
179                ("/capsule/core/tree", SPORE_TREE_KEY_ORDER),
180            ],
181        )
182    }
183}
184
185impl PrettyJson for super::SporeCoreDocument {
186    fn to_pretty_json(&self) -> Result<String> {
187        let value = serialize_to_value(self, "SporeCoreDocument")?;
188        format_value(
189            &value,
190            &[
191                ("", SPORE_CORE_KEY_ORDER),
192                ("/bonds[]", BOND_KEY_ORDER),
193                ("/tree", SPORE_TREE_KEY_ORDER),
194            ],
195        )
196    }
197}
198
199/// Format a spore core draft value for writing to spore.core.json.
200/// Strips `updated_at_epoch_ms` and applies canonical key ordering.
201pub fn format_spore_core_draft(value: &Value) -> Result<String> {
202    let mut clean = value.clone();
203    if let Some(obj) = clean.as_object_mut() {
204        obj.remove("updated_at_epoch_ms");
205    }
206    format_value(
207        &clean,
208        &[
209            ("", SPORE_CORE_KEY_ORDER),
210            ("/bonds[]", BOND_KEY_ORDER),
211            ("/tree", SPORE_TREE_KEY_ORDER),
212        ],
213    )
214}
215
216impl PrettyJson for super::Mycelium {
217    fn to_pretty_json(&self) -> Result<String> {
218        let value = serialize_to_value(self, "Mycelium")?;
219        format_value(
220            &value,
221            &[
222                ("", MYCELIUM_TOP_KEY_ORDER),
223                ("/capsule", MYCELIUM_CAPSULE_KEY_ORDER),
224                ("/capsule/core", MYCELIUM_CORE_KEY_ORDER),
225                ("/capsule/core/nutrients[]", NUTRIENT_KEY_ORDER),
226                ("/capsule/core/spores[]", MYCELIUM_SPORE_KEY_ORDER),
227                ("/capsule/core/tastes[]", MYCELIUM_TASTE_KEY_ORDER),
228            ],
229        )
230    }
231}
232
233impl PrettyJson for super::Taste {
234    fn to_pretty_json(&self) -> Result<String> {
235        let value = serialize_to_value(self, "Taste")?;
236        format_value(
237            &value,
238            &[
239                ("", TASTE_TOP_KEY_ORDER),
240                ("/capsule", TASTE_CAPSULE_KEY_ORDER),
241                ("/capsule/core", TASTE_CORE_KEY_ORDER),
242            ],
243        )
244    }
245}
246
247impl PrettyJson for super::CmnEntry {
248    fn to_pretty_json(&self) -> Result<String> {
249        let value = serialize_to_value(self, "CmnEntry")?;
250        format_value(
251            &value,
252            &[
253                ("", CMN_TOP_KEY_ORDER),
254                ("/capsules[]", CMN_CAPSULE_ENTRY_KEY_ORDER),
255            ],
256        )
257    }
258}
259
260/// Apply key ordering to CmnEntry after it's already a Value.
261/// Useful when the CmnEntry has already been serialized/validated.
262pub fn format_cmn_entry(value: &Value) -> Result<String> {
263    format_value(
264        value,
265        &[
266            ("", CMN_TOP_KEY_ORDER),
267            ("/capsules[]", CMN_CAPSULE_ENTRY_KEY_ORDER),
268        ],
269    )
270}
271
272impl super::CmnEntry {
273    /// Pretty-print with full deep key ordering including nested endpoints.
274    pub fn to_pretty_json_deep(&self) -> Result<String> {
275        let canonical =
276            serde_jcs::to_string(self).map_err(|e| anyhow!("JCS serialization failed: {}", e))?;
277        let mut sorted: Value =
278            serde_json::from_str(&canonical).map_err(|e| anyhow!("JCS re-parse failed: {}", e))?;
279
280        // Top-level
281        if let Value::Object(ref map) = sorted.clone() {
282            sorted = Value::Object(order_keys(map, CMN_TOP_KEY_ORDER));
283        }
284
285        // Each capsule entry
286        if let Some(Value::Array(capsules)) = sorted.pointer_mut("/capsules") {
287            for capsule in capsules.iter_mut() {
288                if let Value::Object(map) = capsule {
289                    *map = order_keys(map, CMN_CAPSULE_ENTRY_KEY_ORDER);
290
291                    // Endpoints inside each capsule
292                    if let Some(Value::Array(endpoints)) = map.get_mut("endpoints") {
293                        for ep in endpoints.iter_mut() {
294                            if let Value::Object(ep_map) = ep {
295                                *ep_map = order_keys(ep_map, CMN_ENDPOINT_KEY_ORDER);
296                            }
297                        }
298                    }
299
300                    // Previous keys inside each capsule
301                    if let Some(Value::Array(prev_keys)) = map.get_mut("previous_keys") {
302                        for pk in prev_keys.iter_mut() {
303                            if let Value::Object(pk_map) = pk {
304                                *pk_map = order_keys(pk_map, PREVIOUS_KEY_ORDER);
305                            }
306                        }
307                    }
308                }
309            }
310        }
311
312        let pretty = serde_json::to_string_pretty(&sorted)
313            .map_err(|e| anyhow!("Pretty-print failed: {}", e))?;
314        Ok(format!("{}\n", pretty))
315    }
316}
317
318#[cfg(test)]
319#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
320mod tests {
321
322    use super::*;
323    use crate::model::*;
324
325    #[test]
326    fn test_order_keys() {
327        let mut map = Map::new();
328        map.insert("z".to_string(), Value::Null);
329        map.insert("a".to_string(), Value::Null);
330        map.insert("m".to_string(), Value::Null);
331
332        let ordered = order_keys(&map, &["m", "a"]);
333        let keys: Vec<&String> = ordered.keys().collect();
334        assert_eq!(keys, vec!["m", "a", "z"]);
335    }
336
337    #[test]
338    fn test_spore_to_pretty_json_key_order() {
339        let spore = Spore::new(
340            "example.com",
341            "test",
342            "A test",
343            vec!["v1".to_string()],
344            "MIT",
345        );
346        let json = spore.to_pretty_json().unwrap();
347
348        // Verify top-level key order: $schema before capsule before capsule_signature
349        let schema_pos = json.find("\"$schema\"").unwrap();
350        let capsule_pos = json.find("\"capsule\"").unwrap();
351        let capsule_sig_pos = json.find("\"capsule_signature\"").unwrap();
352        assert!(schema_pos < capsule_pos);
353        assert!(capsule_pos < capsule_sig_pos);
354
355        // Verify core key order: name before domain before synopsis
356        let name_pos = json.find("\"name\"").unwrap();
357        let domain_pos = json.find("\"domain\"").unwrap();
358        let synopsis_pos = json.find("\"synopsis\"").unwrap();
359        assert!(name_pos < domain_pos);
360        assert!(domain_pos < synopsis_pos);
361    }
362
363    #[test]
364    fn test_spore_core_document_key_order() {
365        let doc = SporeCoreDocument {
366            schema: SPORE_CORE_SCHEMA.to_string(),
367            core: SporeCore {
368                id: String::new(),
369                name: "test".to_string(),
370                version: String::new(),
371                domain: "example.com".to_string(),
372                key: String::new(),
373                synopsis: "A test".to_string(),
374                intent: vec![],
375                license: "MIT".to_string(),
376                mutations: vec![],
377                size_bytes: 0,
378                updated_at_epoch_ms: 0,
379                bonds: vec![],
380                tree: SporeTree::default(),
381            },
382        };
383        let json = doc.to_pretty_json().unwrap();
384
385        let schema_pos = json.find("\"$schema\"").unwrap();
386        let name_pos = json.find("\"name\"").unwrap();
387        let domain_pos = json.find("\"domain\"").unwrap();
388        assert!(schema_pos < name_pos);
389        assert!(name_pos < domain_pos);
390    }
391
392    #[test]
393    fn test_format_spore_core_draft_strips_updated_at() {
394        let value = serde_json::json!({
395            "$schema": SPORE_CORE_SCHEMA,
396            "name": "test",
397            "domain": "example.com",
398            "synopsis": "A test",
399            "intent": [],
400            "license": "MIT",
401            "updated_at_epoch_ms": 12345,
402            "tree": {
403                "algorithm": "blob_tree_blake3_nfc",
404                "exclude_names": [],
405                "follow_rules": []
406            }
407        });
408        let json = format_spore_core_draft(&value).unwrap();
409        assert!(!json.contains("updated_at_epoch_ms"));
410    }
411
412    #[test]
413    fn test_mycelium_to_pretty_json_core_key_order() {
414        let mycelium = Mycelium::new("example.com", "Example", "A test", 123);
415        let json = mycelium.to_pretty_json().unwrap();
416
417        // Verify core key order: domain before name before synopsis
418        let domain_pos = json.find("\"domain\"").unwrap();
419        let name_pos = json.find("\"name\"").unwrap();
420        let synopsis_pos = json.find("\"synopsis\"").unwrap();
421        assert!(domain_pos < name_pos);
422        assert!(name_pos < synopsis_pos);
423    }
424
425    #[test]
426    fn test_cmn_entry_to_pretty_json_deep() {
427        let entry = CmnEntry::new(vec![CmnCapsuleEntry {
428            uri: "cmn://example.com".to_string(),
429            key: "ed25519.abc".to_string(),
430            previous_keys: vec![],
431            endpoints: vec![CmnEndpoint {
432                kind: "mycelium".to_string(),
433                url: "https://example.com/cmn/mycelium/{hash}.json".to_string(),
434                hash: "b3.abc".to_string(),
435                hashes: vec![],
436                format: None,
437                delta_url: None,
438                protocol_version: None,
439            }],
440        }]);
441        let json = entry.to_pretty_json_deep().unwrap();
442
443        let schema_pos = json.find("\"$schema\"").unwrap();
444        let capsules_pos = json.find("\"capsules\"").unwrap();
445        let sig_pos = json.find("\"capsule_signature\"").unwrap();
446        assert!(schema_pos < capsules_pos);
447        assert!(capsules_pos < sig_pos);
448    }
449
450    #[test]
451    fn test_taste_to_pretty_json_key_order() {
452        let taste = Taste {
453            schema: TASTE_SCHEMA.to_string(),
454            capsule: TasteCapsule {
455                uri: "cmn://example.com/taste/b3.abc".to_string(),
456                core: TasteCore {
457                    target_uri: "cmn://other.com/b3.xyz".to_string(),
458                    domain: "example.com".to_string(),
459                    key: "ed25519.abc".to_string(),
460                    verdict: TasteVerdict::Safe,
461                    notes: vec![],
462                    tasted_at_epoch_ms: 123,
463                },
464                core_signature: "ed25519.sig".to_string(),
465            },
466            capsule_signature: "ed25519.capsig".to_string(),
467        };
468        let json = taste.to_pretty_json().unwrap();
469
470        let schema_pos = json.find("\"$schema\"").unwrap();
471        let capsule_pos = json.find("\"capsule\"").unwrap();
472        assert!(schema_pos < capsule_pos);
473    }
474}