1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4pub const MYCELIUM_SCHEMA: &str = "https://cmn.dev/schemas/v1/mycelium.json";
5
6#[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#[derive(Serialize, Deserialize, Debug, Clone)]
17pub struct MyceliumCapsule {
18 pub uri: String,
19 pub core: MyceliumCore,
20 pub core_signature: String,
21}
22
23#[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#[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#[derive(Serialize, Deserialize, Debug, Clone)]
50pub struct MyceliumCoreTaste {
51 pub hash: String,
52 pub target_uri: String,
53}
54
55#[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}