Skip to main content

agentmux_srv/backend/fs_ops/
trash_worker.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Every call into the `trash` crate runs on one dedicated thread.
5//!
6//! The crate documents undefined behaviour when it is called from more than
7//! one thread at a time on Linux/FreeBSD, and on Windows it initializes COM
8//! per calling thread. A single long-lived worker satisfies both: calls are
9//! serialized, and COM is set up once (spec §7.3).
10//!
11//! Spec: docs/specs/SPEC_FILE_BROWSER_PANE_2026_10_01.md §7.3, §9.1.5.
12
13use std::path::Path;
14use std::sync::mpsc;
15use std::sync::OnceLock;
16
17type Job = Box<dyn FnOnce() + Send + 'static>;
18
19/// The worker's queue. `None` if the thread could not be started, in which
20/// case every trash call fails cleanly instead of panicking a handler.
21static WORKER: OnceLock<Option<mpsc::Sender<Job>>> = OnceLock::new();
22
23fn worker() -> Option<&'static mpsc::Sender<Job>> {
24    WORKER
25        .get_or_init(|| {
26            let (tx, rx) = mpsc::channel::<Job>();
27            let spawned = std::thread::Builder::new().name("fs-trash".to_string()).spawn(move || {
28                for job in rx {
29                    // One panicking job must not end the thread, or every
30                    // later trash call would fail.
31                    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job));
32                }
33            });
34            match spawned {
35                Ok(_) => Some(tx),
36                Err(e) => {
37                    tracing::error!(error = %e, "fs: could not start the trash worker thread");
38                    None
39                }
40            }
41        })
42        .as_ref()
43}
44
45/// Run `f` on the trash thread and wait for its answer without blocking the
46/// async runtime.
47pub async fn run<T, F>(f: F) -> Result<T, String>
48where
49    T: Send + 'static,
50    F: FnOnce() -> T + Send + 'static,
51{
52    let (tx, rx) = tokio::sync::oneshot::channel();
53    let sender = worker().ok_or_else(|| "The Trash isn't available right now.".to_string())?;
54    sender
55        .send(Box::new(move || {
56            let _ = tx.send(f());
57        }))
58        .map_err(|_| "The Trash isn't available right now.".to_string())?;
59    rx.await.map_err(|_| "Moving to the Trash failed unexpectedly.".to_string())
60}
61
62/// A `TrashContext` that, on macOS, uses `NSFileManager` rather than the
63/// crate's default of scripting Finder: the Finder route raises an
64/// Automation prompt ("AgentMux wants to control Finder"), and once refused,
65/// later deletes silently do nothing (spec §9.1.5).
66fn context() -> trash::TrashContext {
67    #[allow(unused_mut)]
68    let mut ctx = trash::TrashContext::default();
69    #[cfg(target_os = "macos")]
70    {
71        use trash::macos::{DeleteMethod, TrashContextExtMacos};
72        ctx.set_delete_method(DeleteMethod::NsFileManager);
73    }
74    ctx
75}
76
77/// Move `path` to the Trash. Must be called on the trash thread (via [`run`]).
78/// The crate trashes a link itself, not its target.
79pub fn trash_path(path: &Path) -> Result<(), String> {
80    #[cfg(target_os = "macos")]
81    if let Some(result) = macos::trash(path) {
82        return result;
83    }
84    context().delete(path).map_err(|e| {
85        tracing::warn!(path = %path.display(), error = ?e, "fs.trash: trash crate error");
86        format!("Couldn't move it to the Trash: {}", describe(&e))
87    })
88}
89
90/// A readable clause for a `trash::Error`, whose own `Display` is its
91/// `Debug` dump.
92fn describe(err: &trash::Error) -> String {
93    match err {
94        trash::Error::Os { description, .. } | trash::Error::Unknown { description } => description.clone(),
95        trash::Error::TargetedRoot => "a drive or filesystem root can't be trashed".to_string(),
96        trash::Error::CouldNotAccess { .. } => "it doesn't exist or can't be accessed".to_string(),
97        trash::Error::CanonicalizePath { .. } => "its folder couldn't be found".to_string(),
98        #[cfg(all(unix, not(target_os = "macos"), not(target_os = "ios"), not(target_os = "android")))]
99        trash::Error::FileSystem { source, .. } => source.to_string(),
100        other => format!("{other:?}"),
101    }
102}
103
104/// Put each of `targets` back from the Trash: for each, the most recently
105/// trashed item whose original location is that path. Must be called on the
106/// trash thread (via [`run`]). One result per target, in order.
107#[cfg(any(
108    target_os = "windows",
109    all(unix, not(target_os = "macos"), not(target_os = "ios"), not(target_os = "android"))
110))]
111pub fn restore_paths(targets: &[std::path::PathBuf]) -> Vec<Result<(), String>> {
112    use std::collections::HashSet;
113
114    let items = match trash::os_limited::list() {
115        Ok(items) => items,
116        Err(e) => {
117            let msg = format!("Couldn't read the Trash: {e}");
118            return targets.iter().map(|_| Err(msg.clone())).collect();
119        }
120    };
121    // Items already put back by an earlier target in this same request, so a
122    // path named twice doesn't try to restore one item twice.
123    let mut used: HashSet<std::ffi::OsString> = HashSet::new();
124    targets
125        .iter()
126        .map(|target| {
127            let want = path_key(target);
128            let item = items
129                .iter()
130                .filter(|it| !used.contains(&it.id) && path_key(&it.original_path()) == want)
131                .max_by_key(|it| it.time_deleted)
132                .cloned()
133                .ok_or_else(|| "It isn't in the Trash anymore.".to_string())?;
134            used.insert(item.id.clone());
135            trash::os_limited::restore_all([item]).map_err(|e| match e {
136                trash::Error::RestoreCollision { .. } => {
137                    "Something with the same name is already there, so it can't be put back.".to_string()
138                }
139                other => {
140                    tracing::warn!(path = %target.display(), error = ?other, "fs.restore: trash crate error");
141                    format!("Couldn't put it back: {}", describe(&other))
142                }
143            })
144        })
145        .collect()
146}
147
148/// macOS: the `trash` crate can't list or restore the Trash there, so
149/// [`macos::trash`] remembers where `NSFileManager` put each item and this
150/// moves it back. Only items trashed since srv started can be put back.
151#[cfg(target_os = "macos")]
152pub fn restore_paths(targets: &[std::path::PathBuf]) -> Vec<Result<(), String>> {
153    targets.iter().map(|target| macos::restore(target)).collect()
154}
155
156/// Other platforms the `trash` crate can't restore on.
157#[cfg(not(any(
158    target_os = "windows",
159    target_os = "macos",
160    all(unix, not(target_os = "ios"), not(target_os = "android"))
161)))]
162pub fn restore_paths(targets: &[std::path::PathBuf]) -> Vec<Result<(), String>> {
163    targets
164        .iter()
165        .map(|_| Err("Restoring from the Trash isn't supported on this system.".to_string()))
166        .collect()
167}
168
169#[cfg(target_os = "macos")]
170mod macos {
171    use std::collections::HashMap;
172    use std::os::unix::fs::MetadataExt;
173    use std::path::{Path, PathBuf};
174    use std::sync::{Mutex, OnceLock};
175
176    use objc2_foundation::{NSFileManager, NSString, NSURL};
177
178    use super::super::platform;
179    use super::path_key;
180
181    /// An item in the Trash: where it is, and its device and inode then, so
182    /// a different item that later takes that name isn't mistaken for it.
183    struct Trashed {
184        path: PathBuf,
185        dev: u64,
186        ino: u64,
187    }
188
189    impl Trashed {
190        fn still_there(&self) -> bool {
191            std::fs::symlink_metadata(&self.path).is_ok_and(|m| m.dev() == self.dev && m.ino() == self.ino)
192        }
193    }
194
195    /// Where each item trashed since srv started went, by its original
196    /// path's [`path_key`], oldest first.
197    fn trashed() -> &'static Mutex<HashMap<String, Vec<Trashed>>> {
198        static TRASHED: OnceLock<Mutex<HashMap<String, Vec<Trashed>>>> = OnceLock::new();
199        TRASHED.get_or_init(Default::default)
200    }
201
202    /// Trash `path` through `NSFileManager` and remember where it went.
203    /// `None` for a path that isn't UTF-8, which the crate handles instead.
204    pub(super) fn trash(path: &Path) -> Option<Result<(), String>> {
205        let utf8 = path.to_str()?;
206        let url = NSURL::fileURLWithPath(&NSString::from_str(utf8));
207        let mut resulting = None;
208        let file_mgr = NSFileManager::defaultManager();
209        if let Err(e) = file_mgr.trashItemAtURL_resultingItemURL_error(&url, Some(&mut resulting)) {
210            let description = e.localizedDescription().to_string();
211            tracing::warn!(path = %path.display(), error = %description, "fs.trash: trashItemAtURL failed");
212            return Some(Err(format!("Couldn't move it to the Trash: {description}")));
213        }
214        let dest = resulting.and_then(|u| u.path()).map(|p| PathBuf::from(p.to_string()));
215        match dest.and_then(|dest| std::fs::symlink_metadata(&dest).ok().map(|m| (dest, m))) {
216            Some((dest, meta)) => {
217                let mut map = trashed().lock().unwrap_or_else(|e| e.into_inner());
218                map.entry(path_key(path)).or_default().push(Trashed { path: dest, dev: meta.dev(), ino: meta.ino() });
219            }
220            None => tracing::warn!(path = %path.display(), "fs.trash: trashed, but macOS didn't say where to"),
221        }
222        Some(Ok(()))
223    }
224
225    /// Put back the most recently trashed item that came from `target` and is
226    /// still in the Trash, refusing if something is in its place now.
227    pub(super) fn restore(target: &Path) -> Result<(), String> {
228        let key = path_key(target);
229        let mut map = trashed().lock().unwrap_or_else(|e| e.into_inner());
230        let Some(stack) = map.get_mut(&key) else {
231            return Err(
232                "AgentMux hasn't moved it to the Trash since it started, so it can't put it back. \
233                 Put Back in the Finder can."
234                    .to_string(),
235            );
236        };
237        // Drop what's no longer in the Trash: emptied, moved out of it, or
238        // replaced by another item of the same name.
239        while stack.last().is_some_and(|t| !t.still_there()) {
240            stack.pop();
241        }
242        let Some(from) = stack.last().map(|t| t.path.clone()) else {
243            map.remove(&key);
244            return Err("It isn't in the Trash anymore.".to_string());
245        };
246        if !target.parent().is_some_and(Path::is_dir) {
247            return Err("The folder it was in isn't there anymore, so it can't be put back.".to_string());
248        }
249        platform::rename_no_replace(&from, target).map_err(|e| match e.kind() {
250            std::io::ErrorKind::AlreadyExists => {
251                "Something with the same name is already there, so it can't be put back.".to_string()
252            }
253            _ => {
254                tracing::warn!(path = %target.display(), error = %e, "fs.restore: move back failed");
255                format!("Couldn't put it back: {e}")
256            }
257        })?;
258        stack.pop();
259        if stack.is_empty() {
260            map.remove(&key);
261        }
262        Ok(())
263    }
264
265    #[cfg(test)]
266    mod tests {
267        use super::*;
268
269        #[test]
270        fn another_item_with_the_same_name_is_not_the_trashed_one() {
271            let dir = tempfile::tempdir().unwrap();
272            let path = dir.path().join("item.txt");
273            std::fs::write(&path, "trashed").unwrap();
274            let meta = std::fs::symlink_metadata(&path).unwrap();
275            let t = Trashed { path: path.clone(), dev: meta.dev(), ino: meta.ino() };
276            assert!(t.still_there());
277
278            // The first file stays (moved), so the new one can't reuse its inode.
279            std::fs::rename(&path, dir.path().join("moved.txt")).unwrap();
280            std::fs::write(&path, "another").unwrap();
281            assert!(!t.still_there());
282        }
283    }
284}
285
286/// A path's identity for matching a trashed item's original location: the
287/// display form, case-folded on Windows, whose filesystems are
288/// case-insensitive and whose Recycle Bin reports plain (non-verbatim) paths.
289#[cfg_attr(
290    not(any(target_os = "windows", target_os = "macos", all(unix, not(target_os = "ios"), not(target_os = "android")))),
291    allow(dead_code)
292)]
293fn path_key(path: &Path) -> String {
294    let display = super::platform::display_path(path);
295    if cfg!(windows) {
296        display.to_lowercase()
297    } else {
298        display
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[tokio::test]
307    async fn every_job_runs_on_the_same_thread() {
308        let a = run(|| std::thread::current().id()).await.unwrap();
309        let b = run(|| std::thread::current().id()).await.unwrap();
310        assert_eq!(a, b);
311        assert_ne!(a, std::thread::current().id());
312    }
313
314    #[tokio::test]
315    async fn a_panicking_job_does_not_end_the_worker() {
316        let r = run(|| -> u8 { panic!("boom") }).await;
317        assert!(r.is_err());
318        assert_eq!(run(|| 7).await.unwrap(), 7);
319    }
320
321    /// Touches the real Recycle Bin / Trash, so it is opt-in:
322    /// `cargo test -p agentmux-srv trash_and_restore_round_trip -- --ignored`.
323    #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
324    #[tokio::test]
325    #[ignore]
326    async fn trash_and_restore_round_trip() {
327        let dir = tempfile::tempdir().unwrap();
328        let file = dir.path().canonicalize().unwrap().join(format!("agentmux-trash-test-{}.txt", uuid::Uuid::new_v4()));
329        std::fs::write(&file, "x").unwrap();
330
331        let f = file.clone();
332        run(move || trash_path(&f)).await.unwrap().unwrap();
333        assert!(!file.exists(), "trashed file must be gone from its folder");
334
335        let f = file.clone();
336        let results = run(move || restore_paths(&[f])).await.unwrap();
337        assert_eq!(results.len(), 1);
338        results[0].clone().unwrap();
339        assert_eq!(std::fs::read_to_string(&file).unwrap(), "x", "restored in place");
340    }
341
342    #[cfg(target_os = "macos")]
343    #[tokio::test]
344    async fn macos_restore_refuses_what_it_did_not_trash() {
345        let dir = tempfile::tempdir().unwrap();
346        let file = dir.path().canonicalize().unwrap().join("never-trashed.txt");
347        let results = run(move || restore_paths(&[file])).await.unwrap();
348        assert!(results[0].as_ref().unwrap_err().contains("hasn't moved it to the Trash"));
349    }
350
351    /// Touches the real Trash, so it is opt-in, like the round trip.
352    #[cfg(target_os = "macos")]
353    #[tokio::test]
354    #[ignore]
355    async fn macos_restore_leaves_a_new_item_in_its_place_alone() {
356        let dir = tempfile::tempdir().unwrap();
357        let file = dir.path().canonicalize().unwrap().join(format!("agentmux-trash-test-{}.txt", uuid::Uuid::new_v4()));
358        std::fs::write(&file, "old").unwrap();
359        let f = file.clone();
360        run(move || trash_path(&f)).await.unwrap().unwrap();
361        std::fs::write(&file, "new").unwrap();
362
363        let f = file.clone();
364        let results = run(move || restore_paths(&[f])).await.unwrap();
365        assert!(results[0].as_ref().unwrap_err().contains("already there"));
366        assert_eq!(std::fs::read_to_string(&file).unwrap(), "new", "the new file is untouched");
367
368        // Once it's out of the way, the trashed one comes back.
369        std::fs::remove_file(&file).unwrap();
370        let f = file.clone();
371        let results = run(move || restore_paths(&[f])).await.unwrap();
372        results[0].clone().unwrap();
373        assert_eq!(std::fs::read_to_string(&file).unwrap(), "old");
374    }
375}