1use crate::auth;
2use crate::site::SiteDir;
3use substrate::{build_mycelium_uri, CmnCapsuleEntry, CmnEndpoint, CmnEntry, Mycelium};
4
5mod format;
6mod init;
7mod inventory;
8mod nutrients;
9mod serve;
10
11pub use format::format_mycelium;
12pub use init::handle_init;
13pub(crate) use inventory::{find_local_spore_hash, resolve_spore_ref};
14pub use inventory::{handle_status, update_inventory};
15pub use nutrients::{handle_nutrient_add, handle_nutrient_clear, handle_nutrient_remove};
16pub use serve::{handle_pulse, handle_serve};
17
18pub struct InitArgs<'a> {
19 pub domain: Option<&'a str>,
20 pub hub: Option<&'a str>,
21 pub site_path: Option<&'a str>,
22 pub name: Option<&'a str>,
23 pub synopsis: Option<&'a str>,
24 pub bio: Option<&'a str>,
25 pub endpoints_base: Option<&'a str>,
26}
27
28fn with_warning(mut data: serde_json::Value, warning: String) -> serde_json::Value {
29 if let serde_json::Value::Object(ref mut fields) = data {
30 fields.insert("warning".to_string(), serde_json::Value::String(warning));
31 }
32 data
33}
34
35#[derive(Debug, thiserror::Error)]
37pub(crate) enum MyceliumError {
38 #[error("{0}")]
39 Identity(#[from] anyhow::Error),
40 #[error("JCS canonicalization failed: {0}")]
41 Jcs(String),
42 #[error("signing failed: {0}")]
43 Sign(String),
44 #[error("{0}")]
45 Io(#[from] std::io::Error),
46 #[error("serialization failed: {0}")]
47 Serialize(#[from] serde_json::Error),
48 #[error("schema validation failed: {0}")]
49 Schema(String),
50 #[error("{0}")]
51 Format(String),
52}
53
54impl From<auth::JsonSignError> for MyceliumError {
55 fn from(e: auth::JsonSignError) -> Self {
56 match e {
57 auth::JsonSignError::Jcs(message) => MyceliumError::Jcs(message),
58 auth::JsonSignError::Sign(err) => MyceliumError::Sign(err.to_string()),
59 }
60 }
61}
62
63impl MyceliumError {
64 pub(crate) fn code(&self) -> &'static str {
66 match self {
67 Self::Identity(_) => "identity_error",
68 Self::Jcs(_) => "jcs_error",
69 Self::Sign(_) => "sign_error",
70 Self::Io(_) => "write_error",
71 Self::Serialize(_) => "serialize_error",
72 Self::Schema(_) => "schema_error",
73 Self::Format(_) => "serialize_error",
74 }
75 }
76}
77
78fn sign_and_save_mycelium(
81 site: &SiteDir,
82 domain: &str,
83 mycelium: &mut Mycelium,
84 endpoints: Vec<CmnEndpoint>,
85 now_epoch_ms: u64,
86) -> Result<String, MyceliumError> {
87 let identity = auth::get_identity_with_site(domain, site)?;
88 mycelium.capsule.core.key = identity.public_key.clone();
89 mycelium.capsule.core.updated_at_epoch_ms = now_epoch_ms;
90
91 let core_signature = auth::sign_json_with_site(site, &mycelium.capsule.core)?;
93 mycelium.capsule.core_signature = core_signature.clone();
94
95 let mycelium_hash = mycelium
97 .computed_uri_hash()
98 .map_err(|e| MyceliumError::Jcs(e.to_string()))?;
99
100 mycelium.capsule.uri = build_mycelium_uri(domain, &mycelium_hash);
102 mycelium.capsule_signature = auth::sign_json_with_site(site, &mycelium.capsule)?;
103
104 let mycelium_dir = site.mycelium_dir();
105 std::fs::create_dir_all(&mycelium_dir)?;
106 let mycelium_file = mycelium_dir.join(format!("{}.json", mycelium_hash));
107 let mycelium_value = serde_json::to_value(&*mycelium)?;
108 substrate::validate_schema(&mycelium_value)
109 .map_err(|e| MyceliumError::Schema(format!("Mycelium: {}", e)))?;
110 let mycelium_json =
111 format_mycelium(&mycelium_value).map_err(|e| MyceliumError::Format(e.to_string()))?;
112 std::fs::write(&mycelium_file, &mycelium_json)?;
113
114 let endpoints = endpoints
115 .into_iter()
116 .map(|mut endpoint| {
117 if endpoint.kind == "mycelium" {
118 endpoint.hash = mycelium_hash.clone();
119 }
120 endpoint
121 })
122 .collect();
123 let existing_capsule = std::fs::read_to_string(site.cmn_json_path())
124 .ok()
125 .and_then(|content| serde_json::from_str::<CmnEntry>(&content).ok())
126 .and_then(|entry| entry.primary_capsule().ok().cloned());
127 let serial = existing_capsule
128 .as_ref()
129 .map(|capsule| capsule.serial.saturating_add(1))
130 .unwrap_or(1);
131 let history = existing_capsule
132 .as_ref()
133 .map(|capsule| capsule.history.clone())
134 .unwrap_or_default();
135
136 let entry = CmnEntry::new(vec![CmnCapsuleEntry {
137 uri: substrate::build_domain_uri(domain),
138 serial,
139 key: identity.public_key.clone(),
140 history,
141 endpoints,
142 }]);
143 let capsule_sig = auth::sign_json_with_site(site, &entry.capsules)?;
144 let signed_entry = CmnEntry {
145 capsule_signature: capsule_sig,
146 ..entry
147 };
148 let entry_value = serde_json::to_value(&signed_entry)?;
149 substrate::validate_schema(&entry_value)
150 .map_err(|e| MyceliumError::Schema(format!("CMN: {}", e)))?;
151 let entry_json = signed_entry
152 .to_pretty_json_deep()
153 .map_err(|e| MyceliumError::Format(format!("Failed to format cmn.json: {}", e)))?;
154 let cmn_path = site.cmn_json_path();
155 if let Some(parent) = cmn_path.parent() {
156 std::fs::create_dir_all(parent)?;
157 }
158 std::fs::write(&cmn_path, &entry_json)?;
159
160 Ok(mycelium_hash)
161}
162
163fn load_existing_mycelium(site: &SiteDir) -> Option<(Mycelium, Vec<CmnEndpoint>)> {
165 let cmn_path = site.cmn_json_path();
166 let content = std::fs::read_to_string(&cmn_path).ok()?;
167 let existing = serde_json::from_str::<CmnEntry>(&content).ok()?;
168 let capsule = existing.primary_capsule().ok()?;
169 let endpoints = capsule.endpoints.clone();
170 let mycelium_hash = capsule.mycelium_hash()?;
171 let filename = format!("{}.json", mycelium_hash);
172 let mycelium_path = site.mycelium_dir().join(filename);
173 let mc = std::fs::read_to_string(&mycelium_path).ok()?;
174 let m = serde_json::from_str::<Mycelium>(&mc).ok()?;
175 Some((m, endpoints))
176}
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
180mod tests {
181
182 use super::*;
183
184 #[test]
185 fn test_format_mycelium_core_key_order() {
186 let mut m = Mycelium::new("example.com", "Example", "A test site", 1);
187 m.capsule.core.key = "ed25519.testkey".to_string();
188 let value = serde_json::to_value(&m).unwrap();
189 let formatted = format_mycelium(&value).unwrap();
190 let parsed: serde_json::Value = serde_json::from_str(&formatted).unwrap();
191 let core = parsed["capsule"]["core"].as_object().unwrap();
192 let keys: Vec<&String> = core.keys().collect();
193 let domain_pos = keys.iter().position(|k| *k == "domain").unwrap();
195 let key_pos = keys.iter().position(|k| *k == "key").unwrap();
196 let name_pos = keys.iter().position(|k| *k == "name").unwrap();
197 let synopsis_pos = keys.iter().position(|k| *k == "synopsis").unwrap();
198 assert!(domain_pos < key_pos);
199 assert!(key_pos < name_pos);
200 assert!(name_pos < synopsis_pos);
201 }
202
203 #[test]
204 fn test_format_mycelium_roundtrip() {
205 let mut m = Mycelium::new("example.com", "Example", "A test site", 1);
206 m.capsule.core.bio = "Some bio".to_string();
207 m.capsule.core.nutrients.push(substrate::Nutrient {
208 kind: "lightning_address".to_string(),
209 address: Some("user@example.com".to_string()),
210 recipient: None,
211 url: None,
212 label: None,
213 chain_id: None,
214 token: None,
215 asset_id: None,
216 });
217 let value = serde_json::to_value(&m).unwrap();
218 let formatted = format_mycelium(&value).unwrap();
219 let parsed: Mycelium = serde_json::from_str(&formatted).unwrap();
220 assert_eq!(parsed.capsule.core.domain, "example.com");
221 assert_eq!(parsed.capsule.core.name, "Example");
222 assert_eq!(parsed.capsule.core.synopsis, "A test site");
223 assert_eq!(parsed.capsule.core.bio, "Some bio");
224 assert_eq!(parsed.capsule.core.nutrients.len(), 1);
225 assert_eq!(parsed.capsule.core.nutrients[0].kind, "lightning_address");
226 assert_eq!(
227 parsed.capsule.core.nutrients[0].address,
228 Some("user@example.com".to_string())
229 );
230 }
231}