Skip to main content

substrate/model/
mycelium.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4pub const MYCELIUM_SCHEMA: &str = "https://cmn.dev/schemas/v1/mycelium.json";
5
6/// Full Mycelium manifest (content-addressed)
7#[derive(Serialize, Deserialize, Debug, Clone)]
8pub struct Mycelium {
9    #[serde(rename = "$schema")]
10    pub schema: String,
11    pub capsule: MyceliumCapsule,
12    pub capsule_signature: String,
13}
14
15/// Mycelium capsule containing uri, core, and core_signature
16#[derive(Serialize, Deserialize, Debug, Clone)]
17pub struct MyceliumCapsule {
18    pub uri: String,
19    pub core: MyceliumCore,
20    pub core_signature: String,
21}
22
23/// Core mycelium data (part of hash)
24#[derive(Serialize, Deserialize, Debug, Clone)]
25pub struct MyceliumCore {
26    pub name: String,
27    pub domain: String,
28    pub key: String,
29    pub synopsis: String,
30    #[serde(default, skip_serializing_if = "String::is_empty")]
31    pub bio: String,
32    #[serde(default)]
33    pub nutrients: Vec<Nutrient>,
34    pub updated_at_epoch_ms: u64,
35    #[serde(default)]
36    pub spores: Vec<MyceliumCoreSpore>,
37    #[serde(default)]
38    pub tastes: Vec<MyceliumCoreTaste>,
39}
40
41/// Spore entry in mycelium's spores list
42#[derive(Serialize, Deserialize, Debug, Clone)]
43pub struct MyceliumCoreSpore {
44    pub id: String,
45    pub hash: String,
46    pub name: String,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub synopsis: Option<String>,
49}
50
51/// Taste entry in mycelium's tastes list
52#[derive(Serialize, Deserialize, Debug, Clone)]
53pub struct MyceliumCoreTaste {
54    pub hash: String,
55    pub target_uri: String,
56}
57
58/// Single nutrient method entry
59#[derive(Serialize, Deserialize, Debug, Clone)]
60pub struct Nutrient {
61    #[serde(rename = "type")]
62    pub kind: String,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub address: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub recipient: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub url: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub label: Option<String>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub chain_id: Option<u64>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub token: Option<String>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub asset_id: Option<String>,
77}
78
79impl Mycelium {
80    pub fn new(domain: &str, name: &str, synopsis: &str, updated_at_epoch_ms: u64) -> Self {
81        Self {
82            schema: MYCELIUM_SCHEMA.to_string(),
83            capsule: MyceliumCapsule {
84                uri: String::new(),
85                core: MyceliumCore {
86                    name: name.to_string(),
87                    domain: domain.to_string(),
88                    key: String::new(),
89                    synopsis: synopsis.to_string(),
90                    bio: String::new(),
91                    nutrients: vec![],
92                    updated_at_epoch_ms,
93                    spores: vec![],
94                    tastes: vec![],
95                },
96                core_signature: String::new(),
97            },
98            capsule_signature: String::new(),
99        }
100    }
101
102    pub fn add_spore(
103        &mut self,
104        id: &str,
105        hash: &str,
106        name: &str,
107        synopsis: Option<&str>,
108        updated_at_epoch_ms: u64,
109    ) {
110        self.capsule.core.spores.retain(|entry| {
111            if entry.id.is_empty() {
112                entry.name != name
113            } else {
114                entry.id != id
115            }
116        });
117
118        self.capsule.core.spores.push(MyceliumCoreSpore {
119            id: id.to_string(),
120            hash: hash.to_string(),
121            name: name.to_string(),
122            synopsis: synopsis.map(str::to_string),
123        });
124        self.capsule.core.updated_at_epoch_ms = updated_at_epoch_ms;
125    }
126
127    pub fn uri(&self) -> &str {
128        &self.capsule.uri
129    }
130
131    pub fn author_domain(&self) -> &str {
132        &self.capsule.core.domain
133    }
134
135    pub fn timestamp_ms(&self) -> u64 {
136        self.capsule.core.updated_at_epoch_ms
137    }
138
139    pub fn embedded_core_key(&self) -> Option<&str> {
140        let key = self.capsule.core.key.as_str();
141        (!key.is_empty()).then_some(key)
142    }
143
144    pub fn spore_hashes(&self) -> impl Iterator<Item = &str> {
145        self.capsule
146            .core
147            .spores
148            .iter()
149            .map(|spore| spore.hash.as_str())
150    }
151
152    pub fn verify_core_signature(&self, author_key: &str) -> Result<()> {
153        crate::verify_json_signature(&self.capsule.core, &self.capsule.core_signature, author_key)
154    }
155
156    pub fn verify_capsule_signature(&self, host_key: &str) -> Result<()> {
157        crate::verify_json_signature(&self.capsule, &self.capsule_signature, host_key)
158    }
159
160    pub fn verify_signatures(&self, host_key: &str, author_key: &str) -> Result<()> {
161        self.verify_core_signature(author_key)?;
162        self.verify_capsule_signature(host_key)
163    }
164
165    /// Verify both signatures using the same key (self-hosted case where host == author).
166    pub fn verify_self_hosted_signatures(&self, key: &str) -> Result<()> {
167        self.verify_signatures(key, key)
168    }
169
170    pub fn computed_uri_hash(&self) -> Result<String> {
171        crate::crypto::hash::compute_signed_core_hash(
172            &self.capsule.core,
173            &self.capsule.core_signature,
174        )
175    }
176
177    pub fn verify_uri_hash(&self, expected_hash: &str) -> Result<()> {
178        let actual_hash = self.computed_uri_hash()?;
179        super::verify_expected_uri_hash(&actual_hash, expected_hash)
180    }
181}
182
183#[cfg(test)]
184#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
185mod tests {
186
187    use super::*;
188
189    #[test]
190    fn test_mycelium_new() {
191        let mycelium = Mycelium::new("example.com", "Example", "A test mycelium", 123);
192        assert_eq!(mycelium.schema, MYCELIUM_SCHEMA);
193        assert_eq!(mycelium.capsule.core.name, "Example");
194        assert_eq!(mycelium.capsule.core.synopsis, "A test mycelium");
195        assert_eq!(mycelium.capsule.core.domain, "example.com");
196        assert_eq!(mycelium.capsule.core.updated_at_epoch_ms, 123);
197        assert!(mycelium.capsule.core.spores.is_empty());
198        assert!(mycelium.capsule.core.tastes.is_empty());
199    }
200
201    #[test]
202    fn test_mycelium_add_spore() {
203        let mut mycelium = Mycelium::new("example.com", "Example", "", 10);
204        mycelium.add_spore("test", "b3.abc123", "test-spore", Some("A test spore"), 20);
205
206        assert_eq!(mycelium.capsule.core.spores.len(), 1);
207        assert_eq!(mycelium.capsule.core.spores[0].id, "test");
208        assert_eq!(mycelium.capsule.core.spores[0].hash, "b3.abc123");
209        assert_eq!(mycelium.capsule.core.spores[0].name, "test-spore");
210        assert_eq!(
211            mycelium.capsule.core.spores[0].synopsis,
212            Some("A test spore".to_string())
213        );
214        assert_eq!(mycelium.capsule.core.updated_at_epoch_ms, 20);
215    }
216
217    #[test]
218    fn test_mycelium_add_spore_replaces_existing() {
219        let mut mycelium = Mycelium::new("example.com", "Example", "", 10);
220        mycelium.add_spore(
221            "my-spore",
222            "b3.abc123",
223            "old-name",
224            Some("Old synopsis"),
225            20,
226        );
227        mycelium.add_spore(
228            "my-spore",
229            "b3.def456",
230            "new-name",
231            Some("New synopsis"),
232            30,
233        );
234
235        assert_eq!(mycelium.capsule.core.spores.len(), 1);
236        assert_eq!(mycelium.capsule.core.spores[0].id, "my-spore");
237        assert_eq!(mycelium.capsule.core.spores[0].hash, "b3.def456");
238        assert_eq!(mycelium.capsule.core.spores[0].name, "new-name");
239        assert_eq!(
240            mycelium.capsule.core.spores[0].synopsis,
241            Some("New synopsis".to_string())
242        );
243        assert_eq!(mycelium.capsule.core.updated_at_epoch_ms, 30);
244    }
245
246    #[test]
247    fn test_mycelium_full_serialization() {
248        let mut mycelium = Mycelium::new("dev.example", "Developer", "A Rust developer", 10);
249        mycelium.add_spore("my-lib", "b3.spore1", "my-lib", Some("A library"), 20);
250        mycelium.add_spore("my-app", "b3.spore2", "my-app", None, 30);
251        mycelium.capsule.core_signature = "ed25519.core123".to_string();
252        mycelium.capsule_signature = "ed25519.capsule123".to_string();
253        mycelium.capsule.uri = "cmn://dev.example".to_string();
254
255        let json = serde_json::to_string_pretty(&mycelium).unwrap_or_default();
256        assert!(json.contains("\"$schema\""));
257        assert!(json.contains(MYCELIUM_SCHEMA));
258        assert!(json.contains("Developer"));
259        assert!(json.contains("my-lib"));
260        assert!(json.contains("my-app"));
261    }
262
263    #[test]
264    fn test_mycelium_bio_field() {
265        let mut mycelium = Mycelium::new("example.com", "Example", "A test mycelium", 10);
266        mycelium.capsule.core.bio = "Longer biography of this mycelium".to_string();
267
268        let json = serde_json::to_string(&mycelium).unwrap_or_default();
269        assert!(json.contains("\"bio\""));
270        assert!(json.contains("Longer biography of this mycelium"));
271
272        let parsed: Mycelium = serde_json::from_str(&json).unwrap();
273        assert_eq!(parsed.capsule.core.bio, "Longer biography of this mycelium");
274    }
275
276    #[test]
277    fn test_mycelium_nutrients_field() {
278        let mut mycelium = Mycelium::new("example.com", "Example", "A test mycelium", 10);
279        mycelium.capsule.core.nutrients = vec![
280            Nutrient {
281                kind: "web".to_string(),
282                address: None,
283                recipient: None,
284                url: Some("https://example.com/sponsor".to_string()),
285                label: Some("Sponsor".to_string()),
286                chain_id: None,
287                token: None,
288                asset_id: None,
289            },
290            Nutrient {
291                kind: "evm".to_string(),
292                address: Some("0x1234567890abcdef1234567890abcdef12345678".to_string()),
293                recipient: None,
294                url: None,
295                label: Some("ETH".to_string()),
296                chain_id: Some(1),
297                token: Some("ETH".to_string()),
298                asset_id: None,
299            },
300        ];
301
302        let json = serde_json::to_string(&mycelium).unwrap_or_default();
303        assert!(json.contains("\"nutrients\""));
304        assert!(json.contains("https://example.com/sponsor"));
305        assert!(json.contains("0x1234567890abcdef1234567890abcdef12345678"));
306
307        let parsed: Mycelium = serde_json::from_str(&json).unwrap();
308        assert_eq!(parsed.capsule.core.nutrients.len(), 2);
309        assert_eq!(parsed.capsule.core.nutrients[0].kind, "web");
310        assert_eq!(parsed.capsule.core.nutrients[1].kind, "evm");
311        assert_eq!(parsed.capsule.core.nutrients[1].chain_id, Some(1));
312    }
313
314    #[test]
315    fn test_mycelium_nutrients_serialization() {
316        let nutrient = Nutrient {
317            kind: "bitcoin".to_string(),
318            address: Some("bc1qexampleaddress".to_string()),
319            recipient: Some("donations@example.com".to_string()),
320            url: Some("https://example.com/donate".to_string()),
321            label: Some("Bitcoin".to_string()),
322            chain_id: None,
323            token: None,
324            asset_id: None,
325        };
326
327        let json = serde_json::to_string(&nutrient).unwrap_or_default();
328        assert!(json.contains("\"type\":\"bitcoin\""));
329        assert!(json.contains("bc1qexampleaddress"));
330        assert!(json.contains("donations@example.com"));
331        assert!(json.contains("https://example.com/donate"));
332
333        let parsed: Nutrient = serde_json::from_str(&json).unwrap();
334        assert_eq!(parsed.kind, "bitcoin");
335        assert_eq!(parsed.address, Some("bc1qexampleaddress".to_string()));
336        assert_eq!(parsed.recipient, Some("donations@example.com".to_string()));
337        assert_eq!(parsed.url, Some("https://example.com/donate".to_string()));
338    }
339}