Skip to main content

mermaid_cli/session/
scratchpad.rs

1//! Per-session scratch directories.
2//!
3//! Each chat session gets a private on-disk scratch area keyed by its
4//! conversation id: `<system-temp>/mermaid-<uid>/<project-slug>/<session-id>/scratchpad`.
5//! Tools and spawned subprocesses use it for intermediate files instead
6//! of the shared system temp dir. Living under the system temp dir means
7//! tmpfs speed where the OS provides it and a free wipe on reboot; the
8//! `mermaid-<uid>` root and each session dir are tightened to `0700` so
9//! other local users can neither read nor pre-create paths inside them.
10//!
11//! Lifecycle: the reducer emits `Cmd::EnsureScratchpad` at startup and
12//! whenever the conversation id changes (`/clear`, `/load`, rewind fork);
13//! the effect layer materializes the directory here and reports it back
14//! via `Msg::ScratchpadReady`, which stamps `Session::scratchpad`. A
15//! `.lock` file in the session dir — deliberately *outside* the advertised
16//! `scratchpad/` child, so a stray `rm -rf $MERMAID_SCRATCHPAD` cannot
17//! remove it — is held via `File::try_lock` for the process lifetime and
18//! marks the directory as in use; [`sweep_stale`] reaps unlocked
19//! directories older than the retention window so abandoned sessions
20//! don't accumulate forever.
21
22use 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
29/// Lock marking a session directory as owned by a live process. Sits next
30/// to (not inside) the advertised `scratchpad/` child.
31const LOCK_FILE: &str = ".lock";
32/// The advertised directory itself, as a child of the locked session dir.
33const SCRATCH_SUBDIR: &str = "scratchpad";
34/// Default retention: unlocked scratchpads older than this are reaped by
35/// [`sweep_stale`]. mermaidd overrides it via `daemon.scratchpad_retention_days`.
36pub const RETENTION_DAYS: u64 = 7;
37/// Cap on a sanitized path component — keeps the full scratchpad path
38/// well under PATH_MAX even for deeply nested project directories.
39const MAX_COMPONENT_LEN: usize = 96;
40/// Cap on the `/scratchpad` listing — keeps a scratch dir full of build
41/// output from flooding the transcript.
42const MAX_LIST_ENTRIES: usize = 100;
43
44/// Locks this process holds, keyed by session dir. Holding the open
45/// `File` keeps the OS lock alive for the process lifetime (and releases
46/// it automatically on crash); the map makes `ensure` idempotent — a
47/// second `try_lock` on a path we already own would spuriously read as
48/// "held by someone else".
49static HELD_LOCKS: OnceLock<Mutex<HashMap<PathBuf, File>>> = OnceLock::new();
50
51/// Per-user scratchpad root under the system temp dir. The uid suffix
52/// keeps roots disjoint on shared unix hosts; on Windows `%TEMP%` is
53/// already per-user, so a plain `mermaid` suffices.
54fn 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/// Flatten a project path into a single filesystem-safe component
70/// (`/home/user/my proj` -> `-home-user-my-proj`). Never empty: a
71/// degenerate input falls back to `"project"`.
72pub fn project_slug(project: &Path) -> String {
73    sanitize_component(&project.display().to_string(), "project")
74}
75
76/// One path component: alphanumerics, `-` and `_` pass through, every
77/// other byte becomes `-`. Sanitizing (rather than trusting) the input
78/// means a hostile string like `../../x` can never traverse out of the
79/// scratchpad root. Truncation is char-boundary-safe by construction
80/// (the output is pure ASCII).
81fn 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
100/// Pure path computation: the locked session dir for
101/// `(project, session_id)` under `root`. No filesystem access. The
102/// advertised scratchpad is its [`SCRATCH_SUBDIR`] child.
103pub 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
108/// Create (or adopt) the scratchpad for this project + session id, take
109/// its liveness lock, and return the advertised `scratchpad/` path.
110/// Idempotent — re-running for the same session keeps the already-held
111/// lock and returns the same path. If another live process holds the
112/// lock (the same conversation open twice), the directory is shared and
113/// that process's lock protects it.
114pub fn ensure(project: &Path, session_id: &str) -> io::Result<PathBuf> {
115    ensure_in(&scratch_root(), project, session_id)
116}
117
118/// [`ensure`] against an explicit root, so tests can point it at a
119/// throwaway directory (same pattern as mermaidd's bg-log sweep).
120fn 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        // Best-effort tighten each level to owner-only — cheap, and
128        // self-heals bits loosened by an aggressive umask (mirrors
129        // `private_temp_dir`). Chain: root -> slug -> session -> scratchpad.
130        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            // Hold for the process lifetime; dropped (and OS-released) only
140            // at exit or crash.
141            Ok(()) => {
142                held.insert(dir.clone(), lock);
143            },
144            // Another live mermaid owns this session dir; its lock protects
145            // the directory, so sharing it unlocked is fine.
146            Err(std::fs::TryLockError::WouldBlock) => {},
147            Err(std::fs::TryLockError::Error(err)) => return Err(err),
148        }
149    }
150    Ok(scratch)
151}
152
153/// Is the session dir's lock held by a live process (this one included)?
154/// Missing or unlockable-for-io lock files read as "not live" — the age
155/// check in the sweep still bounds how quickly such a dir can be reaped.
156fn 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        // Acquired (or unreadable): no live owner. The lock drops here,
168        // releasing immediately.
169        _ => false,
170    }
171}
172
173/// Reap unheld scratchpads older than `retention_days`. Returns the
174/// number of session directories removed. Runs on session startup (the
175/// `EnsureScratchpad` effect, with [`RETENTION_DAYS`]) and on mermaidd
176/// startup (with the daemon's configured retention) — no separate timer.
177pub fn sweep_stale(retention_days: u64) -> io::Result<u64> {
178    sweep_stale_in(&scratch_root(), retention_days)
179}
180
181/// [`sweep_stale`] against an explicit root. Public so mermaidd's tests (a
182/// separate bin target that can't see `pub(crate)`) can drive the sweep
183/// against a fixture directory, mirroring its bg-log sweep tests.
184pub 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            // A live owner protects the directory regardless of age (a
202            // week-long session keeps its scratchpad).
203            if lock_is_held(&dir) {
204                continue;
205            }
206            // Age from the directory's mtime; a clock skewed into the
207            // future reads as "fresh" (elapsed errors) — keep, fail open.
208            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        // Best-effort: drop a project dir the sweep just emptied (fails
219        // harmlessly while any session dir remains).
220        let _ = std::fs::remove_dir(project.path());
221    }
222    Ok(removed)
223}
224
225/// Remove one session's scratchpad — the delete-conversation cascade. A
226/// directory whose lock is held by a live process is left alone (that
227/// session is still open, possibly in another mermaid); the sweep reaps
228/// it later.
229pub fn remove(project: &Path, session_id: &str) -> io::Result<()> {
230    remove_in(&scratch_root(), project, session_id)
231}
232
233/// [`remove`] against an explicit root, for tests.
234fn 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
242/// Bounded ASCII listing of a scratchpad's contents, for `/scratchpad`.
243/// Deterministic (sorted, directories recursed depth-first), relative
244/// paths, human-readable sizes, capped at [`MAX_LIST_ENTRIES`] lines with
245/// an explicit "more" marker. The lock file lives outside the advertised
246/// directory, so everything found here is user content.
247pub 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
266/// Depth-first sorted walk feeding [`list_text`]; stops (setting
267/// `truncated`) once the entry cap is hit. Unreadable directories are
268/// skipped rather than failing the whole listing. `rel` is a `/`-joined
269/// string (not a `PathBuf`) so the listing renders identically on every
270/// OS — `Path::display` would print `a\nested.log` on Windows.
271fn 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
300/// `1023 B` / `1.5 KB` / `2.0 MB` — plain ASCII, one decimal past bytes.
301fn 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    /// Throwaway root per test, following the mermaidd sweep-test pattern.
316    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    /// Drop this process's held lock for a session dir, simulating the
327    /// owning process having exited (the OS releases the flock with it).
328    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        // Conversation ids come from on-disk filenames on --resume; a
376        // crafted `../../evil` must sanitize into a plain component that
377        // stays beneath the root instead of traversing out of it.
378        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        // Idempotent: same inputs, same path, lock still held once.
404        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        // Retention 0 makes every directory "old enough", so the lock is
428        // the only thing standing between a dir and removal — which is
429        // exactly the property under test. The "fresh" row uses a huge
430        // retention instead of forging mtimes (portable across CI OSes).
431        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); // owner "exited"
437        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        // Fresh + unheld survives a normal retention window.
449        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        // Held lock = "session open somewhere" — spared.
469        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        // Released = abandoned — removed regardless of age.
474        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        // Missing dir is a noop, not an error.
480        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        // Cap: many files -> exactly MAX_LIST_ENTRIES lines plus a marker.
508        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}