1use anyhow::{Context, Result, bail};
10use std::{
11 collections::HashMap,
12 fs,
13 path::{Component, Path, PathBuf},
14};
15
16use crate::Mindmap;
17
18#[derive(Debug)]
20pub struct MindmapCache {
21 cache: HashMap<PathBuf, Mindmap>,
23 workspace_root: PathBuf,
25 max_file_size: u64,
27 max_depth: usize,
29}
30
31impl MindmapCache {
32 pub fn new(workspace_root: PathBuf) -> Self {
34 let canonical_root = fs::canonicalize(&workspace_root)
36 .unwrap_or_else(|_| workspace_root.canonicalize().unwrap_or(workspace_root));
37
38 MindmapCache {
39 cache: HashMap::new(),
40 workspace_root: canonical_root,
41 max_file_size: 10 * 1024 * 1024, max_depth: 50,
43 }
44 }
45
46 pub fn workspace_root(&self) -> &Path {
48 &self.workspace_root
49 }
50
51 pub fn load(
65 &mut self,
66 base_file: &Path,
67 relative: &str,
68 visited: &std::collections::HashSet<PathBuf>,
69 ) -> Result<&Mindmap> {
70 let canonical = self.resolve_path(base_file, relative)?;
72
73 if visited.contains(&canonical) {
75 bail!(
76 "Circular reference detected: {} -> {}",
77 base_file.display(),
78 relative
79 );
80 }
81
82 if self.cache.contains_key(&canonical) {
84 return Ok(self.cache.get(&canonical).unwrap());
85 }
86
87 let metadata = fs::metadata(&canonical)
89 .with_context(|| format!("Failed to stat file: {}", canonical.display()))?;
90
91 if metadata.len() > self.max_file_size {
92 bail!(
93 "File too large: {} bytes (max: {} bytes)",
94 metadata.len(),
95 self.max_file_size
96 );
97 }
98
99 let mm = Mindmap::load(canonical.clone())
101 .with_context(|| format!("Failed to load mindmap: {}", canonical.display()))?;
102
103 self.cache.insert(canonical.clone(), mm);
105 Ok(self.cache.get(&canonical).unwrap())
106 }
107
108 pub fn resolve_path(&self, base_file: &Path, relative: &str) -> Result<PathBuf> {
121 let rel_path = Path::new(relative);
122
123 if rel_path.is_absolute() {
125 bail!("Absolute paths not allowed: {}", relative);
126 }
127
128 for component in rel_path.components() {
130 match component {
131 Component::Prefix(_) | Component::RootDir => {
132 bail!("Absolute paths not allowed: {}", relative);
133 }
134 _ => {}
135 }
136 }
137
138 let base_dir = base_file.parent().unwrap_or(&self.workspace_root);
140
141 let canonical_base = fs::canonicalize(base_dir).unwrap_or_else(|_| base_dir.to_path_buf());
143
144 let full_path = canonical_base.join(rel_path);
146
147 let canonical = fs::canonicalize(&full_path).with_context(|| {
149 format!(
150 "Failed to resolve path: {} (relative to {})",
151 relative,
152 base_dir.display()
153 )
154 })?;
155
156 if !canonical.starts_with(&self.workspace_root) {
158 bail!(
159 "Path escape attempt: {} resolves outside workspace",
160 relative
161 );
162 }
163
164 Ok(canonical)
165 }
166
167 pub fn clear(&mut self) {
169 self.cache.clear();
170 }
171
172 pub fn stats(&self) -> CacheStats {
174 CacheStats {
175 num_cached: self.cache.len(),
176 total_nodes: self.cache.values().map(|mm| mm.nodes.len()).sum(),
177 }
178 }
179
180 #[cfg(test)]
182 pub fn set_max_file_size(&mut self, size: u64) {
183 self.max_file_size = size;
184 }
185
186 #[cfg(test)]
188 pub fn set_max_depth(&mut self, depth: usize) {
189 self.max_depth = depth;
190 }
191
192 pub fn max_depth(&self) -> usize {
194 self.max_depth
195 }
196}
197
198#[derive(Debug, Clone)]
200pub struct CacheStats {
201 pub num_cached: usize,
202 pub total_nodes: usize,
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use std::fs;
209 use tempfile::TempDir;
210
211 #[test]
212 fn test_cache_new() {
213 let cache = MindmapCache::new(PathBuf::from("."));
214 assert_eq!(cache.cache.len(), 0);
215 assert!(cache.workspace_root.is_absolute());
216 }
217
218 #[test]
219 fn test_resolve_path_relative() -> Result<()> {
220 let temp = TempDir::new()?;
221 let base = temp.path().join("subdir");
222 fs::create_dir(&base)?;
223 let base_file = base.join("MINDMAP.md");
224 fs::write(&base_file, "[1] **Test** - body")?;
225
226 let other_file = base.join("other.md");
228 fs::write(&other_file, "[2] **Other** - body")?;
229
230 let cache = MindmapCache::new(temp.path().to_path_buf());
231
232 let resolved = cache.resolve_path(&base_file, "./other.md")?;
234 assert!(resolved.ends_with("other.md"));
235 assert!(resolved.starts_with(cache.workspace_root()));
236
237 Ok(())
238 }
239
240 #[test]
241 fn test_resolve_path_rejects_absolute_posix() {
242 let cache = MindmapCache::new(PathBuf::from("."));
243 let base_file = PathBuf::from("MINDMAP.md");
244
245 let result = cache.resolve_path(&base_file, "/etc/passwd");
247 assert!(result.is_err());
248 assert!(
249 result
250 .unwrap_err()
251 .to_string()
252 .contains("Absolute paths not allowed")
253 );
254 }
255
256 #[test]
257 fn test_resolve_path_rejects_parent_escape() -> Result<()> {
258 let temp = TempDir::new()?;
259 let workspace = temp.path();
260
261 let subdir = workspace.join("subdir");
263 fs::create_dir(&subdir)?;
264 let base_file = subdir.join("MINDMAP.md");
265 fs::write(&base_file, "[1] **Test** - body")?;
266
267 let cache = MindmapCache::new(workspace.to_path_buf());
268
269 let relative = format!(
271 "{}{}",
272 std::path::MAIN_SEPARATOR.to_string().repeat(10),
273 "etc/passwd"
274 );
275
276 let result = cache.resolve_path(&base_file, &relative);
277
278 if result.is_ok() {
280 let resolved = result.unwrap();
281 assert!(
282 !resolved.starts_with(workspace),
283 "Should not resolve outside workspace"
284 );
285 }
286
287 Ok(())
288 }
289
290 #[test]
291 fn test_load_caches_files() -> Result<()> {
292 let temp = TempDir::new()?;
293 let file1 = temp.path().join("MINDMAP.md");
294 fs::write(&file1, "[1] **Test** - body\n")?;
295
296 let mut cache = MindmapCache::new(temp.path().to_path_buf());
297 let visited = std::collections::HashSet::new();
298
299 let mm1_ptr = {
301 let mm1 = cache.load(&file1, "./MINDMAP.md", &visited)?;
302 mm1 as *const _
303 };
304 assert_eq!(cache.cache.len(), 1);
305
306 let mm2_ptr = {
308 let mm2 = cache.load(&file1, "./MINDMAP.md", &visited)?;
309 mm2 as *const _
310 };
311 assert_eq!(cache.cache.len(), 1);
312
313 assert_eq!(mm1_ptr, mm2_ptr);
315
316 Ok(())
317 }
318
319 #[test]
320 fn test_load_detects_cycle() -> Result<()> {
321 let temp = TempDir::new()?;
322 let file1 = temp.path().join("MINDMAP.md");
323 fs::write(&file1, "[1] **Test** - body\n")?;
324
325 let mut cache = MindmapCache::new(temp.path().to_path_buf());
326 let mut visited = std::collections::HashSet::new();
327
328 let canonical = cache.resolve_path(&file1, "./MINDMAP.md")?;
330 visited.insert(canonical.clone());
331
332 let result = cache.load(&file1, "./MINDMAP.md", &visited);
334 assert!(result.is_err());
335 assert!(
336 result
337 .unwrap_err()
338 .to_string()
339 .contains("Circular reference")
340 );
341
342 Ok(())
343 }
344
345 #[test]
346 fn test_load_rejects_oversized_file() -> Result<()> {
347 let temp = TempDir::new()?;
348 let file1 = temp.path().join("big.md");
349
350 let content = "x".repeat(1024 * 1024); fs::write(&file1, &content)?;
353
354 let mut cache = MindmapCache::new(temp.path().to_path_buf());
355 cache.set_max_file_size(1024); let visited = std::collections::HashSet::new();
358 let result = cache.load(&file1, "./big.md", &visited);
359
360 assert!(result.is_err());
361 assert!(result.unwrap_err().to_string().contains("File too large"));
362
363 Ok(())
364 }
365
366 #[test]
367 fn test_cache_stats() -> Result<()> {
368 let temp = TempDir::new()?;
369 let file1 = temp.path().join("MINDMAP.md");
370 fs::write(&file1, "[1] **Test1** - body\n[2] **Test2** - body\n")?;
371
372 let mut cache = MindmapCache::new(temp.path().to_path_buf());
373 let visited = std::collections::HashSet::new();
374
375 cache.load(&file1, "./MINDMAP.md", &visited)?;
376 let stats = cache.stats();
377
378 assert_eq!(stats.num_cached, 1);
379 assert_eq!(stats.total_nodes, 2);
380
381 Ok(())
382 }
383}