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 #[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#[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#[derive(Serialize, Deserialize, Debug, Clone)]
53pub struct MyceliumCoreTaste {
54 pub hash: String,
55 pub target_uri: String,
56}
57
58#[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 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}