agentmux_srv/backend/fs_ops/
trash_worker.rs1use std::path::Path;
14use std::sync::mpsc;
15use std::sync::OnceLock;
16
17type Job = Box<dyn FnOnce() + Send + 'static>;
18
19static 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 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
45pub 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
62fn 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
77pub 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
90fn 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#[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 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#[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#[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 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 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 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 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 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 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#[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 #[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 #[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 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}