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 use mermaid_model::constants::SCRATCHPAD_RETENTION_DAYS as RETENTION_DAYS;
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"`.
72#[must_use]
73pub fn project_slug(project: &Path) -> String {
74    sanitize_component(&project.display().to_string(), "project")
75}
76
77/// One path component: alphanumerics, `-` and `_` pass through, every
78/// other byte becomes `-`. Sanitizing (rather than trusting) the input
79/// means a hostile string like `../../x` can never traverse out of the
80/// scratchpad root. Truncation is char-boundary-safe by construction
81/// (the output is pure ASCII).
82fn 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/// Pure path computation: the locked session dir for
102/// `(project, session_id)` under `root`. No filesystem access. The
103/// advertised scratchpad is its [`SCRATCH_SUBDIR`] child.
104#[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
110/// Create (or adopt) the scratchpad for this project + session id, take
111/// its liveness lock, and return the advertised `scratchpad/` path.
112/// Idempotent — re-running for the same session keeps the already-held
113/// lock and returns the same path. If another live process holds the
114/// lock (the same conversation open twice), the directory is shared and
115/// that process's lock protects it.
116///
117/// # Errors
118///
119/// Creating the directory chain, creating the lock file, and a lock attempt
120/// that fails for an I/O reason. Losing the lock to another live process is
121/// not an error — that is the shared-directory case above. Tightening each
122/// level to `0700` is best-effort, so an `Ok` path is not proof it is
123/// owner-only.
124pub fn ensure(project: &Path, session_id: &str) -> io::Result<PathBuf> {
125    ensure_in(&scratch_root(), project, session_id)
126}
127
128/// [`ensure`] against an explicit root, so tests can point it at a
129/// throwaway directory (same pattern as mermaidd's bg-log sweep).
130fn 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        // Best-effort tighten each level to owner-only — cheap, and
138        // self-heals bits loosened by an aggressive umask (mirrors
139        // `private_temp_dir`). Chain: root -> slug -> session -> scratchpad.
140        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            // Hold for the process lifetime; dropped (and OS-released) only
150            // at exit or crash.
151            Ok(()) => {
152                held.insert(dir.clone(), lock);
153            },
154            // Another live mermaid owns this session dir; its lock protects
155            // the directory, so sharing it unlocked is fine.
156            Err(std::fs::TryLockError::WouldBlock) => {},
157            Err(std::fs::TryLockError::Error(err)) => return Err(err),
158        }
159    }
160    Ok(scratch)
161}
162
163/// Is the session dir's lock held by a live process (this one included)?
164/// Missing or unlockable-for-io lock files read as "not live" — the age
165/// check in the sweep still bounds how quickly such a dir can be reaped.
166fn 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        // Acquired (or unreadable): no live owner. The lock drops here,
178        // releasing immediately.
179        _ => false,
180    }
181}
182
183/// Reap unheld scratchpads older than `retention_days`. Returns the
184/// number of session directories removed. Runs on session startup (the
185/// `EnsureScratchpad` effect, with [`RETENTION_DAYS`]) and on mermaidd
186/// startup (with the daemon's configured retention) — no separate timer.
187///
188/// # Errors
189///
190/// Only [`sweep_stale_in`]'s.
191pub fn sweep_stale(retention_days: u64) -> io::Result<u64> {
192    sweep_stale_in(&scratch_root(), retention_days)
193}
194
195/// [`sweep_stale`] against an explicit root. Public so mermaidd's tests (a
196/// separate bin target that can't see `pub(crate)`) can drive the sweep
197/// against a fixture directory, mirroring its bg-log sweep tests.
198///
199/// # Errors
200///
201/// Reading `root` or any project directory under it, and stat'ing an entry. A
202/// root that does not exist is `Ok(0)`, a directory that will not delete is
203/// skipped, and a directory whose lock is held or whose mtime reads as fresh
204/// is deliberately kept — the count is what was removed, not what was old.
205pub 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            // A live owner protects the directory regardless of age (a
223            // week-long session keeps its scratchpad).
224            if lock_is_held(&dir) {
225                continue;
226            }
227            // Age from the directory's mtime; a clock skewed into the
228            // future reads as "fresh" (elapsed errors) — keep, fail open.
229            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        // Best-effort: drop a project dir the sweep just emptied (fails
240        // harmlessly while any session dir remains).
241        let _ = std::fs::remove_dir(project.path());
242    }
243    Ok(removed)
244}
245
246/// Remove one session's scratchpad — the delete-conversation cascade. A
247/// directory whose lock is held by a live process is left alone (that
248/// session is still open, possibly in another mermaid); the sweep reaps
249/// it later.
250///
251/// # Errors
252///
253/// Removing the directory tree. A session with no scratchpad, and one whose
254/// lock a live process holds, are both `Ok` — so an `Ok` does not mean the
255/// directory is gone.
256pub fn remove(project: &Path, session_id: &str) -> io::Result<()> {
257    remove_in(&scratch_root(), project, session_id)
258}
259
260/// [`remove`] against an explicit root, for tests.
261fn 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/// Bounded ASCII listing of a scratchpad's contents, for `/scratchpad`.
270/// Deterministic (sorted, directories recursed depth-first), relative
271/// paths, human-readable sizes, capped at [`MAX_LIST_ENTRIES`] lines with
272/// an explicit "more" marker. The lock file lives outside the advertised
273/// directory, so everything found here is user content.
274#[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
294/// Depth-first sorted walk feeding [`list_text`]; stops (setting
295/// `truncated`) once the entry cap is hit. Unreadable directories are
296/// skipped rather than failing the whole listing. `rel` is a `/`-joined
297/// string (not a `PathBuf`) so the listing renders identically on every
298/// OS — `Path::display` would print `a\nested.log` on Windows.
299fn 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
328/// `1023 B` / `1.5 KB` / `2.0 MB` — plain ASCII, one decimal past bytes.
329fn 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    /// Throwaway root per test, following the mermaidd sweep-test pattern.
344    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    /// Drop this process's held lock for a session dir, simulating the
355    /// owning process having exited (the OS releases the flock with it).
356    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        // Conversation ids come from on-disk filenames on --resume; a
404        // crafted `../../evil` must sanitize into a plain component that
405        // stays beneath the root instead of traversing out of it.
406        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        // Idempotent: same inputs, same path, lock still held once.
432        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        // Retention 0 makes every directory "old enough", so the lock is
456        // the only thing standing between a dir and removal — which is
457        // exactly the property under test. The "fresh" row uses a huge
458        // retention instead of forging mtimes (portable across CI OSes).
459        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); // owner "exited"
465        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        // Fresh + unheld survives a normal retention window.
477        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        // Held lock = "session open somewhere" — spared.
497        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        // Released = abandoned — removed regardless of age.
502        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        // Missing dir is a noop, not an error.
508        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        // Cap: many files -> exactly MAX_LIST_ENTRIES lines plus a marker.
536        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}