Skip to main content

hypha/cache/
dir.rs

1use std::path::PathBuf;
2
3use crate::sink::HyphaError;
4
5use super::{
6    dir_size, read_spore_metadata, CachedSpore, DomainCache, HyphaConfig, TasteVerdictCache,
7};
8
9/// Marker file written into cache directories that hypha created itself.
10/// `clean --all` refuses to purge a directory lacking this marker (unless it is
11/// the default cache location), so a misconfigured `cache.path` pointing at
12/// unrelated data is never wiped.
13const CACHE_SENTINEL: &str = ".cmn-cache";
14
15/// Cache directory structure
16pub struct CacheDir {
17    pub root: PathBuf,
18    pub cmn_ttl_ms: u64,
19    pub spore_max_download_bytes: u64,
20    pub spore_max_extract_bytes: u64,
21    pub spore_max_extract_files: u64,
22    pub spore_max_extract_file_bytes: u64,
23    pub spore_reject_path_components: Vec<String>,
24}
25
26impl CacheDir {
27    /// Create a new CacheDir under $CMN_HOME/hypha/cache/ (or [cache] path from config.toml)
28    pub fn new() -> Result<Self, crate::sink::HyphaError> {
29        let cfg = HyphaConfig::load()?;
30        Self::from_config(&cfg)
31    }
32
33    fn from_config(cfg: &crate::config::HyphaConfig) -> Result<Self, crate::sink::HyphaError> {
34        let root = match &cfg.cache.path {
35            Some(p) => PathBuf::from(p),
36            None => crate::config::hypha_dir().join("cache"),
37        };
38
39        if !root.exists() {
40            std::fs::create_dir_all(&root).map_err(|e| {
41                HyphaError::new(
42                    "cache_dir_error",
43                    format!("Failed to create cache directory {}: {}", root.display(), e),
44                )
45            })?;
46            #[cfg(unix)]
47            {
48                use std::os::unix::fs::PermissionsExt;
49                std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).map_err(
50                    |e| {
51                        HyphaError::new(
52                            "cache_dir_error",
53                            format!(
54                                "Failed to protect cache directory {}: {}",
55                                root.display(),
56                                e
57                            ),
58                        )
59                    },
60                )?;
61            }
62            // Mark this directory as a hypha-created cache so clean_all can
63            // safely purge it later. Best-effort: a missing marker only makes
64            // clean more conservative.
65            let _ = std::fs::write(root.join(CACHE_SENTINEL), b"cmn hypha cache\n");
66        }
67
68        Ok(Self {
69            root,
70            cmn_ttl_ms: cfg.cache.cmn_ttl_s * 1000,
71            spore_max_download_bytes: cfg.cache.spore_max_download_bytes,
72            spore_max_extract_bytes: cfg.cache.spore_max_extract_bytes,
73            spore_max_extract_files: cfg.cache.spore_max_extract_files,
74            spore_max_extract_file_bytes: cfg.cache.spore_max_extract_file_bytes,
75            spore_reject_path_components: cfg.cache.spore_reject_path_components.clone(),
76        })
77    }
78
79    /// Get the domain cache helper
80    pub fn domain(&self, domain: &str) -> DomainCache {
81        DomainCache {
82            root: self.root.join(domain),
83            domain: domain.to_string(),
84        }
85    }
86
87    /// Get the cache path for a specific spore (legacy compatibility)
88    pub fn spore_path(&self, domain: &str, hash: &str) -> PathBuf {
89        self.domain(domain).spore_path(hash)
90    }
91
92    /// List all cached spores
93    pub fn list_all(&self) -> Vec<CachedSpore> {
94        let mut spores = Vec::new();
95
96        if !self.root.exists() {
97            return spores;
98        }
99
100        if let Ok(domains) = std::fs::read_dir(&self.root) {
101            for domain_entry in domains.filter_map(|e| e.ok()) {
102                let domain_path = domain_entry.path();
103                if !domain_path.is_dir() {
104                    continue;
105                }
106
107                let domain = domain_entry.file_name().to_string_lossy().to_string();
108                let domain_cache = self.domain(&domain);
109
110                let spore_dir = domain_cache.spore_dir();
111                if let Ok(hashes) = std::fs::read_dir(&spore_dir) {
112                    for hash_entry in hashes.filter_map(|e| e.ok()) {
113                        let hash_path = hash_entry.path();
114                        if !hash_path.is_dir() {
115                            continue;
116                        }
117
118                        let hash_dir = hash_entry.file_name().to_string_lossy().to_string();
119                        let hash = hash_dir.replace('_', ":");
120                        let manifest_path = hash_path.join("spore.json");
121                        let (name, synopsis) = read_spore_metadata(&manifest_path);
122
123                        let verdict = {
124                            let taste_path = hash_path.join("taste.json");
125                            if taste_path.exists() {
126                                std::fs::read_to_string(&taste_path)
127                                    .ok()
128                                    .and_then(|s| {
129                                        serde_json::from_str::<TasteVerdictCache>(&s).ok()
130                                    })
131                                    .map(|v| v.verdict)
132                            } else {
133                                None
134                            }
135                        };
136
137                        let size = dir_size(&hash_path);
138
139                        spores.push(CachedSpore {
140                            domain: domain.clone(),
141                            hash,
142                            name,
143                            synopsis,
144                            path: hash_path,
145                            size,
146                            verdict,
147                        });
148                    }
149                }
150            }
151        }
152
153        spores
154    }
155
156    /// Remove all cached items.
157    ///
158    /// Only purges a directory hypha recognizes as its own — either the default
159    /// cache location or one bearing the [`CACHE_SENTINEL`] marker — and removes
160    /// only the managed subtrees, leaving the cache root (and marker) in place.
161    pub fn clean_all(&self) -> Result<usize, crate::sink::HyphaError> {
162        if !self.root.exists() {
163            return Ok(0);
164        }
165
166        let is_default = self.root == crate::config::hypha_dir().join("cache");
167        let sentinel = self.root.join(CACHE_SENTINEL);
168        if !is_default && !sentinel.exists() {
169            return Err(HyphaError::with_hint(
170                "cache_clean_refused",
171                format!(
172                    "Refusing to clean {}: not a recognized hypha cache (missing {} marker)",
173                    self.root.display(),
174                    CACHE_SENTINEL
175                ),
176                "if this really is your cache directory, remove its contents manually",
177            ));
178        }
179
180        let count = self.list_all().len();
181
182        // Remove only subdirectories (per-domain caches), preserving the root
183        // and the sentinel so a user-configured cache.path is never deleted.
184        if let Ok(entries) = std::fs::read_dir(&self.root) {
185            for entry in entries.filter_map(|e| e.ok()) {
186                let path = entry.path();
187                if !path.is_dir() {
188                    continue;
189                }
190                std::fs::remove_dir_all(&path).map_err(|e| {
191                    HyphaError::new(
192                        "cache_clean_failed",
193                        format!("Failed to remove {}: {}", path.display(), e),
194                    )
195                })?;
196            }
197        }
198
199        Ok(count)
200    }
201}
202
203impl CacheDir {
204    /// Create a CacheDir with explicit TTL values (for testing)
205    #[cfg(test)]
206    pub fn with_root(root: PathBuf) -> Self {
207        Self {
208            root,
209            cmn_ttl_ms: 300 * 1000,
210            spore_max_download_bytes: 1024 * 1024 * 1024,
211            spore_max_extract_bytes: 512 * 1024 * 1024,
212            spore_max_extract_files: 100_000,
213            spore_max_extract_file_bytes: 256 * 1024 * 1024,
214            spore_reject_path_components: vec![".git".to_string(), ".cmn".to_string()],
215        }
216    }
217}