Skip to main content

agentmux_srv/backend/fs_ops/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The filesystem layer behind the generic `fs.*` RPCs and the Files pane
5//! ("Hangar"): paginated listing, known places, name validation, the
6//! mutation safety policy, and the mutations themselves.
7//!
8//! Everything here is blocking and is called from `spawn_blocking` (or the
9//! trash thread): on macOS the first access to a protected folder blocks the
10//! calling thread while a privacy prompt is on screen (spec §9.1.1 fact 10).
11//!
12//! Spec: docs/specs/SPEC_FILE_BROWSER_PANE_2026_10_01.md §6.3, §7, §9.
13
14pub 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
35// ── Paths ────────────────────────────────────────────────────────────────
36
37/// A request path as an absolute host path: `~` expanded, an MSYS-style
38/// `/c/…` converted on Windows, NUL refused, relative refused (a relative
39/// path would resolve against srv's own working directory, which means
40/// nothing to the user).
41pub 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
56/// The path a mutation acts on: the request path with its PARENT
57/// canonicalized and its own last component kept as given.
58///
59/// Canonicalizing the whole path would resolve a final symlink to its
60/// target, and a rename or delete would then act on the target instead of
61/// the link. Canonicalizing the parent still resolves `..`, `~` and any
62/// linked folder above it, so protection checks see where the entry really
63/// is.
64pub 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
81/// Comparison key for paths: the display form, case-folded on Windows,
82/// whose filesystems are case-insensitive. Component-wise `starts_with` on
83/// these is a correct "is inside" test.
84fn 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
96// ── Listing ──────────────────────────────────────────────────────────────
97
98/// Page size when the request names none.
99pub const DEFAULT_LIST_LIMIT: u32 = 1000;
100/// Largest page a request may ask for.
101pub const MAX_LIST_LIMIT: u32 = 5000;
102/// A cursor is dropped after this long without a page being asked for. The
103/// frontend navigating away simply stops asking, so this is the
104/// cancellation (spec §6.3).
105pub const CURSOR_TTL: Duration = Duration::from_secs(30);
106/// Open listings held at once. Each holds an OS directory handle, so a
107/// burst of abandoned listings is bounded rather than left to the TTL.
108const MAX_CURSORS: usize = 256;
109/// Consecutive unreadable directory entries after which a listing gives up,
110/// so a directory whose iterator keeps failing ends instead of looping.
111const MAX_CONSECUTIVE_ENTRY_ERRORS: usize = 64;
112
113/// An open listing between pages.
114pub struct ListCursor {
115    iter: Peekable<std::fs::ReadDir>,
116    display: String,
117    last_used: Instant,
118}
119
120/// Open listings by cursor id.
121#[derive(Default)]
122pub struct CursorStore {
123    cursors: HashMap<String, ListCursor>,
124}
125
126impl CursorStore {
127    /// Remove and return the cursor `id`, unless it has expired.
128    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    /// Store `cursor` under a new id and return the id. Drops expired
134    /// cursors first, then the least recently used if still at capacity.
135    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    /// Drop every cursor idle for longer than the TTL, closing its directory
155    /// handle.
156    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
167/// The process-wide cursor store. Process-wide rather than per connection
168/// because a window reload reconnects; a cursor id is a random UUID, so one
169/// connection can't guess another's.
170pub static CURSORS: LazyLock<Mutex<CursorStore>> = LazyLock::new(|| Mutex::new(CursorStore::default()));
171
172/// Start the periodic cursor sweep, once per process. Without it, cursors
173/// abandoned after the last `fs.list` call would keep their directory
174/// handles open until the next call.
175pub 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
188/// One page of `fs.list`, against the process-wide cursor store.
189pub fn list_page(req: &FsListReq) -> FsListResult {
190    list_page_in(&CURSORS, req, Instant::now())
191}
192
193/// One page of `fs.list`. A failure to open the directory is an `Ok`
194/// answer with `error` set, never an `Err` (spec §9: a denied folder must
195/// never look empty, and must never fail the pane).
196pub 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            // An unreadable directory entry has no name to show a row for.
229            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
251/// The store's lock, recovered if a panic elsewhere poisoned it: the map is
252/// left consistent by every operation on it, and a poisoned lock must not
253/// silently end every listing.
254fn 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
262/// Canonicalize and open the directory for a first page.
263fn 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
279/// The `fs.list` error for a folder that couldn't be opened.
280fn 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
303/// One listing row. Never fails: what can't be read becomes the row's
304/// `error`, and the page goes on (spec §9: errors are per entry).
305fn 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    // `DirEntry::metadata` never follows a link: this is the entry's own
320    // metadata, which is where the hidden/system attributes and the link
321    // type live. On Windows it costs no extra system call.
322    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    // Windows ignores the read-only attribute on folders (Explorer uses it
363    // to mark customized folders such as Documents), so only files report it.
364    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
376// ── Places ───────────────────────────────────────────────────────────────
377
378/// Home and the known folders, by name only. Nothing here reads or stats a
379/// known folder: on macOS that alone can raise a privacy prompt the user
380/// didn't ask for (spec §9.1.5). A folder `dirs` can't name is left out.
381pub 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
401// ── Names ────────────────────────────────────────────────────────────────
402
403/// Validate a new file or folder name with the current platform's rules.
404pub fn validate_new_name(name: &str) -> Result<(), String> {
405    validate_name_rules(name, cfg!(windows))
406}
407
408/// Device names Windows reserves in every folder, with or without an
409/// extension (`nul.txt` is still the null device). The superscript digits
410/// are reserved too.
411const 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
417/// Longest name most filesystems accept (NTFS: 255 UTF-16 units; ext4 and
418/// APFS: 255 bytes). Checked as UTF-16 units and bytes both.
419const MAX_NAME_LEN: usize = 255;
420
421/// The name rules (spec §7.4), with the Windows-only ones switched by
422/// `windows` so every rule can be tested on any platform. Errors are
423/// sentences, shown to the user as they are.
424pub 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        // Windows silently strips these, so the file would get a different
448        // name from the one typed (and a verbatim path would create one that
449        // most programs then can't open).
450        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
461// ── Mutation safety ──────────────────────────────────────────────────────
462
463const ROOT_REFUSAL: &str = "AgentMux won't change the root of a drive.";
464
465/// What a mutation must never touch (spec §9, "Security"): a filesystem
466/// root, the home folder itself, AgentMux's own data and installation, the
467/// OS's system folders, and the inside of a macOS app bundle. Nor anything
468/// that CONTAINS one of those: deleting `C:\Users` would delete the home
469/// folder too.
470///
471/// Every path held here is in `path_key` form.
472#[derive(Debug, Clone, Default)]
473pub struct ProtectedPaths {
474    home: Option<PathBuf>,
475    /// `~/.agentmux`. Inside it only agents' own workspaces
476    /// (`agents/<name>/…`) may be changed; the rest is AgentMux's state.
477    data_root: Option<PathBuf>,
478    install_dirs: Vec<PathBuf>,
479    system_roots: Vec<PathBuf>,
480    /// Inside these the system list doesn't apply: the temp folder, which
481    /// on macOS lives under `/var`.
482    exempt_roots: Vec<PathBuf>,
483}
484
485impl ProtectedPaths {
486    /// The protected set for this machine, computed afresh so a check made
487    /// right before a mutation reflects the filesystem as it is then.
488    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        // A binary sitting directly in home (or anywhere above it) must not
494        // turn the whole home folder read-only: protect just the binary then.
495        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    /// `Ok` if a mutation may act on `target` (a `resolve_entry_path`
515    /// result). The error is a sentence for the user.
516    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        // The inside of a macOS app bundle: changing it breaks the app's
558        // signature, and for apps in /Applications raises an App Management
559        // prompt (spec §9.1.5). The bundle itself is treated as a file.
560        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    /// [`Self::check`] for a WSL distro's file through `\\wsl.localhost`
582    /// (`remote::wsl_fs`). Its `/mnt/<letter>` is a Windows drive, so the
583    /// Windows rules decide; its own system folders and home folders are
584    /// protected as this machine's are.
585    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
617/// The installation a (canonical) executable belongs to: on macOS the whole
618/// bundle, not just Contents/MacOS. The outermost `.app`, because srv's real
619/// file sits in a nested helper app (`AgentMux.app/Contents/Helpers/AgentMux
620/// Server.app`, scripts/package-macos.sh), and protecting only that would
621/// leave the rest of AgentMux.app open to changes.
622fn 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
630/// The OS's own folders (spec §9).
631fn 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            // /etc and /var are links into /private, so their canonical
657            // forms are what a resolved path will start with.
658            roots.extend(["/System", "/Library", "/private/etc", "/private/var"].iter().map(PathBuf::from));
659        }
660        roots
661    }
662}
663
664// ── Mutations ────────────────────────────────────────────────────────────
665//
666// Each re-resolves and re-checks its target immediately before acting, on
667// the thread that acts (spec §9: "re-validated at execution time"), and
668// logs one info line per operation for audit.
669
670fn 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
682/// `fs.rename`: a new name in the same folder. Never overwrites; a
683/// case-only rename goes through a temporary name, because on a
684/// case-insensitive filesystem the new name already "exists" (it is the
685/// file itself) and some filesystems ignore a direct case-only rename
686/// (spec §7.4).
687pub 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    // On a case-sensitive filesystem a differently-cased name can be a
723    // different, existing entry; only an exact match tells.
724    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        // Put it back under its old name rather than strand it under the
735        // temporary one.
736        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
745/// `fs.create`: a new empty file or folder. Creation itself is the
746/// existence check (`create_new` / `create_dir` fail if the name is taken),
747/// so nothing can be overwritten.
748pub 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
781/// `fs.delete`, one path: PERMANENT. A link is unlinked, never followed.
782pub 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
798/// `fs.trash`, one path. Must run on the trash thread
799/// (`trash_worker::run`).
800pub 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
819/// `fs.restore`: put each path back from the Trash. The paths are the
820/// original locations. Must run on the trash thread (`trash_worker::run`).
821/// One result per path, in order.
822pub fn restore(raws: &[String]) -> Vec<Result<(), String>> {
823    let protect = ProtectedPaths::current();
824    // Validate every path first, then hand the valid ones to the trash
825    // crate in one go, so the Trash is listed once per request.
826    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
853/// `fs.open` / `fs.reveal`: hand an existing path to the OS. Not limited to
854/// home (unlike `openinshell`): opening and revealing change nothing.
855pub fn open_or_reveal(raw: &str, reveal: bool) -> Result<(), String> {
856    let path = resolve_request_path(raw)?;
857    // A drive root has no parent to canonicalize; it is opened as it is.
858    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    // `spawn_detached` applies the strict external-process policy (I7).
862    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;