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 const RETENTION_DAYS: u64 = 7;
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
69pub fn project_slug(project: &Path) -> String {
73 sanitize_component(&project.display().to_string(), "project")
74}
75
76fn sanitize_component(raw: &str, fallback: &str) -> String {
82 let mut out: String = raw
83 .chars()
84 .map(|c| {
85 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
86 c
87 } else {
88 '-'
89 }
90 })
91 .collect();
92 out.truncate(MAX_COMPONENT_LEN);
93 if out.is_empty() {
94 fallback.to_string()
95 } else {
96 out
97 }
98}
99
100pub fn session_dir(root: &Path, project: &Path, session_id: &str) -> PathBuf {
104 root.join(project_slug(project))
105 .join(sanitize_component(session_id, "session"))
106}
107
108pub fn ensure(project: &Path, session_id: &str) -> io::Result<PathBuf> {
115 ensure_in(&scratch_root(), project, session_id)
116}
117
118fn ensure_in(root: &Path, project: &Path, session_id: &str) -> io::Result<PathBuf> {
121 let dir = session_dir(root, project, session_id);
122 let scratch = dir.join(SCRATCH_SUBDIR);
123 std::fs::create_dir_all(&scratch)?;
124 #[cfg(unix)]
125 {
126 use std::os::unix::fs::PermissionsExt;
127 for level in [root, dir.parent().unwrap_or(root), &dir, &scratch] {
131 let _ = std::fs::set_permissions(level, std::fs::Permissions::from_mode(0o700));
132 }
133 }
134 let held = HELD_LOCKS.get_or_init(Mutex::default);
135 let mut held = held.lock().expect("scratchpad lock registry poisoned");
136 if !held.contains_key(&dir) {
137 let lock = File::create(dir.join(LOCK_FILE))?;
138 match lock.try_lock() {
139 Ok(()) => {
142 held.insert(dir.clone(), lock);
143 },
144 Err(std::fs::TryLockError::WouldBlock) => {},
147 Err(std::fs::TryLockError::Error(err)) => return Err(err),
148 }
149 }
150 Ok(scratch)
151}
152
153fn lock_is_held(dir: &Path) -> bool {
157 if let Ok(held) = HELD_LOCKS.get_or_init(Mutex::default).lock()
158 && held.contains_key(dir)
159 {
160 return true;
161 }
162 let Ok(lock) = File::open(dir.join(LOCK_FILE)) else {
163 return false;
164 };
165 match lock.try_lock() {
166 Err(std::fs::TryLockError::WouldBlock) => true,
167 _ => false,
170 }
171}
172
173pub fn sweep_stale(retention_days: u64) -> io::Result<u64> {
178 sweep_stale_in(&scratch_root(), retention_days)
179}
180
181pub fn sweep_stale_in(root: &Path, retention_days: u64) -> io::Result<u64> {
185 if !root.exists() {
186 return Ok(0);
187 }
188 let cutoff = Duration::from_secs(retention_days * 24 * 60 * 60);
189 let mut removed = 0u64;
190 for project in std::fs::read_dir(root)? {
191 let project = project?;
192 if !project.file_type()?.is_dir() {
193 continue;
194 }
195 for session in std::fs::read_dir(project.path())? {
196 let session = session?;
197 let dir = session.path();
198 if !session.file_type()?.is_dir() {
199 continue;
200 }
201 if lock_is_held(&dir) {
204 continue;
205 }
206 let stale = session
209 .metadata()
210 .and_then(|m| m.modified())
211 .ok()
212 .and_then(|mtime| mtime.elapsed().ok())
213 .is_some_and(|age| age >= cutoff);
214 if stale && std::fs::remove_dir_all(&dir).is_ok() {
215 removed += 1;
216 }
217 }
218 let _ = std::fs::remove_dir(project.path());
221 }
222 Ok(removed)
223}
224
225pub fn remove(project: &Path, session_id: &str) -> io::Result<()> {
230 remove_in(&scratch_root(), project, session_id)
231}
232
233fn remove_in(root: &Path, project: &Path, session_id: &str) -> io::Result<()> {
235 let dir = session_dir(root, project, session_id);
236 if !dir.exists() || lock_is_held(&dir) {
237 return Ok(());
238 }
239 std::fs::remove_dir_all(&dir)
240}
241
242pub fn list_text(dir: &Path) -> String {
248 let mut out = format!("Scratchpad: {}", dir.display());
249 let mut entries = Vec::new();
250 let mut truncated = false;
251 collect_entries(dir, "", &mut entries, &mut truncated);
252 if entries.is_empty() {
253 out.push_str("\n (empty)");
254 return out;
255 }
256 for line in &entries {
257 out.push_str("\n ");
258 out.push_str(line);
259 }
260 if truncated {
261 out.push_str("\n ... (listing capped)");
262 }
263 out
264}
265
266fn collect_entries(dir: &Path, rel: &str, entries: &mut Vec<String>, truncated: &mut bool) {
272 let Ok(read) = std::fs::read_dir(dir) else {
273 return;
274 };
275 let mut children: Vec<_> = read.flatten().map(|e| e.path()).collect();
276 children.sort();
277 for path in children {
278 if entries.len() >= MAX_LIST_ENTRIES {
279 *truncated = true;
280 return;
281 }
282 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
283 continue;
284 };
285 let child_rel = if rel.is_empty() {
286 name.to_string()
287 } else {
288 format!("{rel}/{name}")
289 };
290 if path.is_dir() {
291 entries.push(format!("{child_rel}/"));
292 collect_entries(&path, &child_rel, entries, truncated);
293 } else {
294 let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
295 entries.push(format!("{child_rel} ({})", human_size(size)));
296 }
297 }
298}
299
300fn human_size(bytes: u64) -> String {
302 if bytes < 1024 {
303 format!("{bytes} B")
304 } else if bytes < 1024 * 1024 {
305 format!("{:.1} KB", bytes as f64 / 1024.0)
306 } else {
307 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 fn temp_root(name: &str) -> PathBuf {
317 let dir = std::env::temp_dir().join(format!(
318 "mermaid_scratchpad_{}_{}",
319 name,
320 std::process::id()
321 ));
322 let _ = std::fs::remove_dir_all(&dir);
323 dir
324 }
325
326 fn release_lock(session_dir: &Path) {
329 HELD_LOCKS
330 .get_or_init(Mutex::default)
331 .lock()
332 .expect("registry")
333 .remove(session_dir);
334 }
335
336 #[test]
337 fn scratch_root_is_per_user_under_system_temp() {
338 let root = scratch_root_in(Path::new("/tmp"));
339 #[cfg(unix)]
340 assert_eq!(
341 root,
342 Path::new("/tmp").join(format!("mermaid-{}", rustix::process::getuid().as_raw()))
343 );
344 #[cfg(not(unix))]
345 assert_eq!(root, Path::new("/tmp").join("mermaid"));
346 }
347
348 #[test]
349 fn project_slug_table() {
350 let cases: &[(&str, &str)] = &[
351 ("/home/user/my proj", "-home-user-my-proj"),
352 ("/", "-"),
353 ("relative-dir", "relative-dir"),
354 ("under_score", "under_score"),
355 ("", "project"),
356 ];
357 for (input, expected) in cases {
358 assert_eq!(
359 project_slug(Path::new(input)),
360 *expected,
361 "slug of {input:?}"
362 );
363 }
364 }
365
366 #[test]
367 fn project_slug_truncates_long_paths() {
368 let long = "/a".repeat(200);
369 let slug = project_slug(Path::new(&long));
370 assert_eq!(slug.len(), MAX_COMPONENT_LEN);
371 }
372
373 #[test]
374 fn session_dir_confines_a_hostile_session_id() {
375 let root = Path::new("/data/scratchpad");
379 let dir = session_dir(root, Path::new("/proj"), "../../evil");
380 assert!(dir.starts_with(root.join("-proj")));
381 assert!(
382 dir.components()
383 .all(|c| !matches!(c, std::path::Component::ParentDir))
384 );
385 }
386
387 #[test]
388 fn ensure_creates_the_advertised_child_and_holds_the_lock() {
389 let root = temp_root("ensure");
390 let scratch = ensure_in(&root, Path::new("/proj"), "20260710_120000_000").expect("ensure");
391 assert!(scratch.is_dir());
392 assert!(
393 scratch.ends_with(SCRATCH_SUBDIR),
394 "advertised path is the scratchpad child: {}",
395 scratch.display()
396 );
397 let session = scratch.parent().expect("session dir");
398 assert!(
399 session.join(LOCK_FILE).is_file(),
400 "lock sits OUTSIDE the advertised dir"
401 );
402 assert!(lock_is_held(session), "ensure holds the liveness lock");
403 let again = ensure_in(&root, Path::new("/proj"), "20260710_120000_000").expect("ensure");
405 assert_eq!(scratch, again);
406 release_lock(session);
407 let _ = std::fs::remove_dir_all(&root);
408 }
409
410 #[cfg(unix)]
411 #[test]
412 fn ensure_tightens_perms_to_owner_only() {
413 use std::os::unix::fs::PermissionsExt;
414 let root = temp_root("perms");
415 let scratch = ensure_in(&root, Path::new("/proj"), "20260710_120000_000").expect("ensure");
416 let session = scratch.parent().expect("session dir").to_path_buf();
417 for level in [&root, &session, &scratch] {
418 let mode = std::fs::metadata(level).expect("meta").permissions().mode();
419 assert_eq!(mode & 0o777, 0o700, "mode of {}", level.display());
420 }
421 release_lock(&session);
422 let _ = std::fs::remove_dir_all(&root);
423 }
424
425 #[test]
426 fn sweep_lock_table() {
427 let root = temp_root("sweep");
432 let live = ensure_in(&root, Path::new("/proj"), "live").expect("ensure");
433 let live_session = live.parent().expect("session").to_path_buf();
434 let abandoned = ensure_in(&root, Path::new("/proj"), "abandoned").expect("ensure");
435 let abandoned_session = abandoned.parent().expect("session").to_path_buf();
436 release_lock(&abandoned_session); let lockless = ensure_in(&root, Path::new("/proj"), "lockless").expect("ensure");
438 let lockless_session = lockless.parent().expect("session").to_path_buf();
439 release_lock(&lockless_session);
440 std::fs::remove_file(lockless_session.join(LOCK_FILE)).expect("drop lock file");
441
442 let removed = sweep_stale_in(&root, 0).expect("sweep");
443 assert_eq!(removed, 2, "released + lockless reaped");
444 assert!(live.is_dir(), "a held lock protects the dir");
445 assert!(!abandoned_session.exists());
446 assert!(!lockless_session.exists());
447
448 let fresh = ensure_in(&root, Path::new("/proj"), "fresh").expect("ensure");
450 let fresh_session = fresh.parent().expect("session").to_path_buf();
451 release_lock(&fresh_session);
452 let removed = sweep_stale_in(&root, RETENTION_DAYS).expect("sweep");
453 assert_eq!(removed, 0);
454 assert!(fresh.is_dir(), "young dirs are kept even without a lock");
455 release_lock(&live_session);
456 let _ = std::fs::remove_dir_all(&root);
457 }
458
459 #[test]
460 fn sweep_of_a_missing_root_is_a_noop() {
461 let root = temp_root("missing");
462 assert_eq!(sweep_stale_in(&root, 0).expect("sweep"), 0);
463 }
464
465 #[test]
466 fn remove_cascades_unheld_dirs_but_spares_live_ones() {
467 let root = temp_root("remove");
468 let live = ensure_in(&root, Path::new("/proj"), "live").expect("ensure");
470 let live_session = live.parent().expect("session").to_path_buf();
471 remove_in(&root, Path::new("/proj"), "live").expect("remove");
472 assert!(live.is_dir(), "a held lock protects the dir from cascade");
473 let gone = ensure_in(&root, Path::new("/proj"), "gone").expect("ensure");
475 let gone_session = gone.parent().expect("session").to_path_buf();
476 release_lock(&gone_session);
477 remove_in(&root, Path::new("/proj"), "gone").expect("remove");
478 assert!(!gone_session.exists());
479 remove_in(&root, Path::new("/proj"), "never-existed").expect("remove");
481 release_lock(&live_session);
482 let _ = std::fs::remove_dir_all(&root);
483 }
484
485 #[test]
486 fn list_text_is_sorted_and_bounded() {
487 let root = temp_root("list");
488 let dir = ensure_in(&root, Path::new("/proj"), "s").expect("ensure");
489 let session = dir.parent().expect("session").to_path_buf();
490 assert_eq!(
491 list_text(&dir),
492 format!("Scratchpad: {}\n (empty)", dir.display()),
493 "a fresh scratchpad reads as empty (the lock is outside it)"
494 );
495 std::fs::write(dir.join("b.txt"), b"hello").expect("write");
496 std::fs::create_dir(dir.join("a")).expect("mkdir");
497 std::fs::write(dir.join("a").join("nested.log"), vec![0u8; 2048]).expect("write");
498 let text = list_text(&dir);
499 assert!(text.is_ascii(), "listing must be pure ASCII");
500 assert_eq!(
501 text,
502 format!(
503 "Scratchpad: {}\n a/\n a/nested.log (2.0 KB)\n b.txt (5 B)",
504 dir.display()
505 )
506 );
507 for i in 0..(MAX_LIST_ENTRIES + 10) {
509 std::fs::write(dir.join(format!("f{i:04}.tmp")), b"x").expect("write");
510 }
511 let text = list_text(&dir);
512 assert_eq!(
513 text.lines().count(),
514 1 + MAX_LIST_ENTRIES + 1,
515 "header + cap + marker"
516 );
517 assert!(text.ends_with("... (listing capped)"));
518 release_lock(&session);
519 let _ = std::fs::remove_dir_all(&root);
520 }
521
522 #[test]
523 fn human_size_units() {
524 assert_eq!(human_size(0), "0 B");
525 assert_eq!(human_size(1023), "1023 B");
526 assert_eq!(human_size(1536), "1.5 KB");
527 assert_eq!(human_size(3 * 1024 * 1024), "3.0 MB");
528 }
529}