1pub mod host_jobs;
15pub mod remote;
16pub mod git;
17pub mod jobs;
18pub mod platform;
19pub mod trash_worker;
20
21use std::collections::HashMap;
22use std::io;
23use std::iter::Peekable;
24use std::path::{Component, Path, PathBuf};
25use std::sync::{LazyLock, Mutex};
26use std::time::{Duration, Instant};
27
28use crate::backend::rpc_types::{
29 FsCreateKind, FsCreateReq, FsCreateResult, FsEntry, FsError, FsErrorKind, FsListReq,
30 FsListResult, FsPlace, FsPlaceKind, FsRenameReq, FsRenameResult,
31};
32
33use platform::display_path;
34
35pub fn resolve_request_path(raw: &str) -> Result<PathBuf, String> {
42 if raw.contains('\0') {
43 return Err("That path contains an invalid character.".to_string());
44 }
45 if raw.is_empty() {
46 return Err("No path was given.".to_string());
47 }
48 let converted = crate::backend::base::msys_to_windows_path(raw);
49 let expanded = crate::backend::base::expand_home_dir_safe(&converted);
50 if !expanded.is_absolute() {
51 return Err(format!("“{raw}” isn't a full path."));
52 }
53 Ok(expanded)
54}
55
56pub fn resolve_entry_path(raw: &str) -> Result<PathBuf, String> {
65 let path = resolve_request_path(raw)?;
66 let name = path.file_name().ok_or_else(|| {
67 if path.parent().is_none() {
68 ROOT_REFUSAL.to_string()
69 } else {
70 format!("“{raw}” isn't a valid path.")
71 }
72 })?;
73 let parent = path.parent().ok_or_else(|| ROOT_REFUSAL.to_string())?;
74 let canonical_parent = parent.canonicalize().map_err(|e| match e.kind() {
75 io::ErrorKind::NotFound => "The folder it's in doesn't exist anymore.".to_string(),
76 _ => format!("Couldn't open the folder it's in: {e}"),
77 })?;
78 Ok(canonical_parent.join(name))
79}
80
81fn path_key(path: &Path) -> PathBuf {
85 if cfg!(windows) {
86 PathBuf::from(display_path(path).to_lowercase())
87 } else {
88 path.to_path_buf()
89 }
90}
91
92fn file_name_string(path: &Path) -> String {
93 path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
94}
95
96pub const DEFAULT_LIST_LIMIT: u32 = 1000;
100pub const MAX_LIST_LIMIT: u32 = 5000;
102pub const CURSOR_TTL: Duration = Duration::from_secs(30);
106const MAX_CURSORS: usize = 256;
109const MAX_CONSECUTIVE_ENTRY_ERRORS: usize = 64;
112
113pub struct ListCursor {
115 iter: Peekable<std::fs::ReadDir>,
116 display: String,
117 last_used: Instant,
118}
119
120#[derive(Default)]
122pub struct CursorStore {
123 cursors: HashMap<String, ListCursor>,
124}
125
126impl CursorStore {
127 fn take(&mut self, id: &str, now: Instant) -> Option<ListCursor> {
129 let cursor = self.cursors.remove(id)?;
130 (now.saturating_duration_since(cursor.last_used) <= CURSOR_TTL).then_some(cursor)
131 }
132
133 fn put(&mut self, mut cursor: ListCursor, now: Instant) -> String {
136 self.sweep(now);
137 while self.cursors.len() >= MAX_CURSORS {
138 let Some(oldest) = self
139 .cursors
140 .iter()
141 .min_by_key(|(_, c)| c.last_used)
142 .map(|(id, _)| id.clone())
143 else {
144 break;
145 };
146 self.cursors.remove(&oldest);
147 }
148 cursor.last_used = now;
149 let id = uuid::Uuid::new_v4().to_string();
150 self.cursors.insert(id.clone(), cursor);
151 id
152 }
153
154 pub fn sweep(&mut self, now: Instant) {
157 self.cursors
158 .retain(|_, c| now.saturating_duration_since(c.last_used) <= CURSOR_TTL);
159 }
160
161 #[cfg(test)]
162 fn len(&self) -> usize {
163 self.cursors.len()
164 }
165}
166
167pub static CURSORS: LazyLock<Mutex<CursorStore>> = LazyLock::new(|| Mutex::new(CursorStore::default()));
171
172pub fn ensure_cursor_sweeper() {
176 static STARTED: std::sync::Once = std::sync::Once::new();
177 STARTED.call_once(|| {
178 tokio::spawn(async {
179 let mut tick = tokio::time::interval(Duration::from_secs(10));
180 loop {
181 tick.tick().await;
182 lock_store(&CURSORS).sweep(Instant::now());
183 }
184 });
185 });
186}
187
188pub fn list_page(req: &FsListReq) -> FsListResult {
190 list_page_in(&CURSORS, req, Instant::now())
191}
192
193pub fn list_page_in(store: &Mutex<CursorStore>, req: &FsListReq, now: Instant) -> FsListResult {
197 let limit = req.limit.unwrap_or(DEFAULT_LIST_LIMIT).clamp(1, MAX_LIST_LIMIT) as usize;
198
199 let mut cursor = match req.cursor.as_deref() {
200 Some(id) => {
201 let taken = lock_store(store).take(id, now);
202 match taken {
203 Some(c) => c,
204 None => {
205 let path = resolve_request_path(&req.path)
206 .map(|p| display_path(&p))
207 .unwrap_or_else(|_| req.path.clone());
208 return list_failure(path, FsErrorKind::Expired, "This listing expired. Reload the folder.".to_string());
209 }
210 }
211 }
212 None => match open_listing(&req.path) {
213 Ok(c) => c,
214 Err(failure) => return failure,
215 },
216 };
217
218 let mut entries = Vec::with_capacity(limit.min(1024));
219 let mut consecutive_errors = 0usize;
220 let mut gave_up = false;
221 while entries.len() < limit {
222 match cursor.iter.next() {
223 None => break,
224 Some(Ok(de)) => {
225 consecutive_errors = 0;
226 entries.push(build_entry(&de));
227 }
228 Some(Err(e)) => {
230 tracing::debug!(dir = %cursor.display, error = %e, "fs.list: skipping an unreadable directory entry");
231 consecutive_errors += 1;
232 if consecutive_errors >= MAX_CONSECUTIVE_ENTRY_ERRORS {
233 tracing::warn!(dir = %cursor.display, "fs.list: too many unreadable entries in a row; ending the listing");
234 gave_up = true;
235 break;
236 }
237 }
238 }
239 }
240
241 let display = cursor.display.clone();
242 let done = gave_up || cursor.iter.peek().is_none();
243 let next = if done {
244 None
245 } else {
246 Some(lock_store(store).put(cursor, now))
247 };
248 FsListResult { path: display, entries, cursor: next, error: None }
249}
250
251fn lock_store(store: &Mutex<CursorStore>) -> std::sync::MutexGuard<'_, CursorStore> {
255 store.lock().unwrap_or_else(|e| e.into_inner())
256}
257
258fn list_failure(path: String, kind: FsErrorKind, message: String) -> FsListResult {
259 FsListResult { path, entries: Vec::new(), cursor: None, error: Some(FsError { kind, message }) }
260}
261
262fn open_listing(raw: &str) -> Result<ListCursor, FsListResult> {
264 let path = resolve_request_path(raw)
265 .map_err(|msg| list_failure(raw.to_string(), FsErrorKind::Other, msg))?;
266 let canonical = path.canonicalize().map_err(|e| folder_failure(display_path(&path), &e))?;
267 let display = display_path(&canonical);
268 match std::fs::metadata(&canonical) {
269 Ok(meta) if !meta.is_dir() => {
270 return Err(list_failure(display, FsErrorKind::NotADirectory, "This isn't a folder.".to_string()));
271 }
272 Ok(_) => {}
273 Err(e) => return Err(folder_failure(display, &e)),
274 }
275 let iter = std::fs::read_dir(&canonical).map_err(|e| folder_failure(display.clone(), &e))?;
276 Ok(ListCursor { iter: iter.peekable(), display, last_used: Instant::now() })
277}
278
279fn folder_failure(display: String, err: &io::Error) -> FsListResult {
281 let (kind, message) = classify_folder_error(err);
282 list_failure(display, kind, message)
283}
284
285pub fn classify_folder_error(err: &io::Error) -> (FsErrorKind, String) {
286 match err.kind() {
287 io::ErrorKind::NotFound => (FsErrorKind::NotFound, "This folder doesn't exist.".to_string()),
288 io::ErrorKind::PermissionDenied if platform::is_os_blocked(err) => (
289 FsErrorKind::OsBlocked,
290 "macOS blocked AgentMux from reading this folder. Open System Settings → Privacy & Security → \
291 Files & Folders, turn AgentMux on, then try again."
292 .to_string(),
293 ),
294 io::ErrorKind::PermissionDenied => (
295 FsErrorKind::PermissionDenied,
296 "You don't have permission to open this folder.".to_string(),
297 ),
298 io::ErrorKind::NotADirectory => (FsErrorKind::NotADirectory, "This isn't a folder.".to_string()),
299 _ => (FsErrorKind::Other, format!("Couldn't open this folder: {err}")),
300 }
301}
302
303fn build_entry(de: &std::fs::DirEntry) -> FsEntry {
306 let name = de.file_name().to_string_lossy().into_owned();
307 let mut entry = FsEntry {
308 hidden: name.starts_with('.'),
309 name,
310 is_dir: false,
311 is_symlink: false,
312 size: None,
313 mtime: None,
314 readonly: false,
315 link_target: None,
316 error: None,
317 };
318
319 let own = match de.metadata() {
323 Ok(m) => m,
324 Err(e) => {
325 if let Ok(ft) = de.file_type() {
326 entry.is_symlink = ft.is_symlink();
327 entry.is_dir = ft.is_dir();
328 }
329 entry.error = Some(entry_error_message(&e));
330 return entry;
331 }
332 };
333 entry.hidden |= platform::has_hidden_attribute(&own);
334 entry.is_symlink = own.file_type().is_symlink();
335
336 let effective = if entry.is_symlink {
337 let path = de.path();
338 entry.link_target = std::fs::read_link(&path).ok().map(|t| display_path(&t));
339 match std::fs::metadata(&path) {
340 Ok(m) => m,
341 Err(e) => {
342 entry.error = Some(match e.kind() {
343 io::ErrorKind::NotFound => "The link's target doesn't exist.".to_string(),
344 _ => format!("Couldn't follow the link: {e}"),
345 });
346 return entry;
347 }
348 }
349 } else {
350 own
351 };
352
353 entry.is_dir = effective.is_dir();
354 if !entry.is_dir {
355 entry.size = Some(effective.len());
356 }
357 entry.mtime = effective
358 .modified()
359 .ok()
360 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
361 .map(|d| d.as_millis() as u64);
362 entry.readonly = !(cfg!(windows) && entry.is_dir) && effective.permissions().readonly();
365 entry
366}
367
368fn entry_error_message(err: &io::Error) -> String {
369 match err.kind() {
370 io::ErrorKind::NotFound => "It was removed while the folder was being read.".to_string(),
371 io::ErrorKind::PermissionDenied => "Access denied.".to_string(),
372 _ => err.to_string(),
373 }
374}
375
376pub fn home_and_known_places() -> (String, Vec<FsPlace>) {
382 let home = dirs::home_dir().map(|h| display_path(&h)).unwrap_or_default();
383 let mut places = Vec::new();
384 if !home.is_empty() {
385 places.push(FsPlace { id: "home".to_string(), label: "Home".to_string(), path: home.clone(), kind: FsPlaceKind::Home });
386 }
387 let known: [(&str, &str, Option<PathBuf>); 4] = [
388 ("desktop", "Desktop", dirs::desktop_dir()),
389 ("documents", "Documents", dirs::document_dir()),
390 ("downloads", "Downloads", dirs::download_dir()),
391 ("pictures", "Pictures", dirs::picture_dir()),
392 ];
393 for (id, label, dir) in known {
394 if let Some(dir) = dir {
395 places.push(FsPlace { id: id.to_string(), label: label.to_string(), path: display_path(&dir), kind: FsPlaceKind::Known });
396 }
397 }
398 (home, places)
399}
400
401pub fn validate_new_name(name: &str) -> Result<(), String> {
405 validate_name_rules(name, cfg!(windows))
406}
407
408const WINDOWS_RESERVED: &[&str] = &[
412 "con", "prn", "aux", "nul", "conin$", "conout$", "com0", "com1", "com2", "com3", "com4", "com5", "com6", "com7",
413 "com8", "com9", "com¹", "com²", "com³", "lpt0", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8",
414 "lpt9", "lpt¹", "lpt²", "lpt³",
415];
416
417const MAX_NAME_LEN: usize = 255;
420
421pub fn validate_name_rules(name: &str, windows: bool) -> Result<(), String> {
425 if name.trim().is_empty() {
426 return Err("A name can't be empty.".to_string());
427 }
428 if name == "." || name == ".." {
429 return Err("“.” and “..” can't be used as names.".to_string());
430 }
431 if name.contains('/') || name.contains('\\') {
432 return Err("A name can't contain / or \\.".to_string());
433 }
434 if name.contains('\0') {
435 return Err("A name can't contain a NUL character.".to_string());
436 }
437 if name.len() > MAX_NAME_LEN || name.encode_utf16().count() > MAX_NAME_LEN {
438 return Err("That name is too long.".to_string());
439 }
440 if windows {
441 if let Some(c) = name.chars().find(|c| matches!(c, '<' | '>' | ':' | '"' | '|' | '?' | '*')) {
442 return Err(format!("Windows doesn't allow {c} in a name."));
443 }
444 if name.chars().any(|c| (c as u32) < 0x20) {
445 return Err("A name can't contain control characters.".to_string());
446 }
447 if name.ends_with('.') || name.ends_with(' ') {
451 return Err("Windows doesn't allow a name to end with a dot or a space.".to_string());
452 }
453 let stem = name.split('.').next().unwrap_or(name).trim_end_matches(' ').to_lowercase();
454 if WINDOWS_RESERVED.contains(&stem.as_str()) {
455 return Err(format!("“{name}” is reserved by Windows. Pick another name."));
456 }
457 }
458 Ok(())
459}
460
461const ROOT_REFUSAL: &str = "AgentMux won't change the root of a drive.";
464
465#[derive(Debug, Clone, Default)]
473pub struct ProtectedPaths {
474 home: Option<PathBuf>,
475 data_root: Option<PathBuf>,
478 install_dirs: Vec<PathBuf>,
479 system_roots: Vec<PathBuf>,
480 exempt_roots: Vec<PathBuf>,
483}
484
485impl ProtectedPaths {
486 pub fn current() -> Self {
489 let canon = |p: PathBuf| -> PathBuf { path_key(&p.canonicalize().unwrap_or(p)) };
490 let home = dirs::home_dir().map(canon);
491 let data_root = Some(canon(crate::backend::base::get_mux_data_dir()));
492
493 let covers_home = |dir: &Path| home.as_ref().is_some_and(|h| h.starts_with(dir));
496 let mut install_dirs = Vec::new();
497 if let Ok(exe) = std::env::current_exe() {
498 let exe = exe.canonicalize().unwrap_or(exe);
499 if let Some(dir) = install_dir_of(&exe).map(path_key) {
500 install_dirs.push(if covers_home(&dir) { path_key(&exe) } else { dir });
501 }
502 }
503 if let Some(app) = crate::backend::base::get_mux_app_path().map(canon) {
504 if !covers_home(&app) {
505 install_dirs.push(app);
506 }
507 }
508
509 let system_roots = system_roots().into_iter().map(canon).collect();
510 let exempt_roots = vec![canon(std::env::temp_dir())];
511 Self { home, data_root, install_dirs, system_roots, exempt_roots }
512 }
513
514 pub fn check(&self, target: &Path) -> Result<(), String> {
517 if target.parent().is_none() {
518 return Err(ROOT_REFUSAL.to_string());
519 }
520 if let Some(share) = crate::backend::remote::wsl_fs::share_of(target) {
521 return self.check_in_distro(&share);
522 }
523 let key = path_key(target);
524 let contains = |protected: &Path| protected.starts_with(&key);
525
526 if let Some(home) = &self.home {
527 if *home == key {
528 return Err("AgentMux won't rename, move or delete your home folder.".to_string());
529 }
530 if contains(home) {
531 return Err("This folder contains your home folder, so AgentMux won't change it.".to_string());
532 }
533 }
534
535 if let Some(data) = &self.data_root {
536 if contains(data) {
537 return Err("This is, or contains, AgentMux's own data folder; AgentMux won't change it.".to_string());
538 }
539 if let Ok(rel) = key.strip_prefix(data) {
540 let parts: Vec<Component<'_>> = rel.components().collect();
541 let in_workspace = parts.len() >= 3 && parts[0].as_os_str() == "agents";
542 if !in_workspace {
543 return Err(
544 "This is part of AgentMux's own data. Only files inside an agent's workspace can be changed here."
545 .to_string(),
546 );
547 }
548 }
549 }
550
551 for dir in &self.install_dirs {
552 if key.starts_with(dir) || contains(dir) {
553 return Err("This is part of the AgentMux installation; AgentMux won't change it.".to_string());
554 }
555 }
556
557 if cfg!(target_os = "macos")
561 && key
562 .ancestors()
563 .skip(1)
564 .any(|a| a.extension().is_some_and(|e| e.eq_ignore_ascii_case("app")))
565 {
566 return Err("AgentMux won't change the inside of an app bundle.".to_string());
567 }
568
569 let inside_home = self.home.as_ref().is_some_and(|h| key.starts_with(h));
570 let exempt = self.exempt_roots.iter().any(|r| key.starts_with(r));
571 if !inside_home && !exempt {
572 for root in &self.system_roots {
573 if key.starts_with(root) || contains(root) {
574 return Err("This is a protected system folder; AgentMux won't change it.".to_string());
575 }
576 }
577 }
578 Ok(())
579 }
580
581 fn check_in_distro(&self, share: &crate::backend::remote::wsl_fs::SharePath) -> Result<(), String> {
586 use crate::backend::remote::wsl_fs;
587 if share.linux == "/" {
588 return Err(ROOT_REFUSAL.to_string());
589 }
590 if let Some(windows) = wsl_fs::drive_path(&share.linux) {
591 return self.check(Path::new(&windows));
592 }
593 if wsl_fs::is_system_path(&share.linux) {
594 return Err(format!(
595 "This is a system folder of the {} WSL distribution; AgentMux won't change it.",
596 share.distro
597 ));
598 }
599 if share.linux == "/home" || wsl_fs::is_home(&share.linux) {
600 return Err("AgentMux won't rename, move or delete a home folder.".to_string());
601 }
602 Ok(())
603 }
604
605 #[cfg(test)]
606 fn for_test(home: &Path, data_root: &Path, install: &[&Path], system: &[&Path], exempt: &[&Path]) -> Self {
607 Self {
608 home: Some(path_key(home)),
609 data_root: Some(path_key(data_root)),
610 install_dirs: install.iter().map(|p| path_key(p)).collect(),
611 system_roots: system.iter().map(|p| path_key(p)).collect(),
612 exempt_roots: exempt.iter().map(|p| path_key(p)).collect(),
613 }
614 }
615}
616
617fn install_dir_of(exe: &Path) -> Option<&Path> {
623 exe.ancestors()
624 .skip(1)
625 .filter(|a| a.extension().is_some_and(|e| e.eq_ignore_ascii_case("app")))
626 .last()
627 .or_else(|| exe.parent())
628}
629
630fn system_roots() -> Vec<PathBuf> {
632 #[cfg(windows)]
633 {
634 let mut roots = Vec::new();
635 for (var, fallback) in [
636 ("SystemRoot", r"C:\Windows"),
637 ("ProgramFiles", r"C:\Program Files"),
638 ("ProgramFiles(x86)", r"C:\Program Files (x86)"),
639 ("ProgramW6432", r"C:\Program Files"),
640 ("ProgramData", r"C:\ProgramData"),
641 ] {
642 let dir = std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from).unwrap_or_else(|| PathBuf::from(fallback));
643 if !roots.contains(&dir) {
644 roots.push(dir);
645 }
646 }
647 roots
648 }
649 #[cfg(not(windows))]
650 {
651 let mut roots: Vec<PathBuf> = ["/usr", "/bin", "/sbin", "/etc", "/var", "/boot", "/lib", "/lib32", "/lib64", "/proc", "/sys", "/dev"]
652 .iter()
653 .map(PathBuf::from)
654 .collect();
655 if cfg!(target_os = "macos") {
656 roots.extend(["/System", "/Library", "/private/etc", "/private/var"].iter().map(PathBuf::from));
659 }
660 roots
661 }
662}
663
664fn exists_message(name: &str) -> String {
671 format!("Something named “{name}” already exists here.")
672}
673
674fn missing_or(err: io::Error, what: &str) -> String {
675 match err.kind() {
676 io::ErrorKind::NotFound => "It doesn't exist anymore.".to_string(),
677 io::ErrorKind::PermissionDenied => format!("You don't have permission to {what} it."),
678 _ => format!("Couldn't {what} it: {err}"),
679 }
680}
681
682pub fn rename(req: &FsRenameReq) -> Result<FsRenameResult, String> {
688 let protect = ProtectedPaths::current();
689 let src = resolve_entry_path(&req.path)?;
690 validate_new_name(&req.new_name)?;
691 std::fs::symlink_metadata(&src).map_err(|e| missing_or(e, "rename"))?;
692 protect.check(&src)?;
693 let parent = src.parent().ok_or_else(|| ROOT_REFUSAL.to_string())?.to_path_buf();
694 let dest = parent.join(&req.new_name);
695 protect.check(&dest)?;
696
697 let old_name = file_name_string(&src);
698 if old_name == req.new_name {
699 return Ok(FsRenameResult { new_path: display_path(&dest) });
700 }
701
702 let result = if old_name.to_lowercase() == req.new_name.to_lowercase() {
703 rename_case_only(&src, &dest, &parent, &req.new_name)
704 } else {
705 platform::rename_no_replace(&src, &dest).map_err(|e| match e.kind() {
706 io::ErrorKind::AlreadyExists => exists_message(&req.new_name),
707 _ => missing_or(e, "rename"),
708 })
709 };
710 tracing::info!(
711 op = "fs.rename",
712 from = %display_path(&src),
713 to = %display_path(&dest),
714 outcome = if result.is_ok() { "ok" } else { "error" },
715 error = result.as_ref().err().map(String::as_str).unwrap_or(""),
716 "fs mutation"
717 );
718 result.map(|()| FsRenameResult { new_path: display_path(&dest) })
719}
720
721fn rename_case_only(src: &Path, dest: &Path, parent: &Path, new_name: &str) -> Result<(), String> {
722 let taken = std::fs::read_dir(parent)
725 .map_err(|e| missing_or(e, "rename"))?
726 .flatten()
727 .any(|e| e.file_name() == std::ffi::OsStr::new(new_name));
728 if taken {
729 return Err(exists_message(new_name));
730 }
731 let tmp = parent.join(format!(".agentmux-rename-{}", uuid::Uuid::new_v4().simple()));
732 platform::rename_no_replace(src, &tmp).map_err(|e| missing_or(e, "rename"))?;
733 if let Err(e) = platform::rename_no_replace(&tmp, dest) {
734 let _ = platform::rename_no_replace(&tmp, src);
737 return Err(match e.kind() {
738 io::ErrorKind::AlreadyExists => exists_message(new_name),
739 _ => missing_or(e, "rename"),
740 });
741 }
742 Ok(())
743}
744
745pub fn create(req: &FsCreateReq) -> Result<FsCreateResult, String> {
749 let protect = ProtectedPaths::current();
750 validate_new_name(&req.name)?;
751 let parent = resolve_request_path(&req.parent)?.canonicalize().map_err(|e| match e.kind() {
752 io::ErrorKind::NotFound => "That folder doesn't exist anymore.".to_string(),
753 _ => format!("Couldn't open that folder: {e}"),
754 })?;
755 if !parent.is_dir() {
756 return Err("That isn't a folder.".to_string());
757 }
758 let target = parent.join(&req.name);
759 protect.check(&target)?;
760
761 let result = match req.kind {
762 FsCreateKind::File => std::fs::OpenOptions::new().write(true).create_new(true).open(&target).map(|_| ()),
763 FsCreateKind::Dir => std::fs::create_dir(&target),
764 }
765 .map_err(|e| match e.kind() {
766 io::ErrorKind::AlreadyExists => exists_message(&req.name),
767 io::ErrorKind::PermissionDenied => "You don't have permission to create something here.".to_string(),
768 _ => format!("Couldn't create it: {e}"),
769 });
770 tracing::info!(
771 op = "fs.create",
772 kind = ?req.kind,
773 path = %display_path(&target),
774 outcome = if result.is_ok() { "ok" } else { "error" },
775 error = result.as_ref().err().map(String::as_str).unwrap_or(""),
776 "fs mutation"
777 );
778 result.map(|()| FsCreateResult { path: display_path(&target) })
779}
780
781pub fn delete_permanently(raw: &str) -> Result<(), String> {
783 let protect = ProtectedPaths::current();
784 let target = resolve_entry_path(raw)?;
785 std::fs::symlink_metadata(&target).map_err(|e| missing_or(e, "delete"))?;
786 protect.check(&target)?;
787 let result = platform::remove_entry_no_follow(&target).map_err(|e| missing_or(e, "delete"));
788 tracing::info!(
789 op = "fs.delete",
790 path = %display_path(&target),
791 outcome = if result.is_ok() { "ok" } else { "error" },
792 error = result.as_ref().err().map(String::as_str).unwrap_or(""),
793 "fs mutation"
794 );
795 result
796}
797
798pub fn trash(raw: &str) -> Result<(), String> {
801 let protect = ProtectedPaths::current();
802 let target = resolve_entry_path(raw)?;
803 std::fs::symlink_metadata(&target).map_err(|e| missing_or(e, "move"))?;
804 protect.check(&target)?;
805 let result = match platform::trash_unavailable_reason(&target) {
806 Some(reason) => Err(reason.to_string()),
807 None => trash_worker::trash_path(&target),
808 };
809 tracing::info!(
810 op = "fs.trash",
811 path = %display_path(&target),
812 outcome = if result.is_ok() { "ok" } else { "error" },
813 error = result.as_ref().err().map(String::as_str).unwrap_or(""),
814 "fs mutation"
815 );
816 result
817}
818
819pub fn restore(raws: &[String]) -> Vec<Result<(), String>> {
823 let protect = ProtectedPaths::current();
824 let checked: Vec<Result<PathBuf, String>> = raws
827 .iter()
828 .map(|raw| {
829 let target = resolve_entry_path(raw)?;
830 protect.check(&target)?;
831 Ok(target)
832 })
833 .collect();
834 let valid: Vec<PathBuf> = checked.iter().filter_map(|c| c.as_ref().ok().cloned()).collect();
835 let mut restored = trash_worker::restore_paths(&valid).into_iter();
836 checked
837 .into_iter()
838 .map(|c| {
839 let target = c?;
840 let result = restored.next().unwrap_or_else(|| Err("Couldn't put it back.".to_string()));
841 tracing::info!(
842 op = "fs.restore",
843 path = %display_path(&target),
844 outcome = if result.is_ok() { "ok" } else { "error" },
845 error = result.as_ref().err().map(String::as_str).unwrap_or(""),
846 "fs mutation"
847 );
848 result
849 })
850 .collect()
851}
852
853pub fn open_or_reveal(raw: &str, reveal: bool) -> Result<(), String> {
856 let path = resolve_request_path(raw)?;
857 let target = if path.parent().is_none() { path } else { resolve_entry_path(raw)? };
859 std::fs::symlink_metadata(&target).map_err(|e| missing_or(e, "open"))?;
860 let cmd = if reveal { platform::reveal_command(&target) } else { platform::open_command(&target) };
861 let result = platform::spawn_detached(cmd).map_err(|e| format!("Couldn't open it: {e}"));
863 tracing::info!(
864 op = if reveal { "fs.reveal" } else { "fs.open" },
865 path = %display_path(&target),
866 outcome = if result.is_ok() { "ok" } else { "error" },
867 "fs action"
868 );
869 result
870}
871
872#[cfg(test)]
873mod tests;