Skip to main content

hypha/mycelium/
mod.rs

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/// Error type for mycelium sign-and-save operations.
36#[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    /// Return an error code suitable for Agent-First Data output.
65    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
78/// Sign mycelium core, compute hash, write mycelium file and update cmn.json.
79/// Returns the new mycelium hash on success.
80fn 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    // Sign core
92    let core_signature = auth::sign_json_with_site(site, &mycelium.capsule.core)?;
93    mycelium.capsule.core_signature = core_signature.clone();
94
95    // Compute hash
96    let mycelium_hash = mycelium
97        .computed_uri_hash()
98        .map_err(|e| MyceliumError::Jcs(e.to_string()))?;
99
100    // Write mycelium file
101    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
163/// Load existing mycelium and configured endpoints from a site's cmn.json.
164fn 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        // Verify order: domain, key, name, synopsis, ... then remaining
194        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}