agentmux_srv/backend/fs_ops/
platform.rs1use std::io;
14use std::path::Path;
15use std::process::{Command, Stdio};
16
17pub 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
32pub 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#[cfg(any(windows, test))]
50const ATTR_HIDDEN: u32 = 0x2;
51#[cfg(any(windows, test))]
53const ATTR_SYSTEM: u32 = 0x4;
54
55#[cfg(any(windows, test))]
58pub fn attributes_mark_hidden(attributes: u32) -> bool {
59 attributes & (ATTR_HIDDEN | ATTR_SYSTEM) != 0
60}
61
62pub 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
78pub 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
94pub 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 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 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 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 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 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#[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
178pub 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 #[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
206pub 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
226pub 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
251pub 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
258pub 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 #[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
281pub 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 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 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#[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
333pub fn open_command(path: &Path) -> Command {
336 #[cfg(windows)]
337 {
338 use std::os::windows::process::CommandExt;
339 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
361pub 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
385pub 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 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)); assert!(!attributes_mark_hidden(0x10)); assert!(!attributes_mark_hidden(0x1)); }
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 #[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}