Skip to main content

agentmux_srv/backend/fs_ops/
platform.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The OS-specific half of the Files pane's filesystem layer: path display
5//! form, hidden attributes, a rename that never overwrites, a delete that
6//! never follows a link, and the commands that open or reveal a path.
7//!
8//! Deliberately depends on nothing but `std`, `libc` and `windows-sys`, so
9//! every `cfg` arm can be checked on its own.
10//!
11//! Spec: docs/specs/SPEC_FILE_BROWSER_PANE_2026_10_01.md §9.
12
13use std::io;
14use std::path::Path;
15use std::process::{Command, Stdio};
16
17/// `path` as the frontend should see it. On Windows, `canonicalize` returns
18/// verbatim paths (`\\?\C:\…`, `\\?\UNC\host\share\…`); those are turned
19/// back into their ordinary forms. Elsewhere the path is unchanged.
20///
21/// Stripping the prefix loses nothing the server needs: Rust's `std::fs`
22/// re-adds it on its own for an absolute path too long for the legacy APIs.
23pub fn display_path(path: &Path) -> String {
24    let s = path.to_string_lossy();
25    if cfg!(windows) {
26        strip_verbatim(&s)
27    } else {
28        s.into_owned()
29    }
30}
31
32/// The string half of [`display_path`], separate so it can be tested on any
33/// platform. Only the drive and UNC forms are rewritten; any other verbatim
34/// path (`\\?\Volume{…}\`) has no ordinary spelling and is left alone.
35pub fn strip_verbatim(s: &str) -> String {
36    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
37        return format!(r"\\{rest}");
38    }
39    if let Some(rest) = s.strip_prefix(r"\\?\") {
40        let b = rest.as_bytes();
41        if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
42            return rest.to_string();
43        }
44    }
45    s.to_string()
46}
47
48/// `FILE_ATTRIBUTE_HIDDEN`.
49#[cfg(any(windows, test))]
50const ATTR_HIDDEN: u32 = 0x2;
51/// `FILE_ATTRIBUTE_SYSTEM`.
52#[cfg(any(windows, test))]
53const ATTR_SYSTEM: u32 = 0x4;
54
55/// Whether Windows file attributes mark an entry hidden. Explorer hides both
56/// hidden and system entries by default, so both count (spec §9).
57#[cfg(any(windows, test))]
58pub fn attributes_mark_hidden(attributes: u32) -> bool {
59    attributes & (ATTR_HIDDEN | ATTR_SYSTEM) != 0
60}
61
62/// Whether `meta` (the entry's own metadata, not a link's target) carries
63/// the hidden or system attribute. Always false off Windows, where hidden
64/// means only a leading dot.
65pub fn has_hidden_attribute(meta: &std::fs::Metadata) -> bool {
66    #[cfg(windows)]
67    {
68        use std::os::windows::fs::MetadataExt;
69        attributes_mark_hidden(meta.file_attributes())
70    }
71    #[cfg(not(windows))]
72    {
73        let _ = meta;
74        false
75    }
76}
77
78/// Whether an access error is macOS's privacy system (TCC) refusing, rather
79/// than ordinary permissions: TCC denials are `EPERM`, ordinary ones `EACCES`
80/// (spec §9.1.1 fact 2). Rust maps both to `PermissionDenied`, so the raw
81/// code decides.
82pub fn is_os_blocked(err: &io::Error) -> bool {
83    #[cfg(target_os = "macos")]
84    {
85        err.raw_os_error() == Some(libc::EPERM)
86    }
87    #[cfg(not(target_os = "macos"))]
88    {
89        let _ = err;
90        false
91    }
92}
93
94/// Rename `from` to `to`, failing with `AlreadyExists` instead of replacing
95/// an existing `to`.
96///
97/// `std::fs::rename` replaces an existing destination on every platform, so
98/// checking first and renaming after would let a file created in between be
99/// silently overwritten. Each OS has an atomic no-replace rename; where the
100/// filesystem doesn't support it, this falls back to check-then-rename.
101pub fn rename_no_replace(from: &Path, to: &Path) -> io::Result<()> {
102    #[cfg(windows)]
103    {
104        use std::os::windows::ffi::OsStrExt;
105        let wide = |p: &Path| -> Vec<u16> { p.as_os_str().encode_wide().chain(std::iter::once(0)).collect() };
106        let (f, t) = (wide(from), wide(to));
107        // Flags 0: without MOVEFILE_REPLACE_EXISTING an existing destination
108        // fails with ERROR_ALREADY_EXISTS, which std maps to AlreadyExists.
109        // SAFETY: both buffers are NUL-terminated UTF-16 that outlive the call.
110        let ok = unsafe { windows_sys::Win32::Storage::FileSystem::MoveFileExW(f.as_ptr(), t.as_ptr(), 0) };
111        if ok == 0 {
112            return Err(io::Error::last_os_error());
113        }
114        Ok(())
115    }
116    #[cfg(target_os = "linux")]
117    {
118        use std::os::unix::ffi::OsStrExt;
119        let f = std::ffi::CString::new(from.as_os_str().as_bytes())?;
120        let t = std::ffi::CString::new(to.as_os_str().as_bytes())?;
121        // The raw syscall rather than glibc's wrapper, which only exists from
122        // glibc 2.28 on.
123        // SAFETY: both pointers are NUL-terminated strings that outlive the call.
124        let r = unsafe {
125            libc::syscall(
126                libc::SYS_renameat2,
127                libc::AT_FDCWD,
128                f.as_ptr(),
129                libc::AT_FDCWD,
130                t.as_ptr(),
131                libc::RENAME_NOREPLACE,
132            )
133        };
134        if r == 0 {
135            return Ok(());
136        }
137        let err = io::Error::last_os_error();
138        match err.raw_os_error() {
139            // An old kernel, or a filesystem without RENAME_NOREPLACE.
140            Some(libc::ENOSYS) | Some(libc::EINVAL) => rename_checked(from, to),
141            _ => Err(err),
142        }
143    }
144    #[cfg(target_os = "macos")]
145    {
146        use std::os::unix::ffi::OsStrExt;
147        let f = std::ffi::CString::new(from.as_os_str().as_bytes())?;
148        let t = std::ffi::CString::new(to.as_os_str().as_bytes())?;
149        // SAFETY: both pointers are NUL-terminated strings that outlive the call.
150        let r = unsafe { libc::renamex_np(f.as_ptr(), t.as_ptr(), libc::RENAME_EXCL) };
151        if r == 0 {
152            return Ok(());
153        }
154        let err = io::Error::last_os_error();
155        match err.raw_os_error() {
156            // A filesystem without RENAME_EXCL (some network volumes).
157            Some(libc::ENOTSUP) | Some(libc::EINVAL) => rename_checked(from, to),
158            _ => Err(err),
159        }
160    }
161    #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
162    {
163        rename_checked(from, to)
164    }
165}
166
167/// Check-then-rename, for filesystems with no atomic no-replace rename.
168/// `symlink_metadata` rather than `exists`, so a dangling link at `to`
169/// still counts as taken.
170#[cfg_attr(windows, allow(dead_code))]
171fn rename_checked(from: &Path, to: &Path) -> io::Result<()> {
172    if std::fs::symlink_metadata(to).is_ok() {
173        return Err(io::Error::new(io::ErrorKind::AlreadyExists, "destination exists"));
174    }
175    std::fs::rename(from, to)
176}
177
178/// Permanently remove the entry at `path` without following it: a symlink
179/// (or Windows junction) is unlinked and its target kept, a directory is
180/// removed with its contents, anything else is removed as a file.
181///
182/// Same rule as `deleteeditorfile`, which acts on the link path rather than
183/// the canonical path for exactly this reason. `remove_dir_all` does not
184/// follow links inside the tree either.
185pub fn remove_entry_no_follow(path: &Path) -> io::Result<()> {
186    let file_type = std::fs::symlink_metadata(path)?.file_type();
187    if file_type.is_symlink() {
188        // A directory link on Windows (symlink or junction) needs
189        // RemoveDirectory; DeleteFile refuses it. Elsewhere a link is a file.
190        #[cfg(windows)]
191        {
192            use std::os::windows::fs::FileTypeExt;
193            if file_type.is_symlink_dir() {
194                return std::fs::remove_dir(path);
195            }
196        }
197        return std::fs::remove_file(path);
198    }
199    if file_type.is_dir() {
200        std::fs::remove_dir_all(path)
201    } else {
202        std::fs::remove_file(path)
203    }
204}
205
206/// Open `path` for reading, refusing a symlink where the OS can: a copy
207/// reads the file that was listed, never what a link swapped in since
208/// points at (spec §9: never follow a link when copying).
209///
210/// Unix refuses with `O_NOFOLLOW`. Windows opens normally: the flag that
211/// would refuse a link there (`FILE_FLAG_OPEN_REPARSE_POINT`) also opens a
212/// cloud placeholder (OneDrive) without fetching its contents. The caller
213/// checks the opened file's own type either way.
214pub fn open_no_follow(path: &Path) -> io::Result<std::fs::File> {
215    #[cfg(unix)]
216    {
217        use std::os::unix::fs::OpenOptionsExt;
218        std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW).open(path)
219    }
220    #[cfg(not(unix))]
221    {
222        std::fs::File::open(path)
223    }
224}
225
226/// Create a symlink at `link` pointing at `target`, of the same kind as
227/// the link being copied (`like`, its own file type). Windows has separate
228/// file and directory links; a junction is recreated as a directory link.
229pub fn create_symlink(target: &Path, link: &Path, like: &std::fs::FileType) -> io::Result<()> {
230    #[cfg(unix)]
231    {
232        let _ = like;
233        std::os::unix::fs::symlink(target, link)
234    }
235    #[cfg(windows)]
236    {
237        use std::os::windows::fs::FileTypeExt;
238        if like.is_symlink_dir() {
239            std::os::windows::fs::symlink_dir(target, link)
240        } else {
241            std::os::windows::fs::symlink_file(target, link)
242        }
243    }
244    #[cfg(not(any(unix, windows)))]
245    {
246        let _ = (target, link, like);
247        Err(io::Error::new(io::ErrorKind::Unsupported, "symlinks aren't supported here"))
248    }
249}
250
251/// Whether creating a symlink failed because Windows requires Developer
252/// Mode or elevation for it (`ERROR_PRIVILEGE_NOT_HELD`).
253pub fn is_symlink_privilege_error(err: &io::Error) -> bool {
254    const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314;
255    cfg!(windows) && err.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD)
256}
257
258/// Remove the regular file at `path`. On Windows a read-only file refuses
259/// deletion, so the attribute is cleared first when that is the refusal:
260/// a moved file's original and a discarded partial copy both have to go.
261pub fn remove_file_force(path: &Path) -> io::Result<()> {
262    match std::fs::remove_file(path) {
263        #[cfg(windows)]
264        Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
265            let meta = std::fs::symlink_metadata(path)?;
266            if !meta.is_file() || !meta.permissions().readonly() {
267                return Err(e);
268            }
269            let mut perms = meta.permissions();
270            // Windows only: this clears the read-only attribute; there is
271            // no Unix mode to make world-writable.
272            #[allow(clippy::permissions_set_readonly_false)]
273            perms.set_readonly(false);
274            std::fs::set_permissions(path, perms)?;
275            std::fs::remove_file(path)
276        }
277        other => other,
278    }
279}
280
281/// Why `path` can't go to the Recycle Bin, if it can't.
282///
283/// Windows has no Recycle Bin on network shares or removable drives, and
284/// the shell's delete with "allow undo" and no UI then deletes permanently
285/// rather than failing. So those are refused here, and the user is pointed
286/// at the explicit permanent delete (spec §7.3). Always `None` elsewhere.
287pub fn trash_unavailable_reason(path: &Path) -> Option<&'static str> {
288    #[cfg(windows)]
289    {
290        const NETWORK: &str = "Network locations have no Recycle Bin. Use Delete permanently instead.";
291        const REMOVABLE: &str = "This drive has no Recycle Bin. Use Delete permanently instead.";
292        // GetDriveTypeW's answers (winbase.h). Defined here rather than
293        // enabling windows-sys's whole WindowsProgramming feature for two.
294        const DRIVE_REMOVABLE: u32 = 2;
295        const DRIVE_REMOTE: u32 = 4;
296        let display = display_path(path);
297        if display.starts_with(r"\\") {
298            return Some(NETWORK);
299        }
300        let b = display.as_bytes();
301        if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
302            let root: Vec<u16> = format!("{}:\\", b[0] as char).encode_utf16().chain(std::iter::once(0)).collect();
303            // SAFETY: `root` is a NUL-terminated UTF-16 string that outlives the call.
304            let kind = unsafe { windows_sys::Win32::Storage::FileSystem::GetDriveTypeW(root.as_ptr()) };
305            return match kind {
306                DRIVE_REMOTE => Some(NETWORK),
307                DRIVE_REMOVABLE => Some(REMOVABLE),
308                _ => None,
309            };
310        }
311        None
312    }
313    #[cfg(not(windows))]
314    {
315        let _ = path;
316        None
317    }
318}
319
320/// `explorer.exe`'s argument for `display`. Windows paths can't contain `"`,
321/// so quoting is unambiguous, and it keeps a path with spaces or commas in
322/// one piece. A trailing backslash would escape the closing quote, so it is
323/// trimmed; a drive root (`C:\`) has no spaces and goes unquoted.
324#[cfg_attr(not(windows), allow(dead_code))]
325pub fn explorer_quote(display: &str) -> String {
326    let b = display.as_bytes();
327    if b.len() == 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && b[2] == b'\\' {
328        return display.to_string();
329    }
330    format!("\"{}\"", display.trim_end_matches('\\'))
331}
332
333/// The command that opens `path` with the OS default application, for
334/// `spawn_detached` (which sanitizes its environment).
335pub fn open_command(path: &Path) -> Command {
336    #[cfg(windows)]
337    {
338        use std::os::windows::process::CommandExt;
339        // explorer.exe hands the path to the already-running shell, which
340        // opens it the way a double-click would. ShellExecuteW from this
341        // process would instead start the application from srv's own
342        // environment.
343        let mut cmd = Command::new("explorer.exe");
344        cmd.raw_arg(explorer_quote(&display_path(path)));
345        cmd
346    }
347    #[cfg(target_os = "macos")]
348    {
349        let mut cmd = Command::new("open");
350        cmd.arg(path);
351        cmd
352    }
353    #[cfg(not(any(windows, target_os = "macos")))]
354    {
355        let mut cmd = Command::new("xdg-open");
356        cmd.arg(path);
357        cmd
358    }
359}
360
361/// The command that shows `path` selected in the OS file manager. Linux has
362/// no portable "select", so it opens the containing folder.
363pub fn reveal_command(path: &Path) -> Command {
364    #[cfg(windows)]
365    {
366        use std::os::windows::process::CommandExt;
367        let mut cmd = Command::new("explorer.exe");
368        cmd.raw_arg(format!("/select,{}", explorer_quote(&display_path(path))));
369        cmd
370    }
371    #[cfg(target_os = "macos")]
372    {
373        let mut cmd = Command::new("open");
374        cmd.arg("-R").arg(path);
375        cmd
376    }
377    #[cfg(not(any(windows, target_os = "macos")))]
378    {
379        let mut cmd = Command::new("xdg-open");
380        cmd.arg(path.parent().unwrap_or(path));
381        cmd
382    }
383}
384
385/// Spawn `cmd` detached from srv's own stdio, and reap it on a thread.
386///
387/// Null stdio because srv's stdout may be a pipe its launcher reads; a
388/// reaper because an un-waited child stays a zombie on Unix until srv
389/// exits, and `xdg-open` can run as long as the application it starts.
390///
391/// The program is third-party and long-lived (a file manager, or whatever
392/// application the OS opens the file with), and could launch another
393/// AgentMux build that would adopt this instance's identity: the strict
394/// external-process policy, as `openinshell` uses (invariant I7). Applied
395/// here, where the spawn is, so no caller can skip it.
396pub fn spawn_detached(mut cmd: Command) -> io::Result<()> {
397    crate::backend::pane_env::sanitize_external_std_command(&mut cmd);
398    let mut child = cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn()?;
399    std::thread::Builder::new()
400        .name("fs-open-reaper".to_string())
401        .spawn(move || {
402            let _ = child.wait();
403        })?;
404    Ok(())
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn verbatim_prefixes_are_stripped_for_display() {
413        assert_eq!(strip_verbatim(r"\\?\C:\Users\me"), r"C:\Users\me");
414        assert_eq!(strip_verbatim(r"\\?\C:\"), r"C:\");
415        assert_eq!(strip_verbatim(r"\\?\UNC\nas\share\dir"), r"\\nas\share\dir");
416        // No ordinary spelling exists for a volume GUID path; keep it.
417        assert_eq!(strip_verbatim(r"\\?\Volume{abc}\x"), r"\\?\Volume{abc}\x");
418        assert_eq!(strip_verbatim("/home/me"), "/home/me");
419        assert_eq!(strip_verbatim(r"C:\plain"), r"C:\plain");
420    }
421
422    #[test]
423    fn hidden_and_system_attributes_both_hide() {
424        assert!(attributes_mark_hidden(0x2));
425        assert!(attributes_mark_hidden(0x4));
426        assert!(attributes_mark_hidden(0x2 | 0x20));
427        assert!(!attributes_mark_hidden(0x20)); // ARCHIVE
428        assert!(!attributes_mark_hidden(0x10)); // DIRECTORY
429        assert!(!attributes_mark_hidden(0x1)); // READONLY
430    }
431
432    #[test]
433    fn explorer_arguments_stay_in_one_piece() {
434        assert_eq!(explorer_quote(r"C:\a b\c,d.txt"), r#""C:\a b\c,d.txt""#);
435        assert_eq!(explorer_quote(r"C:\"), r"C:\");
436        assert_eq!(explorer_quote(r"\\nas\My Share\"), r#""\\nas\My Share""#);
437    }
438
439    #[test]
440    fn rename_no_replace_refuses_an_existing_destination() {
441        let dir = tempfile::tempdir().unwrap();
442        let (a, b) = (dir.path().join("a.txt"), dir.path().join("b.txt"));
443        std::fs::write(&a, "a").unwrap();
444        std::fs::write(&b, "b").unwrap();
445        let err = rename_no_replace(&a, &b).unwrap_err();
446        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists, "{err}");
447        assert_eq!(std::fs::read_to_string(&b).unwrap(), "b", "the destination must be untouched");
448
449        let c = dir.path().join("c.txt");
450        rename_no_replace(&a, &c).unwrap();
451        assert!(!a.exists());
452        assert_eq!(std::fs::read_to_string(&c).unwrap(), "a");
453    }
454
455    #[test]
456    fn deleting_a_link_keeps_its_target() {
457        let dir = tempfile::tempdir().unwrap();
458        let target = dir.path().join("target");
459        std::fs::create_dir(&target).unwrap();
460        std::fs::write(target.join("keep.txt"), "x").unwrap();
461        let link = dir.path().join("link");
462        #[cfg(unix)]
463        let made = std::os::unix::fs::symlink(&target, &link).is_ok();
464        // Creating a symlink on Windows needs Developer Mode or elevation;
465        // without either there's nothing to test.
466        #[cfg(windows)]
467        let made = std::os::windows::fs::symlink_dir(&target, &link).is_ok();
468        if !made {
469            return;
470        }
471        remove_entry_no_follow(&link).unwrap();
472        assert!(std::fs::symlink_metadata(&link).is_err(), "the link is gone");
473        assert!(target.join("keep.txt").exists(), "the target and its contents remain");
474    }
475
476    #[test]
477    fn deleting_a_directory_removes_its_contents() {
478        let dir = tempfile::tempdir().unwrap();
479        let sub = dir.path().join("sub");
480        std::fs::create_dir_all(sub.join("deeper")).unwrap();
481        std::fs::write(sub.join("deeper").join("f.txt"), "x").unwrap();
482        remove_entry_no_follow(&sub).unwrap();
483        assert!(!sub.exists());
484    }
485}