Skip to main content

hypha/cache/
domain.rs

1use std::path::PathBuf;
2
3use substrate::CmnEntry;
4
5use crate::sink::HyphaError;
6
7use super::{
8    locked_update_file, locked_write_file, CacheStatus, DomainStatePin, FetchStatus, KeyTrustEntry,
9    TasteVerdictCache,
10};
11
12pub const DOMAIN_STATE_JUMP_THRESHOLD: u64 = 1000;
13
14/// Domain-specific cache operations
15pub struct DomainCache {
16    pub root: PathBuf,
17    pub domain: String,
18}
19
20impl DomainCache {
21    /// Get the mycelium metadata directory
22    pub fn mycelium_dir(&self) -> PathBuf {
23        self.root.join("mycelium")
24    }
25
26    /// Get the spore cache directory
27    pub fn spore_dir(&self) -> PathBuf {
28        self.root.join("spore")
29    }
30
31    /// Get the cache path for a specific spore
32    pub fn spore_path(&self, hash: &str) -> PathBuf {
33        self.spore_dir().join(hash)
34    }
35
36    /// Get the repos cache directory for this domain
37    pub fn repos_dir(&self) -> PathBuf {
38        self.root.join("repos")
39    }
40
41    /// Get the cache path for a specific repository by root_commit
42    pub fn repo_path(&self, root_commit: &str) -> PathBuf {
43        self.repos_dir().join(root_commit)
44    }
45
46    /// Get path to cached cmn.json
47    pub fn cmn_path(&self) -> PathBuf {
48        self.mycelium_dir().join("cmn.json")
49    }
50
51    /// Get path to the local cmn.json domain-state pin.
52    pub fn domain_state_path(&self) -> PathBuf {
53        self.mycelium_dir().join("domain_state.json")
54    }
55
56    /// Load cached cmn.json entry
57    pub fn load_cmn(&self) -> Option<CmnEntry> {
58        let path = self.cmn_path();
59        if path.exists() {
60            std::fs::read_to_string(&path)
61                .ok()
62                .and_then(|s| serde_json::from_str(&s).ok())
63        } else {
64            None
65        }
66    }
67
68    /// Save cmn.json entry to cache
69    pub fn save_cmn(&self, entry: &CmnEntry) -> Result<(), crate::sink::HyphaError> {
70        let dir = self.mycelium_dir();
71        std::fs::create_dir_all(&dir).map_err(|e| {
72            HyphaError::new(
73                "cache_write_failed",
74                format!("Failed to create mycelium dir: {}", e),
75            )
76        })?;
77
78        let content = serde_json::to_string_pretty(entry).map_err(|e| {
79            HyphaError::new(
80                "cache_write_failed",
81                format!("Failed to serialize cmn entry: {}", e),
82            )
83        })?;
84
85        locked_write_file(&self.cmn_path(), &content)
86    }
87
88    /// Load the local anti-rollback pin for this domain.
89    pub fn load_domain_state(&self) -> Option<DomainStatePin> {
90        let path = self.domain_state_path();
91        if path.exists() {
92            std::fs::read_to_string(&path)
93                .ok()
94                .and_then(|s| serde_json::from_str(&s).ok())
95        } else {
96            None
97        }
98    }
99
100    /// Verify the fetched cmn.json against the local pin and advance the pin.
101    pub fn validate_and_pin_cmn_state(
102        &self,
103        entry: &CmnEntry,
104    ) -> Result<(), crate::sink::HyphaError> {
105        let capsule = entry.primary_capsule().map_err(|e| {
106            HyphaError::new("domain_state_invalid", format!("Invalid cmn.json: {e}"))
107        })?;
108        let expected_uri = substrate::build_domain_uri(&self.domain);
109        if capsule.uri != expected_uri {
110            return Err(HyphaError::new(
111                "domain_state_invalid",
112                format!(
113                    "cmn.json primary capsule uri {} does not match domain {}",
114                    capsule.uri, self.domain
115                ),
116            ));
117        }
118        let digest = entry.capsules_digest().map_err(|e| {
119            HyphaError::new(
120                "domain_state_invalid",
121                format!("Failed to digest cmn.json capsules: {e}"),
122            )
123        })?;
124
125        if let Some(pin) = self.load_domain_state() {
126            if capsule.serial < pin.serial {
127                return Err(HyphaError::new(
128                    "domain_state_rollback",
129                    format!(
130                        "cmn.json serial {} is lower than pinned serial {} for {}",
131                        capsule.serial, pin.serial, self.domain
132                    ),
133                ));
134            }
135            if capsule.serial == pin.serial && digest != pin.capsules_digest {
136                return Err(HyphaError::new(
137                    "domain_state_equivocation",
138                    format!(
139                        "cmn.json serial {} for {} has a different capsules digest than the local pin",
140                        capsule.serial, self.domain
141                    ),
142                ));
143            }
144            if capsule.serial.saturating_sub(pin.serial) > DOMAIN_STATE_JUMP_THRESHOLD {
145                return Err(HyphaError::new(
146                    "domain_state_jump",
147                    format!(
148                        "cmn.json serial jumped from {} to {} for {}",
149                        pin.serial, capsule.serial, self.domain
150                    ),
151                ));
152            }
153            if capsule.key != pin.current_key {
154                capsule
155                    .verify_rotation_chain_from(&pin.current_key)
156                    .map_err(|e| {
157                        HyphaError::new(
158                            "domain_key_rotation_unproven",
159                            format!(
160                                "cmn.json key changed for {} without a valid rotation chain: {e}",
161                                self.domain
162                            ),
163                        )
164                    })?;
165            }
166        }
167
168        self.save_domain_state(&DomainStatePin {
169            serial: capsule.serial,
170            capsules_digest: digest,
171            current_key: capsule.key.clone(),
172            pinned_at_epoch_ms: crate::time::now_epoch_ms(),
173        })
174    }
175
176    fn save_domain_state(&self, pin: &DomainStatePin) -> Result<(), crate::sink::HyphaError> {
177        let dir = self.mycelium_dir();
178        std::fs::create_dir_all(&dir).map_err(|e| {
179            HyphaError::new(
180                "cache_write_failed",
181                format!("Failed to create mycelium dir: {}", e),
182            )
183        })?;
184
185        let content = serde_json::to_string_pretty(pin).map_err(|e| {
186            HyphaError::new(
187                "cache_write_failed",
188                format!("Failed to serialize domain state: {}", e),
189            )
190        })?;
191
192        locked_write_file(&self.domain_state_path(), &content)
193    }
194
195    /// Get path to mycelium.json (complete manifest with spores list)
196    pub fn mycelium_path(&self) -> PathBuf {
197        self.mycelium_dir().join("mycelium.json")
198    }
199
200    /// Load cached full mycelium manifest
201    pub fn load_mycelium(&self) -> Option<serde_json::Value> {
202        let path = self.mycelium_path();
203        if path.exists() {
204            std::fs::read_to_string(&path)
205                .ok()
206                .and_then(|s| serde_json::from_str(&s).ok())
207        } else {
208            None
209        }
210    }
211
212    /// Save full mycelium manifest to cache
213    pub fn save_mycelium(
214        &self,
215        mycelium: &serde_json::Value,
216    ) -> Result<(), crate::sink::HyphaError> {
217        let dir = self.mycelium_dir();
218        std::fs::create_dir_all(&dir).map_err(|e| {
219            HyphaError::new(
220                "cache_write_failed",
221                format!("Failed to create mycelium dir: {}", e),
222            )
223        })?;
224
225        let content = crate::mycelium::format_mycelium(mycelium).map_err(|e| {
226            HyphaError::new(
227                "cache_write_failed",
228                format!("Failed to serialize mycelium: {}", e),
229            )
230        })?;
231
232        locked_write_file(&self.mycelium_path(), &content)
233    }
234
235    /// Get path to status.json
236    pub fn status_path(&self) -> PathBuf {
237        self.mycelium_dir().join("status.json")
238    }
239
240    /// Load cache status
241    pub fn load_status(&self) -> CacheStatus {
242        let path = self.status_path();
243        if path.exists() {
244            std::fs::read_to_string(&path)
245                .ok()
246                .and_then(|s| serde_json::from_str(&s).ok())
247                .unwrap_or_default()
248        } else {
249            CacheStatus::default()
250        }
251    }
252
253    /// Save cache status
254    pub fn save_status(&self, status: &CacheStatus) -> Result<(), crate::sink::HyphaError> {
255        let dir = self.mycelium_dir();
256        std::fs::create_dir_all(&dir).map_err(|e| {
257            HyphaError::new(
258                "cache_write_failed",
259                format!("Failed to create mycelium dir: {}", e),
260            )
261        })?;
262
263        let content = serde_json::to_string_pretty(status).map_err(|e| {
264            HyphaError::new(
265                "cache_write_failed",
266                format!("Failed to serialize status: {}", e),
267            )
268        })?;
269
270        locked_write_file(&self.status_path(), &content)
271    }
272
273    /// Get path to domain-level taste.json
274    pub fn domain_taste_path(&self) -> PathBuf {
275        self.mycelium_dir().join("taste.json")
276    }
277
278    /// Load cached taste verdict for the domain itself
279    pub fn load_domain_taste(&self) -> Option<TasteVerdictCache> {
280        let path = self.domain_taste_path();
281        if path.exists() {
282            std::fs::read_to_string(&path)
283                .ok()
284                .and_then(|s| serde_json::from_str(&s).ok())
285        } else {
286            None
287        }
288    }
289
290    /// Save taste verdict for the domain itself
291    pub fn save_domain_taste(
292        &self,
293        verdict: &TasteVerdictCache,
294    ) -> Result<(), crate::sink::HyphaError> {
295        let dir = self.mycelium_dir();
296        std::fs::create_dir_all(&dir).map_err(|e| {
297            HyphaError::new(
298                "cache_write_failed",
299                format!("Failed to create mycelium dir: {}", e),
300            )
301        })?;
302
303        let content = serde_json::to_string_pretty(verdict).map_err(|e| {
304            HyphaError::new(
305                "cache_write_failed",
306                format!("Failed to serialize domain taste verdict: {}", e),
307            )
308        })?;
309
310        locked_write_file(&self.domain_taste_path(), &content)
311    }
312
313    /// Get path to taste.json for a spore
314    pub fn taste_path(&self, hash: &str) -> PathBuf {
315        self.spore_path(hash).join("taste.json")
316    }
317
318    /// Load cached taste verdict for a spore
319    pub fn load_taste(&self, hash: &str) -> Option<TasteVerdictCache> {
320        let path = self.taste_path(hash);
321        if path.exists() {
322            std::fs::read_to_string(&path)
323                .ok()
324                .and_then(|s| serde_json::from_str(&s).ok())
325        } else {
326            None
327        }
328    }
329
330    /// Save taste verdict for a spore
331    pub fn save_taste(
332        &self,
333        hash: &str,
334        verdict: &TasteVerdictCache,
335    ) -> Result<(), crate::sink::HyphaError> {
336        let dir = self.spore_path(hash);
337        std::fs::create_dir_all(&dir).map_err(|e| {
338            HyphaError::new(
339                "cache_write_failed",
340                format!("Failed to create spore dir: {}", e),
341            )
342        })?;
343
344        let content = serde_json::to_string_pretty(verdict).map_err(|e| {
345            HyphaError::new(
346                "cache_write_failed",
347                format!("Failed to serialize taste verdict: {}", e),
348            )
349        })?;
350
351        locked_write_file(&self.taste_path(hash), &content)
352    }
353
354    /// Get path to key_trust.json for this domain
355    pub fn key_trust_path(&self) -> PathBuf {
356        self.mycelium_dir().join("key_trust.json")
357    }
358
359    /// Load cached key trust entries
360    pub fn load_key_trust(&self) -> Vec<KeyTrustEntry> {
361        let path = self.key_trust_path();
362        if path.exists() {
363            std::fs::read_to_string(&path)
364                .ok()
365                .and_then(|s| serde_json::from_str(&s).ok())
366                .unwrap_or_default()
367        } else {
368            Vec::new()
369        }
370    }
371
372    /// Save a key trust entry (domain confirmed this key).
373    ///
374    /// Read-modify-write happens under the directory lock so concurrent
375    /// confirmations can't clobber each other's entries.
376    pub fn save_key_trust(&self, key: &str) -> Result<(), crate::sink::HyphaError> {
377        self.save_key_trust_with_retirement(key, None)
378    }
379
380    /// Save a key trust entry with the retirement cutoff advertised by cmn.json.
381    pub fn save_key_trust_with_retirement(
382        &self,
383        key: &str,
384        retired_at_epoch_ms: Option<u64>,
385    ) -> Result<(), crate::sink::HyphaError> {
386        locked_update_file(&self.key_trust_path(), |existing| {
387            let mut entries: Vec<KeyTrustEntry> = existing
388                .and_then(|s| serde_json::from_str(&s).ok())
389                .unwrap_or_default();
390            if let Some(entry) = entries.iter_mut().find(|e| e.key == key) {
391                entry.confirmed_at_epoch_ms = crate::time::now_epoch_ms();
392                entry.retired_at_epoch_ms = retired_at_epoch_ms;
393            } else {
394                entries.push(KeyTrustEntry {
395                    key: key.to_string(),
396                    confirmed_at_epoch_ms: crate::time::now_epoch_ms(),
397                    retired_at_epoch_ms,
398                });
399            }
400            serde_json::to_string_pretty(&entries).map_err(|e| {
401                HyphaError::new(
402                    "cache_write_failed",
403                    format!("Failed to serialize key trust: {}", e),
404                )
405            })
406        })
407    }
408
409    /// Check if a key is trusted within the given TTL (milliseconds).
410    /// Applies clock skew tolerance to prevent false negatives from clock drift.
411    pub fn is_key_trusted(&self, key: &str, ttl_ms: u64, clock_skew_tolerance_ms: u64) -> bool {
412        self.is_key_trusted_entry(key, ttl_ms, clock_skew_tolerance_ms)
413            .is_some()
414    }
415
416    /// Check if a key is trusted for content signed at the given timestamp.
417    pub fn is_key_trusted_for_time(
418        &self,
419        key: &str,
420        signed_at_epoch_ms: u64,
421        ttl_ms: u64,
422        clock_skew_tolerance_ms: u64,
423    ) -> bool {
424        self.is_key_trusted_entry(key, ttl_ms, clock_skew_tolerance_ms)
425            .map(|entry| {
426                entry
427                    .retired_at_epoch_ms
428                    .map(|retired_at| signed_at_epoch_ms <= retired_at)
429                    .unwrap_or(true)
430            })
431            .unwrap_or(false)
432    }
433
434    fn is_key_trusted_entry(
435        &self,
436        key: &str,
437        ttl_ms: u64,
438        clock_skew_tolerance_ms: u64,
439    ) -> Option<KeyTrustEntry> {
440        let entries = self.load_key_trust();
441        let now = crate::time::now_epoch_ms();
442        let effective_ttl = ttl_ms.saturating_add(clock_skew_tolerance_ms);
443        entries
444            .into_iter()
445            .find(|e| e.key == key && now.saturating_sub(e.confirmed_at_epoch_ms) < effective_ttl)
446    }
447
448    /// Update cmn.json fetch status (locked read-modify-write).
449    pub fn update_cmn_status(&self, success: bool, error: Option<&str>) {
450        let _ = locked_update_file(&self.status_path(), |existing| {
451            let mut status: CacheStatus = existing
452                .and_then(|s| serde_json::from_str(&s).ok())
453                .unwrap_or_default();
454            if success {
455                status.cmn = FetchStatus::success();
456            } else {
457                status.cmn =
458                    FetchStatus::failure(error.unwrap_or("Unknown error"), Some(&status.cmn));
459            }
460            serde_json::to_string_pretty(&status).map_err(|e| {
461                HyphaError::new(
462                    "cache_write_failed",
463                    format!("Failed to serialize status: {}", e),
464                )
465            })
466        });
467    }
468}