Skip to main content

computer_use/
cache.rs

1//! Per-session tree snapshots.
2//!
3//! Unix files are created mode `0600`. `XDG_CACHE_HOME` wins on every OS so
4//! tests can point at a temp directory. Without it, Windows uses
5//! `%LOCALAPPDATA%\computer-use\cache` and other platforms use `~/.cache`.
6//! `dirs::cache_dir` is not used: on macOS that is `~/Library/Caches`, and the
7//! session path contract is `~/.cache`.
8
9use std::fs::{self, OpenOptions};
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::ComputerError;
17use crate::tree::{ElectronEdit, SigNode};
18
19/// One saved observation. Secure-field values are already redacted in `root`.
20#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
21pub struct Snapshot {
22    /// Structural revision.
23    pub revision: u64,
24    /// Last full tree text.
25    pub tree: String,
26    /// Signature tree for the next diff.
27    pub root: SigNode,
28    /// Whether the full text hit a cap.
29    pub truncated: bool,
30    /// Target pid.
31    pub pid: i32,
32    /// Joined window id.
33    pub window_id: u32,
34    /// Bundle id used in the file name.
35    pub bundle_id: Option<String>,
36    /// Electron flags to restore. Absent when this process did not change them.
37    pub electron: Option<ElectronEdit>,
38    /// Latest tree used to revalidate element indices.
39    ///
40    /// `tree --diff` keeps comparing against [`Self::root`]. An element action
41    /// updates this field and leaves the diff baseline in place.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub observed: Option<SigNode>,
44    /// [`revision_of`](crate::tree::revision_of) of [`Self::observed`].
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub observed_revision: Option<u64>,
47}
48
49const OWNER_FILE: &str = "owner.json";
50/// Sessions older than this, whose owner process is not alive, are stale.
51pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
52
53/// Cache root: `foreground.lock` and `overlay.sock` live here, next to `sessions/`.
54///
55/// This does not create the directory. Tests panic unless `XDG_CACHE_HOME` is set.
56pub fn cache_dir() -> PathBuf {
57    cache_root()
58}
59
60/// Directory that holds every session. This does not create it.
61pub fn sessions_root() -> PathBuf {
62    cache_root().join("sessions")
63}
64
65fn session_path(session: Option<&str>) -> PathBuf {
66    let name =
67        sanitize_token(session.unwrap_or("")).unwrap_or_else(|| std::process::id().to_string());
68    sessions_root().join(name)
69}
70
71/// Directory for one session. Creates it, mode `0700`, and writes `owner.json` once.
72pub fn session_dir(session: Option<&str>) -> Result<PathBuf, ComputerError> {
73    let dir = session_path(session);
74    fs::create_dir_all(&dir).map_err(ComputerError::Io)?;
75    #[cfg(unix)]
76    set_mode(&dir, 0o700);
77    ensure_owner(&dir)?;
78    Ok(dir)
79}
80
81#[derive(Clone, Debug, Serialize, Deserialize)]
82struct SessionOwner {
83    pid: u32,
84    created_unix_ms: u64,
85}
86
87fn ensure_owner(dir: &Path) -> Result<(), ComputerError> {
88    let path = dir.join(OWNER_FILE);
89    if path.exists() {
90        return Ok(());
91    }
92    let owner = SessionOwner {
93        pid: std::process::id(),
94        created_unix_ms: unix_ms(SystemTime::now()),
95    };
96    write_secret_json(&path, &owner)
97}
98
99fn unix_ms(time: SystemTime) -> u64 {
100    time.duration_since(UNIX_EPOCH)
101        .map(|duration| duration.as_millis() as u64)
102        .unwrap_or(0)
103}
104
105fn time_from_unix_ms(ms: u64) -> SystemTime {
106    UNIX_EPOCH + Duration::from_millis(ms)
107}
108
109/// Read the snapshot for one app window, if a previous call stored one.
110pub fn load(
111    session: Option<&str>,
112    bundle_id: Option<&str>,
113    pid: i32,
114    window_id: u32,
115) -> Result<Option<Snapshot>, ComputerError> {
116    let path = snapshot_path(session, bundle_id, pid, window_id)?;
117    if !path.exists() {
118        return Ok(None);
119    }
120    let text = fs::read_to_string(&path).map_err(ComputerError::Io)?;
121    serde_json::from_str(&text).map(Some).map_err(|error| {
122        ComputerError::ExecutionFailed(format!(
123            "session cache {} is not readable: {error}",
124            path.display()
125        ))
126    })
127}
128
129const SCREENSHOT_MAP_FILE: &str = "screenshot-map.json";
130
131/// Write the last screenshot map. This file is not a tree snapshot.
132pub fn store_screenshot_map(
133    session: Option<&str>,
134    map: &crate::image_util::ScreenshotMap,
135) -> Result<(), ComputerError> {
136    let path = session_dir(session)?.join(SCREENSHOT_MAP_FILE);
137    write_secret_json(&path, map)
138}
139
140/// Read the last screenshot map for this session, if one was stored.
141pub fn load_screenshot_map(
142    session: Option<&str>,
143) -> Result<Option<crate::image_util::ScreenshotMap>, ComputerError> {
144    let path = session_path(session).join(SCREENSHOT_MAP_FILE);
145    if !path.exists() {
146        return Ok(None);
147    }
148    let text = fs::read_to_string(&path).map_err(ComputerError::Io)?;
149    serde_json::from_str(&text).map(Some).map_err(|error| {
150        ComputerError::ExecutionFailed(format!(
151            "screenshot map {} is not readable: {error}",
152            path.display()
153        ))
154    })
155}
156
157/// Write a snapshot. An older Electron edit is kept when the new one has none.
158pub fn store(session: Option<&str>, snapshot: &Snapshot) -> Result<(), ComputerError> {
159    let path = snapshot_path(
160        session,
161        snapshot.bundle_id.as_deref(),
162        snapshot.pid,
163        snapshot.window_id,
164    )?;
165    let mut snapshot = snapshot.clone();
166    if snapshot.electron.is_none() {
167        if let Some(previous) = read_snapshot(&path)? {
168            snapshot.electron = previous.electron;
169        }
170    }
171    write_json(&path, &snapshot)
172}
173
174/// Electron edits recorded for this session, one per pid.
175pub fn pending_restores(session: Option<&str>) -> Result<Vec<(i32, ElectronEdit)>, ComputerError> {
176    let dir = session_dir(session)?;
177    let mut found = Vec::new();
178    let entries = fs::read_dir(&dir).map_err(ComputerError::Io)?;
179    for entry in entries {
180        let entry = entry.map_err(ComputerError::Io)?;
181        let path = entry.path();
182        if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
183            continue;
184        }
185        if !is_snapshot_file(&path) {
186            continue;
187        }
188        let Some(snapshot) = read_snapshot(&path)? else {
189            continue;
190        };
191        let Some(edit) = snapshot.electron else {
192            continue;
193        };
194        if found.iter().any(|(pid, _)| *pid == snapshot.pid) {
195            continue;
196        }
197        found.push((snapshot.pid, edit));
198    }
199    Ok(found)
200}
201
202/// Drop Electron edits after a successful restore so a second teardown is a no-op.
203pub fn clear_restores(session: Option<&str>, pids: &[i32]) -> Result<(), ComputerError> {
204    let dir = session_dir(session)?;
205    let entries = fs::read_dir(&dir).map_err(ComputerError::Io)?;
206    for entry in entries {
207        let entry = entry.map_err(ComputerError::Io)?;
208        let path = entry.path();
209        if !is_snapshot_file(&path) {
210            continue;
211        }
212        let Some(mut snapshot) = read_snapshot(&path)? else {
213            continue;
214        };
215        if pids.contains(&snapshot.pid) && snapshot.electron.is_some() {
216            snapshot.electron = None;
217            write_json(&path, &snapshot)?;
218        }
219    }
220    Ok(())
221}
222
223fn is_snapshot_file(path: &Path) -> bool {
224    if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
225        return false;
226    }
227    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
228        return false;
229    };
230    name != SCREENSHOT_MAP_FILE && name != OWNER_FILE
231}
232
233/// Options for [`prune_sessions`].
234#[derive(Clone, Debug)]
235pub struct PruneOptions {
236    /// Report matches without deleting them.
237    pub dry_run: bool,
238    /// Age after which a session with a dead or unknown owner is stale.
239    pub ttl: Duration,
240    /// Clock used for the age check.
241    pub now: SystemTime,
242}
243
244impl Default for PruneOptions {
245    fn default() -> Self {
246        Self {
247            dry_run: false,
248            ttl: DEFAULT_SESSION_TTL,
249            now: SystemTime::now(),
250        }
251    }
252}
253
254/// Directories [`prune_sessions`] removed or left in place.
255#[derive(Clone, Debug, PartialEq, Eq)]
256pub struct PruneReport {
257    /// Session directory names that were removed, or would be in a dry run.
258    pub removed: Vec<String>,
259    /// Session directory names that stayed.
260    pub kept: Vec<String>,
261}
262
263/// Remove empty session directories and stale ones under `root`.
264///
265/// A directory with no snapshot or screenshot map is empty and is removed even
266/// when its owner pid is alive. A directory that still has content is kept
267/// while `alive` says the owner pid is running. Content whose owner is dead
268/// or unknown is removed once it is at least `ttl` old. `alive` is injected
269/// so tests do not depend on the process table.
270pub fn prune_sessions(
271    root: &Path,
272    options: &PruneOptions,
273    alive: &dyn Fn(u32) -> bool,
274) -> Result<PruneReport, ComputerError> {
275    let mut removed = Vec::new();
276    let mut kept = Vec::new();
277    if !root.exists() {
278        return Ok(PruneReport { removed, kept });
279    }
280    let mut names = Vec::new();
281    for entry in fs::read_dir(root).map_err(ComputerError::Io)? {
282        let entry = entry.map_err(ComputerError::Io)?;
283        let path = entry.path();
284        if !path.is_dir() {
285            continue;
286        }
287        let Some(name) = entry.file_name().into_string().ok() else {
288            continue;
289        };
290        names.push((name, path));
291    }
292    names.sort_by(|left, right| left.0.cmp(&right.0));
293    for (name, path) in names {
294        if should_prune(&path, &name, options, alive)? {
295            if !options.dry_run {
296                fs::remove_dir_all(&path).map_err(ComputerError::Io)?;
297            }
298            removed.push(name);
299        } else {
300            kept.push(name);
301        }
302    }
303    Ok(PruneReport { removed, kept })
304}
305
306/// Drop empty session directories under the real cache. Content is left alone.
307pub fn prune_empty_sessions() -> Result<Vec<String>, ComputerError> {
308    let root = sessions_root();
309    if !root.exists() {
310        return Ok(Vec::new());
311    }
312    let mut removed = Vec::new();
313    for entry in fs::read_dir(&root).map_err(ComputerError::Io)? {
314        let entry = entry.map_err(ComputerError::Io)?;
315        let path = entry.path();
316        if !path.is_dir() {
317            continue;
318        }
319        match session_has_content(&path) {
320            Ok(true) => continue,
321            Ok(false) => {}
322            Err(_) => continue,
323        }
324        fs::remove_dir_all(&path).map_err(ComputerError::Io)?;
325        if let Ok(name) = entry.file_name().into_string() {
326            removed.push(name);
327        }
328    }
329    removed.sort();
330    Ok(removed)
331}
332
333/// True when `pid` is still running. Signal 0 does not deliver a signal.
334///
335/// `0` is the current process group. A value above `i32::MAX` becomes `-1`
336/// in `kill`, and `-1` means every process the caller may signal.
337pub fn pid_alive(pid: u32) -> bool {
338    if pid == 0 || pid > i32::MAX as u32 {
339        return false;
340    }
341    #[cfg(unix)]
342    {
343        std::process::Command::new("kill")
344            .arg("-0")
345            .arg(pid.to_string())
346            .stdin(std::process::Stdio::null())
347            .stdout(std::process::Stdio::null())
348            .stderr(std::process::Stdio::null())
349            .status()
350            .map(|status| status.success())
351            .unwrap_or(false)
352    }
353    #[cfg(windows)]
354    {
355        let output = std::process::Command::new("tasklist")
356            .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
357            .stdin(std::process::Stdio::null())
358            .stdout(std::process::Stdio::piped())
359            .stderr(std::process::Stdio::null())
360            .output();
361        match output {
362            Ok(output) => {
363                let text = String::from_utf8_lossy(&output.stdout);
364                text.split(',')
365                    .any(|field| field.trim().trim_matches('"') == pid.to_string())
366            }
367            Err(_) => false,
368        }
369    }
370}
371
372fn should_prune(
373    path: &Path,
374    name: &str,
375    options: &PruneOptions,
376    alive: &dyn Fn(u32) -> bool,
377) -> Result<bool, ComputerError> {
378    if !session_has_content(path)? {
379        return Ok(true);
380    }
381    if let Some(pid) = owner_pid(path, name) {
382        if alive(pid) {
383            return Ok(false);
384        }
385    }
386    Ok(session_age(path, options.now) >= options.ttl)
387}
388
389fn session_has_content(dir: &Path) -> Result<bool, ComputerError> {
390    let entries = fs::read_dir(dir).map_err(ComputerError::Io)?;
391    for entry in entries {
392        let entry = entry.map_err(ComputerError::Io)?;
393        if entry.file_name() == std::ffi::OsStr::new(OWNER_FILE) {
394            continue;
395        }
396        return Ok(true);
397    }
398    Ok(false)
399}
400
401fn owner_pid(dir: &Path, name: &str) -> Option<u32> {
402    if let Some(owner) = read_owner(dir) {
403        return Some(owner.pid);
404    }
405    name.parse::<u32>().ok()
406}
407
408fn read_owner(dir: &Path) -> Option<SessionOwner> {
409    let text = fs::read_to_string(dir.join(OWNER_FILE)).ok()?;
410    serde_json::from_str(&text).ok()
411}
412
413fn session_age(dir: &Path, now: SystemTime) -> Duration {
414    let created = read_owner(dir)
415        .map(|owner| time_from_unix_ms(owner.created_unix_ms))
416        .or_else(|| dir.metadata().ok().and_then(|meta| meta.modified().ok()));
417    match created {
418        Some(created) => now.duration_since(created).unwrap_or(Duration::ZERO),
419        None => Duration::MAX,
420    }
421}
422
423fn snapshot_path(
424    session: Option<&str>,
425    bundle_id: Option<&str>,
426    pid: i32,
427    window_id: u32,
428) -> Result<PathBuf, ComputerError> {
429    let bundle = sanitize_token(bundle_id.unwrap_or("")).unwrap_or_else(|| "app".into());
430    Ok(session_dir(session)?.join(format!("{bundle}-{pid}-{window_id}.json")))
431}
432
433fn read_snapshot(path: &Path) -> Result<Option<Snapshot>, ComputerError> {
434    if !path.exists() {
435        return Ok(None);
436    }
437    let text = fs::read_to_string(path).map_err(ComputerError::Io)?;
438    if text.trim().is_empty() {
439        return Ok(None);
440    }
441    serde_json::from_str(&text).map(Some).map_err(|error| {
442        ComputerError::ExecutionFailed(format!(
443            "session cache {} is not readable: {error}",
444            path.display()
445        ))
446    })
447}
448
449fn write_json(path: &Path, snapshot: &Snapshot) -> Result<(), ComputerError> {
450    write_secret_json(path, snapshot)
451}
452
453/// Write bytes with mode `0600` on Unix.
454pub fn write_secret_bytes(path: &Path, bytes: &[u8]) -> Result<(), ComputerError> {
455    let mut options = OpenOptions::new();
456    options.write(true).create(true).truncate(true);
457    #[cfg(unix)]
458    {
459        use std::os::unix::fs::OpenOptionsExt;
460        options.mode(0o600);
461    }
462    let mut file = options.open(path).map_err(ComputerError::Io)?;
463    file.write_all(bytes).map_err(ComputerError::Io)?;
464    Ok(())
465}
466
467fn write_secret_json(path: &Path, value: &impl Serialize) -> Result<(), ComputerError> {
468    let text = serde_json::to_string(value).map_err(|error| {
469        ComputerError::ExecutionFailed(format!("session cache encode failed: {error}"))
470    })?;
471    let mut options = OpenOptions::new();
472    options.write(true).create(true).truncate(true);
473    #[cfg(unix)]
474    {
475        use std::os::unix::fs::OpenOptionsExt;
476        options.mode(0o600);
477    }
478    let mut file = options.open(path).map_err(ComputerError::Io)?;
479    file.write_all(text.as_bytes()).map_err(ComputerError::Io)?;
480    Ok(())
481}
482
483fn cache_root() -> PathBuf {
484    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
485        if !xdg.is_empty() {
486            return PathBuf::from(xdg).join("computer-use");
487        }
488    }
489    #[cfg(test)]
490    {
491        panic!("test used the session cache without XDG_CACHE_HOME");
492    }
493    #[cfg(not(test))]
494    {
495        cache_root_unchecked()
496    }
497}
498
499#[cfg(not(test))]
500fn cache_root_unchecked() -> PathBuf {
501    #[cfg(windows)]
502    {
503        dirs::data_local_dir()
504            .unwrap_or_else(|| PathBuf::from("."))
505            .join("computer-use")
506            .join("cache")
507    }
508    #[cfg(not(windows))]
509    {
510        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
511        home.join(".cache").join("computer-use")
512    }
513}
514
515fn sanitize_token(value: &str) -> Option<String> {
516    let mut out = String::new();
517    for ch in value.chars() {
518        if ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-' {
519            out.push(ch);
520        } else {
521            out.push('_');
522        }
523    }
524    let trimmed = out.trim_matches('_');
525    if trimmed.is_empty() {
526        None
527    } else {
528        Some(trimmed.to_string())
529    }
530}
531
532#[cfg(unix)]
533fn set_mode(path: &Path, mode: u32) {
534    use std::os::unix::fs::PermissionsExt;
535    let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
536}
537
538#[cfg(test)]
539pub(crate) static CACHE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
540
541#[cfg(test)]
542#[allow(unsafe_code)]
543pub(crate) struct CacheEnv {
544    dir: PathBuf,
545    previous: Option<std::ffi::OsString>,
546    _lock: std::sync::MutexGuard<'static, ()>,
547}
548
549#[cfg(test)]
550#[allow(unsafe_code)]
551impl CacheEnv {
552    /// Point `XDG_CACHE_HOME` at a temp directory until this value drops.
553    pub(crate) fn acquire() -> Self {
554        let lock = CACHE_ENV_LOCK
555            .lock()
556            .unwrap_or_else(|poison| poison.into_inner());
557        let dir = std::env::temp_dir().join(format!(
558            "computer-use-cache-{}-{}",
559            std::process::id(),
560            std::time::SystemTime::now()
561                .duration_since(std::time::UNIX_EPOCH)
562                .map(|duration| duration.as_nanos())
563                .unwrap_or(0)
564        ));
565        let _ = fs::remove_dir_all(&dir);
566        fs::create_dir_all(&dir).expect("temp cache dir");
567        let previous = std::env::var_os("XDG_CACHE_HOME");
568        // SAFETY: CACHE_ENV_LOCK is held until drop, which restores the previous value.
569        unsafe { std::env::set_var("XDG_CACHE_HOME", &dir) };
570        Self {
571            dir,
572            previous,
573            _lock: lock,
574        }
575    }
576
577    pub(crate) fn dir(&self) -> &Path {
578        &self.dir
579    }
580}
581
582#[cfg(test)]
583#[allow(unsafe_code)]
584impl Drop for CacheEnv {
585    fn drop(&mut self) {
586        // SAFETY: the same lock taken in acquire is still held.
587        unsafe {
588            match &self.previous {
589                Some(value) => std::env::set_var("XDG_CACHE_HOME", value),
590                None => std::env::remove_var("XDG_CACHE_HOME"),
591            }
592        }
593        let _ = fs::remove_dir_all(&self.dir);
594    }
595}
596
597#[cfg(test)]
598#[allow(unsafe_code)]
599mod tests {
600    use super::*;
601    use crate::tree::SigNode;
602
603    fn sample(pid: i32, window: u32, value: &str) -> Snapshot {
604        Snapshot {
605            revision: 7,
606            tree: format!("[0] AXWindow \"{value}\"\n"),
607            root: SigNode {
608                index: 0,
609                role: "AXWindow".into(),
610                identity: value.into(),
611                child_count: 0,
612                value: String::new(),
613                frame: None,
614                secure: false,
615                children: Vec::new(),
616            },
617            truncated: false,
618            pid,
619            window_id: window,
620            bundle_id: Some("com.apple.Notes".into()),
621            electron: None,
622            observed: None,
623            observed_revision: None,
624        }
625    }
626
627    #[test]
628    fn round_trip_under_xdg_cache_home() {
629        let _guard = CACHE_ENV_LOCK.lock().unwrap();
630        let dir = std::env::temp_dir().join(format!(
631            "computer-use-cache-{}-{}",
632            std::process::id(),
633            std::time::SystemTime::now()
634                .duration_since(std::time::UNIX_EPOCH)
635                .map(|duration| duration.as_nanos())
636                .unwrap_or(0)
637        ));
638        let _ = fs::remove_dir_all(&dir);
639        fs::create_dir_all(&dir).unwrap();
640        // SAFETY: CACHE_ENV_LOCK is held, and this test restores the variable before release.
641        // Other tests do not read XDG_CACHE_HOME.
642        unsafe { std::env::set_var("XDG_CACHE_HOME", &dir) };
643        let session = Some("unit");
644        let snapshot = sample(12, 34, "Notes");
645        store(session, &snapshot).unwrap();
646        let loaded = load(session, Some("com.apple.Notes"), 12, 34)
647            .unwrap()
648            .expect("stored snapshot");
649        assert_eq!(loaded, snapshot);
650        #[cfg(unix)]
651        {
652            use std::os::unix::fs::PermissionsExt;
653            let path = snapshot_path(session, Some("com.apple.Notes"), 12, 34).unwrap();
654            let mode = fs::metadata(&path).unwrap().permissions().mode();
655            assert_eq!(mode & 0o777, 0o600, "mode {mode:o}");
656        }
657        let mut with_flags = snapshot.clone();
658        with_flags.electron = Some(crate::tree::electron_enable(crate::tree::FlagState {
659            manual: Some(false),
660            enhanced: Some(false),
661        }));
662        with_flags.tree = "[0] AXWindow \"next\"\n".into();
663        store(session, &with_flags).unwrap();
664        let kept = load(session, Some("com.apple.Notes"), 12, 34)
665            .unwrap()
666            .unwrap();
667        assert!(kept.electron.is_some());
668        let pending = pending_restores(session).unwrap();
669        assert_eq!(pending.len(), 1);
670        assert_eq!(pending[0].0, 12);
671        clear_restores(session, &[12]).unwrap();
672        assert!(pending_restores(session).unwrap().is_empty());
673        let before = load(session, Some("com.apple.Notes"), 12, 34)
674            .unwrap()
675            .unwrap();
676        let map = crate::image_util::prepare_shot(
677            &image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 3, 255])),
678            crate::image_util::ShotFit {
679                scale: 2.0,
680                origin_x: 0,
681                origin_y: 0,
682                logical_width: 1,
683                logical_height: 1,
684                max_width: 1440,
685                canvas: None,
686            },
687        )
688        .unwrap()
689        .map;
690        store_screenshot_map(session, &map).unwrap();
691        let after = load(session, Some("com.apple.Notes"), 12, 34)
692            .unwrap()
693            .unwrap();
694        assert_eq!(before, after);
695        let loaded = load_screenshot_map(session).unwrap().expect("map");
696        assert_eq!(loaded.width, map.width);
697        assert!(pending_restores(session).unwrap().is_empty());
698        #[cfg(unix)]
699        {
700            use std::os::unix::fs::PermissionsExt;
701            let map_path = session_dir(session).unwrap().join("screenshot-map.json");
702            let mode = fs::metadata(&map_path).unwrap().permissions().mode();
703            assert_eq!(mode & 0o777, 0o600, "mode {mode:o}");
704        }
705        // SAFETY: same lock as set_var. The variable is removed before the guard drops.
706        unsafe { std::env::remove_var("XDG_CACHE_HOME") };
707        let _ = fs::remove_dir_all(&dir);
708    }
709
710    #[test]
711    fn prune_removes_empty_and_stale_dirs_and_keeps_a_live_owner() {
712        let root = std::env::temp_dir().join(format!(
713            "computer-use-prune-{}-{}",
714            std::process::id(),
715            std::time::SystemTime::now()
716                .duration_since(std::time::UNIX_EPOCH)
717                .map(|duration| duration.as_nanos())
718                .unwrap_or(0)
719        ));
720        let _ = fs::remove_dir_all(&root);
721        fs::create_dir_all(&root).unwrap();
722        let now = SystemTime::now();
723        write_session(&root, "empty", 111, now, false);
724        write_session(
725            &root,
726            "live",
727            222,
728            now - Duration::from_secs(10 * 24 * 60 * 60),
729            true,
730        );
731        write_session(&root, "dead-fresh", 333, now, true);
732        write_session(
733            &root,
734            "dead-old",
735            444,
736            now - Duration::from_secs(48 * 60 * 60),
737            true,
738        );
739        let alive = |pid: u32| pid == 222;
740        let options = PruneOptions {
741            dry_run: true,
742            ttl: DEFAULT_SESSION_TTL,
743            now,
744        };
745        let dry = prune_sessions(&root, &options, &alive).unwrap();
746        assert_eq!(
747            dry.removed,
748            vec!["dead-old".to_string(), "empty".to_string()]
749        );
750        assert!(root.join("empty").exists(), "dry run must not delete");
751        assert!(root.join("live").exists());
752        assert!(root.join("dead-fresh").exists());
753
754        let removed = prune_sessions(
755            &root,
756            &PruneOptions {
757                dry_run: false,
758                ttl: DEFAULT_SESSION_TTL,
759                now,
760            },
761            &alive,
762        )
763        .unwrap();
764        assert_eq!(
765            removed.removed,
766            vec!["dead-old".to_string(), "empty".to_string()]
767        );
768        assert_eq!(
769            removed.kept,
770            vec!["dead-fresh".to_string(), "live".to_string()]
771        );
772        assert!(!root.join("empty").exists());
773        assert!(!root.join("dead-old").exists());
774        assert!(root.join("live").join("snapshot.json").exists());
775        assert!(root.join("dead-fresh").join("snapshot.json").exists());
776        let _ = fs::remove_dir_all(&root);
777    }
778
779    fn write_session(root: &Path, name: &str, pid: u32, created: SystemTime, content: bool) {
780        let dir = root.join(name);
781        fs::create_dir_all(&dir).unwrap();
782        let owner = serde_json::json!({
783            "pid": pid,
784            "created_unix_ms": created
785                .duration_since(std::time::UNIX_EPOCH)
786                .unwrap()
787                .as_millis() as u64,
788        });
789        fs::write(dir.join("owner.json"), owner.to_string()).unwrap();
790        if content {
791            fs::write(dir.join("snapshot.json"), "{\"revision\":1}").unwrap();
792        }
793    }
794
795    #[test]
796    fn current_process_is_alive_and_a_huge_pid_is_not() {
797        assert!(pid_alive(std::process::id()));
798        assert!(!pid_alive(u32::MAX));
799    }
800}