1use std::collections::HashMap;
23use std::fs::File;
24use std::io;
25use std::path::{Path, PathBuf};
26use std::sync::{Mutex, OnceLock};
27use std::time::Duration;
28
29const LOCK_FILE: &str = ".lock";
32const SCRATCH_SUBDIR: &str = "scratchpad";
34pub use mermaid_model::constants::SCRATCHPAD_RETENTION_DAYS as RETENTION_DAYS;
37const MAX_COMPONENT_LEN: usize = 96;
40const MAX_LIST_ENTRIES: usize = 100;
43
44static HELD_LOCKS: OnceLock<Mutex<HashMap<PathBuf, File>>> = OnceLock::new();
50
51fn scratch_root_in(temp: &Path) -> PathBuf {
55 #[cfg(unix)]
56 {
57 temp.join(format!("mermaid-{}", rustix::process::getuid().as_raw()))
58 }
59 #[cfg(not(unix))]
60 {
61 temp.join("mermaid")
62 }
63}
64
65fn scratch_root() -> PathBuf {
66 scratch_root_in(&std::env::temp_dir())
67}
68
69#[must_use]
73pub fn project_slug(project: &Path) -> String {
74 sanitize_component(&project.display().to_string(), "project")
75}
76
77fn sanitize_component(raw: &str, fallback: &str) -> String {
83 let mut out: String = raw
84 .chars()
85 .map(|c| {
86 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
87 c
88 } else {
89 '-'
90 }
91 })
92 .collect();
93 out.truncate(MAX_COMPONENT_LEN);
94 if out.is_empty() {
95 fallback.to_string()
96 } else {
97 out
98 }
99}
100
101#[must_use]
105pub fn session_dir(root: &Path, project: &Path, session_id: &str) -> PathBuf {
106 root.join(project_slug(project))
107 .join(sanitize_component(session_id, "session"))
108}
109
110pub fn ensure(project: &Path, session_id: &str) -> io::Result<PathBuf> {
125 ensure_in(&scratch_root(), project, session_id)
126}
127
128fn ensure_in(root: &Path, project: &Path, session_id: &str) -> io::Result<PathBuf> {
131 let dir = session_dir(root, project, session_id);
132 let scratch = dir.join(SCRATCH_SUBDIR);
133 std::fs::create_dir_all(&scratch)?;
134 #[cfg(unix)]
135 {
136 use std::os::unix::fs::PermissionsExt;
137 for level in [root, dir.parent().unwrap_or(root), &dir, &scratch] {
141 let _ = std::fs::set_permissions(level, std::fs::Permissions::from_mode(0o700));
142 }
143 }
144 let held = HELD_LOCKS.get_or_init(Mutex::default);
145 let mut held = held.lock().expect("scratchpad lock registry poisoned");
146 if !held.contains_key(&dir) {
147 let lock = File::create(dir.join(LOCK_FILE))?;
148 match lock.try_lock() {
149 Ok(()) => {
152 held.insert(dir.clone(), lock);
153 },
154 Err(std::fs::TryLockError::WouldBlock) => {},
157 Err(std::fs::TryLockError::Error(err)) => return Err(err),
158 }
159 }
160 Ok(scratch)
161}
162
163fn lock_is_held(dir: &Path) -> bool {
167 if let Ok(held) = HELD_LOCKS.get_or_init(Mutex::default).lock()
168 && held.contains_key(dir)
169 {
170 return true;
171 }
172 let Ok(lock) = File::open(dir.join(LOCK_FILE)) else {
173 return false;
174 };
175 match lock.try_lock() {
176 Err(std::fs::TryLockError::WouldBlock) => true,
177 _ => false,
180 }
181}
182
183pub fn sweep_stale(retention_days: u64) -> io::Result<u64> {
192 sweep_stale_in(&scratch_root(), retention_days)
193}
194
195pub fn sweep_stale_in(root: &Path, retention_days: u64) -> io::Result<u64> {
206 if !root.exists() {
207 return Ok(0);
208 }
209 let cutoff = Duration::from_secs(retention_days * 24 * 60 * 60);
210 let mut removed = 0u64;
211 for project in std::fs::read_dir(root)? {
212 let project = project?;
213 if !project.file_type()?.is_dir() {
214 continue;
215 }
216 for session in std::fs::read_dir(project.path())? {
217 let session = session?;
218 let dir = session.path();
219 if !session.file_type()?.is_dir() {
220 continue;
221 }
222 if lock_is_held(&dir) {
225 continue;
226 }
227 let stale = session
230 .metadata()
231 .and_then(|m| m.modified())
232 .ok()
233 .and_then(|mtime| mtime.elapsed().ok())
234 .is_some_and(|age| age >= cutoff);
235 if stale && std::fs::remove_dir_all(&dir).is_ok() {
236 removed += 1;
237 }
238 }
239 let _ = std::fs::remove_dir(project.path());
242 }
243 Ok(removed)
244}
245
246pub fn remove(project: &Path, session_id: &str) -> io::Result<()> {
257 remove_in(&scratch_root(), project, session_id)
258}
259
260fn remove_in(root: &Path, project: &Path, session_id: &str) -> io::Result<()> {
262 let dir = session_dir(root, project, session_id);
263 if !dir.exists() || lock_is_held(&dir) {
264 return Ok(());
265 }
266 std::fs::remove_dir_all(&dir)
267}
268
269#[must_use]
275pub fn list_text(dir: &Path) -> String {
276 let mut out = format!("Scratchpad: {}", dir.display());
277 let mut entries = Vec::new();
278 let mut truncated = false;
279 collect_entries(dir, "", &mut entries, &mut truncated);
280 if entries.is_empty() {
281 out.push_str("\n (empty)");
282 return out;
283 }
284 for line in &entries {
285 out.push_str("\n ");
286 out.push_str(line);
287 }
288 if truncated {
289 out.push_str("\n ... (listing capped)");
290 }
291 out
292}
293
294fn collect_entries(dir: &Path, rel: &str, entries: &mut Vec<String>, truncated: &mut bool) {
300 let Ok(read) = std::fs::read_dir(dir) else {
301 return;
302 };
303 let mut children: Vec<_> = read.flatten().map(|e| e.path()).collect();
304 children.sort();
305 for path in children {
306 if entries.len() >= MAX_LIST_ENTRIES {
307 *truncated = true;
308 return;
309 }
310 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
311 continue;
312 };
313 let child_rel = if rel.is_empty() {
314 name.to_string()
315 } else {
316 format!("{rel}/{name}")
317 };
318 if path.is_dir() {
319 entries.push(format!("{child_rel}/"));
320 collect_entries(&path, &child_rel, entries, truncated);
321 } else {
322 let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
323 entries.push(format!("{child_rel} ({})", human_size(size)));
324 }
325 }
326}
327
328fn human_size(bytes: u64) -> String {
330 if bytes < 1024 {
331 format!("{bytes} B")
332 } else if bytes < 1024 * 1024 {
333 format!("{:.1} KB", bytes as f64 / 1024.0)
334 } else {
335 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 fn temp_root(name: &str) -> PathBuf {
345 let dir = std::env::temp_dir().join(format!(
346 "mermaid_scratchpad_{}_{}",
347 name,
348 std::process::id()
349 ));
350 let _ = std::fs::remove_dir_all(&dir);
351 dir
352 }
353
354 fn release_lock(session_dir: &Path) {
357 HELD_LOCKS
358 .get_or_init(Mutex::default)
359 .lock()
360 .expect("registry")
361 .remove(session_dir);
362 }
363
364 #[test]
365 fn scratch_root_is_per_user_under_system_temp() {
366 let root = scratch_root_in(Path::new("/tmp"));
367 #[cfg(unix)]
368 assert_eq!(
369 root,
370 Path::new("/tmp").join(format!("mermaid-{}", rustix::process::getuid().as_raw()))
371 );
372 #[cfg(not(unix))]
373 assert_eq!(root, Path::new("/tmp").join("mermaid"));
374 }
375
376 #[test]
377 fn project_slug_table() {
378 let cases: &[(&str, &str)] = &[
379 ("/home/user/my proj", "-home-user-my-proj"),
380 ("/", "-"),
381 ("relative-dir", "relative-dir"),
382 ("under_score", "under_score"),
383 ("", "project"),
384 ];
385 for (input, expected) in cases {
386 assert_eq!(
387 project_slug(Path::new(input)),
388 *expected,
389 "slug of {input:?}"
390 );
391 }
392 }
393
394 #[test]
395 fn project_slug_truncates_long_paths() {
396 let long = "/a".repeat(200);
397 let slug = project_slug(Path::new(&long));
398 assert_eq!(slug.len(), MAX_COMPONENT_LEN);
399 }
400
401 #[test]
402 fn session_dir_confines_a_hostile_session_id() {
403 let root = Path::new("/data/scratchpad");
407 let dir = session_dir(root, Path::new("/proj"), "../../evil");
408 assert!(dir.starts_with(root.join("-proj")));
409 assert!(
410 dir.components()
411 .all(|c| !matches!(c, std::path::Component::ParentDir))
412 );
413 }
414
415 #[test]
416 fn ensure_creates_the_advertised_child_and_holds_the_lock() {
417 let root = temp_root("ensure");
418 let scratch = ensure_in(&root, Path::new("/proj"), "20260710_120000_000").expect("ensure");
419 assert!(scratch.is_dir());
420 assert!(
421 scratch.ends_with(SCRATCH_SUBDIR),
422 "advertised path is the scratchpad child: {}",
423 scratch.display()
424 );
425 let session = scratch.parent().expect("session dir");
426 assert!(
427 session.join(LOCK_FILE).is_file(),
428 "lock sits OUTSIDE the advertised dir"
429 );
430 assert!(lock_is_held(session), "ensure holds the liveness lock");
431 let again = ensure_in(&root, Path::new("/proj"), "20260710_120000_000").expect("ensure");
433 assert_eq!(scratch, again);
434 release_lock(session);
435 let _ = std::fs::remove_dir_all(&root);
436 }
437
438 #[cfg(unix)]
439 #[test]
440 fn ensure_tightens_perms_to_owner_only() {
441 use std::os::unix::fs::PermissionsExt;
442 let root = temp_root("perms");
443 let scratch = ensure_in(&root, Path::new("/proj"), "20260710_120000_000").expect("ensure");
444 let session = scratch.parent().expect("session dir").to_path_buf();
445 for level in [&root, &session, &scratch] {
446 let mode = std::fs::metadata(level).expect("meta").permissions().mode();
447 assert_eq!(mode & 0o777, 0o700, "mode of {}", level.display());
448 }
449 release_lock(&session);
450 let _ = std::fs::remove_dir_all(&root);
451 }
452
453 #[test]
454 fn sweep_lock_table() {
455 let root = temp_root("sweep");
460 let live = ensure_in(&root, Path::new("/proj"), "live").expect("ensure");
461 let live_session = live.parent().expect("session").to_path_buf();
462 let abandoned = ensure_in(&root, Path::new("/proj"), "abandoned").expect("ensure");
463 let abandoned_session = abandoned.parent().expect("session").to_path_buf();
464 release_lock(&abandoned_session); let lockless = ensure_in(&root, Path::new("/proj"), "lockless").expect("ensure");
466 let lockless_session = lockless.parent().expect("session").to_path_buf();
467 release_lock(&lockless_session);
468 std::fs::remove_file(lockless_session.join(LOCK_FILE)).expect("drop lock file");
469
470 let removed = sweep_stale_in(&root, 0).expect("sweep");
471 assert_eq!(removed, 2, "released + lockless reaped");
472 assert!(live.is_dir(), "a held lock protects the dir");
473 assert!(!abandoned_session.exists());
474 assert!(!lockless_session.exists());
475
476 let fresh = ensure_in(&root, Path::new("/proj"), "fresh").expect("ensure");
478 let fresh_session = fresh.parent().expect("session").to_path_buf();
479 release_lock(&fresh_session);
480 let removed = sweep_stale_in(&root, RETENTION_DAYS).expect("sweep");
481 assert_eq!(removed, 0);
482 assert!(fresh.is_dir(), "young dirs are kept even without a lock");
483 release_lock(&live_session);
484 let _ = std::fs::remove_dir_all(&root);
485 }
486
487 #[test]
488 fn sweep_of_a_missing_root_is_a_noop() {
489 let root = temp_root("missing");
490 assert_eq!(sweep_stale_in(&root, 0).expect("sweep"), 0);
491 }
492
493 #[test]
494 fn remove_cascades_unheld_dirs_but_spares_live_ones() {
495 let root = temp_root("remove");
496 let live = ensure_in(&root, Path::new("/proj"), "live").expect("ensure");
498 let live_session = live.parent().expect("session").to_path_buf();
499 remove_in(&root, Path::new("/proj"), "live").expect("remove");
500 assert!(live.is_dir(), "a held lock protects the dir from cascade");
501 let gone = ensure_in(&root, Path::new("/proj"), "gone").expect("ensure");
503 let gone_session = gone.parent().expect("session").to_path_buf();
504 release_lock(&gone_session);
505 remove_in(&root, Path::new("/proj"), "gone").expect("remove");
506 assert!(!gone_session.exists());
507 remove_in(&root, Path::new("/proj"), "never-existed").expect("remove");
509 release_lock(&live_session);
510 let _ = std::fs::remove_dir_all(&root);
511 }
512
513 #[test]
514 fn list_text_is_sorted_and_bounded() {
515 let root = temp_root("list");
516 let dir = ensure_in(&root, Path::new("/proj"), "s").expect("ensure");
517 let session = dir.parent().expect("session").to_path_buf();
518 assert_eq!(
519 list_text(&dir),
520 format!("Scratchpad: {}\n (empty)", dir.display()),
521 "a fresh scratchpad reads as empty (the lock is outside it)"
522 );
523 std::fs::write(dir.join("b.txt"), b"hello").expect("write");
524 std::fs::create_dir(dir.join("a")).expect("mkdir");
525 std::fs::write(dir.join("a").join("nested.log"), vec![0u8; 2048]).expect("write");
526 let text = list_text(&dir);
527 assert!(text.is_ascii(), "listing must be pure ASCII");
528 assert_eq!(
529 text,
530 format!(
531 "Scratchpad: {}\n a/\n a/nested.log (2.0 KB)\n b.txt (5 B)",
532 dir.display()
533 )
534 );
535 for i in 0..(MAX_LIST_ENTRIES + 10) {
537 std::fs::write(dir.join(format!("f{i:04}.tmp")), b"x").expect("write");
538 }
539 let text = list_text(&dir);
540 assert_eq!(
541 text.lines().count(),
542 1 + MAX_LIST_ENTRIES + 1,
543 "header + cap + marker"
544 );
545 assert!(text.ends_with("... (listing capped)"));
546 release_lock(&session);
547 let _ = std::fs::remove_dir_all(&root);
548 }
549
550 #[test]
551 fn human_size_units() {
552 assert_eq!(human_size(0), "0 B");
553 assert_eq!(human_size(1023), "1023 B");
554 assert_eq!(human_size(1536), "1.5 KB");
555 assert_eq!(human_size(3 * 1024 * 1024), "3.0 MB");
556 }
557}