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