diff --git a/Cargo.lock b/Cargo.lock index 4150d0d..9205b91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,6 +194,7 @@ dependencies = [ "sha2", "unicode-segmentation", "unicode-width", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 044ac2c..8db12b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ serde_json = "1" unicode-segmentation = "1" unicode-width = "0.2" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_System_Time", "Win32_UI_Shell"] } + [profile.release] lto = true codegen-units = 1 diff --git a/src/config.rs b/src/config.rs index 3dcfe7c..5715cb3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,14 +44,18 @@ impl Default for Settings { } } -/// The config file location. +/// The config file location: `/mapsize/config.json` where dir is +/// %AppData% on Windows, ~/Library/Application Support on macOS, and +/// $XDG_CONFIG_HOME or ~/.config elsewhere. pub fn path() -> Option { let env = |k: &str| { std::env::var_os(k) .filter(|v| !v.is_empty()) .map(PathBuf::from) }; - let dir = if cfg!(target_os = "macos") { + let dir = if cfg!(windows) { + env("AppData")? + } else if cfg!(target_os = "macos") { env("HOME")?.join("Library/Application Support") } else { env("XDG_CONFIG_HOME").or_else(|| env("HOME").map(|h| h.join(".config")))? diff --git a/src/duplicate.rs b/src/duplicate.rs index 1fd640d..fec328b 100644 --- a/src/duplicate.rs +++ b/src/duplicate.rs @@ -14,7 +14,6 @@ use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::fs::File; use std::io::{self, Read}; -use std::os::unix::fs::FileExt; use std::path::PathBuf; use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering}; use std::sync::{Mutex, RwLock}; @@ -229,7 +228,7 @@ fn sample_hash(_: &Finder, c: &Cand) -> Option<[u8; 32]> { let mut h = Sha256::new(); let mut buf = vec![0u8; SAMPLE_SIZE as usize]; for off in [0, c.size / 2 - SAMPLE_SIZE / 2, c.size - SAMPLE_SIZE] { - match fh.read_exact_at(&mut buf, off as u64) { + match platform::read_exact_at(&fh, &mut buf, off as u64) { Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {} // shrank; stage 3 rejects it Err(_) => return None, @@ -263,14 +262,12 @@ fn full_hash(f: &Finder, c: &Cand) -> Option<[u8; 32]> { mod tests { use super::*; use crate::scan::{self, tests::TempDir}; - use std::os::unix::ffi::OsStrExt; use std::path::Path; use std::sync::Arc; - use std::time::Duration; fn scan(root: &Path, workers: usize) -> Arc> { let s = scan::start( - root.as_os_str().as_bytes(), + root.as_os_str().as_encoded_bytes(), scan::Options { workers, ..Default::default() @@ -323,8 +320,11 @@ mod tests { /// A candidate replaced by a FIFO (or a symlink to one) after the scan /// must be skipped, not block the search forever in open(2). + #[cfg(unix)] #[test] fn find_skips_swapped_fifo() { + use std::os::unix::ffi::OsStrExt; + use std::time::Duration; let root = TempDir::new("dupfifo"); let other = TempDir::new("dupfifo2"); for n in ["a", "b", "c"] { diff --git a/src/filter/eval.rs b/src/filter/eval.rs index 24ed7e1..7142c81 100644 --- a/src/filter/eval.rs +++ b/src/filter/eval.rs @@ -98,8 +98,8 @@ impl<'a> Ctx<'a> { .filter(|p| !p.is_empty()) { self.path.push_str(pp); - if !pp.ends_with('/') { - self.path.push('/'); + if !pp.ends_with(platform::SEP as char) { + self.path.push(platform::SEP as char); } push_lower(&mut self.path, &self.node.name); return; diff --git a/src/filter/parser.rs b/src/filter/parser.rs index 7633c07..4de7ccb 100644 --- a/src/filter/parser.rs +++ b/src/filter/parser.rs @@ -1,6 +1,7 @@ use super::eval::{Expr, NumOp}; use super::lexer::{lex, TokKind, Token}; use crate::inventory::{self as inv, Flags}; +use crate::platform::days_from_civil; use regex::Regex; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -561,24 +562,6 @@ fn days_in(y: i64, m: i64) -> i64 { } } -/// Days since 1970-01-01 of a proleptic Gregorian date. -fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146097 + doe - 719468 -} - fn local_nanos(y: i64, mo: i64, d: i64, h: i64, mi: i64) -> Option { - let mut tm: libc::tm = unsafe { std::mem::zeroed() }; - tm.tm_year = (y - 1900) as i32; - tm.tm_mon = (mo - 1) as i32; - tm.tm_mday = d as i32; - tm.tm_hour = h as i32; - tm.tm_min = mi as i32; - tm.tm_isdst = -1; - let t = unsafe { libc::mktime(&mut tm) }; - (t != -1).then(|| t as i64 * 1_000_000_000) + Some(crate::platform::local_to_unix(y, mo, d, h, mi) * 1_000_000_000) } diff --git a/src/inventory/mod.rs b/src/inventory/mod.rs index 315a6c6..cda018b 100644 --- a/src/inventory/mod.rs +++ b/src/inventory/mod.rs @@ -13,9 +13,8 @@ pub use category::*; pub use query::*; pub use stats::*; +use crate::platform; use std::collections::HashMap; -use std::ffi::OsStr; -use std::os::unix::ffi::OsStrExt; use std::path::PathBuf; /// Addresses a node within one Tree. The root is always 0. @@ -310,8 +309,8 @@ impl Tree { } let mut out = Vec::with_capacity(parts.iter().map(|p| p.len() + 1).sum()); for p in parts.iter().rev() { - if !out.is_empty() && out.last() != Some(&b'/') { - out.push(b'/'); + if !out.is_empty() && !out.last().is_some_and(|&b| platform::is_sep(b)) { + out.push(platform::SEP); } out.extend_from_slice(p); } @@ -320,7 +319,7 @@ impl Tree { /// Reconstructs the full path of id. pub fn path(&self, id: NodeId) -> PathBuf { - PathBuf::from(OsStr::from_bytes(&self.path_bytes(id))) + platform::path_from_bytes(&self.path_bytes(id)) } /// The full path as (lossy) UTF-8, unsanitised. diff --git a/src/inventory/stats.rs b/src/inventory/stats.rs index 2c9369c..2685296 100644 --- a/src/inventory/stats.rs +++ b/src/inventory/stats.rs @@ -42,16 +42,18 @@ impl ErrKind { /// Maps an OS error to an ErrKind. pub fn classify(err: &io::Error) -> ErrKind { + #[cfg(unix)] match err.raw_os_error() { - Some(libc::EACCES) | Some(libc::EPERM) => ErrKind::Permission, - Some(libc::ENOENT) => ErrKind::Vanished, - Some(libc::ELOOP) => ErrKind::Loop, - Some(libc::EIO) => ErrKind::Io, - _ => match err.kind() { - io::ErrorKind::PermissionDenied => ErrKind::Permission, - io::ErrorKind::NotFound => ErrKind::Vanished, - _ => ErrKind::Other, - }, + Some(libc::EACCES | libc::EPERM) => return ErrKind::Permission, + Some(libc::ENOENT) => return ErrKind::Vanished, + Some(libc::ELOOP) => return ErrKind::Loop, + Some(libc::EIO) => return ErrKind::Io, + _ => {} + } + match err.kind() { + io::ErrorKind::PermissionDenied => ErrKind::Permission, + io::ErrorKind::NotFound => ErrKind::Vanished, + _ => ErrKind::Other, } } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index bb1a180..7106c18 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,19 +1,57 @@ -//! OS-specific filesystem metadata and file operations (Unix; Linux first). -//! Nothing else in the program calls libc for filesystem work. +//! OS-specific filesystem metadata and file operations behind a small common +//! API. Nothing else in the program calls the OS directly for filesystem +//! work; the per-OS halves live in `unix.rs` and `windows.rs`. +//! +//! Per OS: +//! * Linux: statx relative to the directory fd (birth time), virtual +//! filesystem detection, freedesktop.org trash, xdg-open. +//! * macOS: fstatat relative to the directory fd, Finder trash, `open -R`. +//! * FreeBSD, NetBSD: fstatat relative to the directory fd; no trash or +//! reveal (permanent delete only, offered on headless systems). +//! * Windows: std directory listing and metadata. Allocated size is unknown +//! and there is no inode identity, so symlinks are never followed and hard +//! links are not deduplicated. Recycle Bin and Explorer reveal; no +//! permanent delete. use std::collections::HashMap; -use std::ffi::{CStr, CString, OsStr}; +use std::ffi::OsStr; use std::fs::File; -use std::io::{self, Write}; -use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; -use std::os::unix::ffi::OsStrExt; -use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; +use std::io; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; -/// File mode bits in Go's io/fs.FileMode layout. Kept so snapshots stay -/// byte-compatible with the Go implementation. +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub use unix::*; +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub use windows::*; + +/// File mode bits as stored in snapshots, identical on every OS so that +/// snapshots are portable. +/// +/// The low 9 bits are the rwxrwxrwx permissions. Bits 31 down to 19 are +/// flags, rendered by [`mode::string`] with one letter each: +/// +/// | bit | letter | meaning | +/// |-----|--------|---------| +/// | 31 | d | directory | +/// | 30 | a | append-only | +/// | 29 | l | exclusive use | +/// | 28 | T | temporary | +/// | 27 | L | symbolic link | +/// | 26 | D | device | +/// | 25 | p | named pipe (FIFO) | +/// | 24 | S | Unix domain socket | +/// | 23 | u | setuid | +/// | 22 | g | setgid | +/// | 21 | c | character device (with D) | +/// | 20 | t | sticky | +/// | 19 | ? | irregular (none of the above types) | +/// +/// A regular file has none of the type bits (d L D p S c ?) set. pub mod mode { pub const DIR: u32 = 1 << 31; pub const APPEND: u32 = 1 << 30; @@ -41,7 +79,8 @@ pub mod mode { m & SYMLINK != 0 } - /// Formats like Go's FileMode.String, e.g. "drwxr-xr-x". + /// Formats the mode as the set flag letters (in bit order, or "-" if + /// none) followed by rwxrwxrwx, e.g. "drwxr-xr-x", "Lrwxrwxrwx". pub fn string(m: u32) -> String { const STR: &[u8] = b"dalTLDpSugct?"; let mut out = String::with_capacity(12); @@ -60,35 +99,11 @@ pub mod mode { } } -/// Converts a Unix st_mode to the Go FileMode layout. -pub fn mode_from_unix(m: u32) -> u32 { - let mut out = m & 0o777; - out |= match m & libc::S_IFMT { - libc::S_IFDIR => mode::DIR, - libc::S_IFLNK => mode::SYMLINK, - libc::S_IFIFO => mode::NAMED_PIPE, - libc::S_IFSOCK => mode::SOCKET, - libc::S_IFCHR => mode::DEVICE | mode::CHAR_DEVICE, - libc::S_IFBLK => mode::DEVICE, - _ => 0, - }; - if m & libc::S_ISUID != 0 { - out |= mode::SETUID; - } - if m & libc::S_ISGID != 0 { - out |= mode::SETGID; - } - if m & libc::S_ISVTX != 0 { - out |= mode::STICKY; - } - out -} - /// Metadata of one directory entry. #[derive(Debug, Default)] pub struct Meta { pub name: Vec, - pub mode: u32, // Go FileMode layout + pub mode: u32, // see `mode` pub size: i64, pub alloc: i64, pub alloc_known: bool, @@ -99,175 +114,10 @@ pub struct Meta { pub nlink: u32, pub dev: u64, pub ino: u64, - pub has_ino: bool, + pub has_ino: bool, // dev/ino are meaningful pub err: Option, // stat failed; only name is valid } -fn cstr(b: &[u8]) -> io::Result { - CString::new(b).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL)) -} - -fn cvt(r: libc::c_int) -> io::Result { - if r < 0 { - Err(io::Error::last_os_error()) - } else { - Ok(r) - } -} - -fn open_dir(path: &[u8]) -> io::Result { - let c = cstr(path)?; - let fd = cvt(unsafe { - libc::open( - c.as_ptr(), - libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC, - ) - })?; - Ok(unsafe { OwnedFd::from_raw_fd(fd) }) -} - -#[cfg(target_os = "linux")] -static NO_STATX: AtomicBool = AtomicBool::new(false); - -#[cfg(target_os = "linux")] -fn stat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { - if !NO_STATX.load(Ordering::Relaxed) { - let mut st: libc::statx = unsafe { std::mem::zeroed() }; - let mask = libc::STATX_BASIC_STATS | libc::STATX_BTIME; - let r = unsafe { - libc::statx( - dirfd, - name.as_ptr(), - flags | libc::AT_STATX_DONT_SYNC, - mask, - &mut st, - ) - }; - if r == 0 { - let mut m = Meta { - mode: mode_from_unix(st.stx_mode as u32), - size: st.stx_size as i64, - alloc: st.stx_blocks as i64 * 512, - alloc_known: true, - mtime: st.stx_mtime.tv_sec * 1_000_000_000 + st.stx_mtime.tv_nsec as i64, - uid: st.stx_uid, - gid: st.stx_gid, - nlink: st.stx_nlink, - dev: libc::makedev(st.stx_dev_major, st.stx_dev_minor), - ino: st.stx_ino, - has_ino: true, - ..Default::default() - }; - if st.stx_mask & libc::STATX_BTIME != 0 { - m.btime = st.stx_btime.tv_sec * 1_000_000_000 + st.stx_btime.tv_nsec as i64; - } - return Ok(m); - } - let err = io::Error::last_os_error(); - if err.raw_os_error() != Some(libc::ENOSYS) { - return Err(err); - } - NO_STATX.store(true, Ordering::Relaxed); - } - fstat_at(dirfd, name, flags) -} - -#[cfg(not(target_os = "linux"))] -fn stat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { - fstat_at(dirfd, name, flags) -} - -fn fstat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { - let mut st: libc::stat = unsafe { std::mem::zeroed() }; - cvt(unsafe { libc::fstatat(dirfd, name.as_ptr(), &mut st, flags) })?; - Ok(from_stat(&st)) -} - -#[allow(clippy::unnecessary_cast)] -fn from_stat(st: &libc::stat) -> Meta { - Meta { - mode: mode_from_unix(st.st_mode as u32), - size: st.st_size as i64, - alloc: st.st_blocks as i64 * 512, - alloc_known: true, - mtime: st.st_mtime as i64 * 1_000_000_000 + st.st_mtime_nsec as i64, - uid: st.st_uid, - gid: st.st_gid, - nlink: st.st_nlink as u32, - dev: st.st_dev as u64, - ino: st.st_ino as u64, - has_ino: true, - ..Default::default() - } -} - -/// An open directory stream (readdir over the fd we also stat relative to). -struct DirStream { - dir: *mut libc::DIR, -} - -impl Drop for DirStream { - fn drop(&mut self) { - unsafe { libc::closedir(self.dir) }; - } -} - -/// Streams the entries of a directory in chunks of up to `chunk`, stat-ing -/// each entry relative to the directory fd (no repeated path resolution). -/// Returning an error from f stops the read and returns it. -pub fn read_dir>( - path: &[u8], - chunk: usize, - mut f: impl FnMut(Vec) -> Result<(), E>, -) -> Result<(), E> { - let fd = open_dir(path)?; - let raw = fd.as_raw_fd(); - let dir = unsafe { libc::fdopendir(raw) }; - if dir.is_null() { - return Err(io::Error::last_os_error().into()); - } - std::mem::forget(fd); // owned by the DIR stream now - let ds = DirStream { dir }; - let mut out = Vec::with_capacity(chunk); - loop { - unsafe { *libc::__errno_location() = 0 }; - let ent = unsafe { libc::readdir(ds.dir) }; - if ent.is_null() { - let e = io::Error::last_os_error(); - if e.raw_os_error().unwrap_or(0) != 0 { - return Err(e.into()); - } - break; - } - let name = unsafe { CStr::from_ptr((*ent).d_name.as_ptr()) }; - let nb = name.to_bytes(); - if nb == b"." || nb == b".." { - continue; - } - let mut m = stat_at(raw, name, libc::AT_SYMLINK_NOFOLLOW).unwrap_or_else(|e| Meta { - err: Some(e), - ..Default::default() - }); - m.name = nb.to_vec(); - out.push(m); - if out.len() >= chunk { - f(std::mem::replace(&mut out, Vec::with_capacity(chunk)))?; - } - } - if !out.is_empty() { - f(out)?; - } - Ok(()) -} - -fn stat_path(path: &[u8], follow: bool) -> io::Result { - let c = cstr(path)?; - let flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW }; - let mut m = stat_at(libc::AT_FDCWD, &c, flags)?; - m.name = base_name(path).to_vec(); - Ok(m) -} - /// Metadata for path without following a final symlink. pub fn lstat(path: &[u8]) -> io::Result { stat_path(path, false) @@ -278,108 +128,6 @@ pub fn stat(path: &[u8]) -> io::Result { stat_path(path, true) } -/// Joins a directory and an entry name without cleaning. -pub fn join_path(dir: &[u8], name: &[u8]) -> Vec { - let mut p = Vec::with_capacity(dir.len() + name.len() + 1); - p.extend_from_slice(dir); - if dir.last() != Some(&b'/') { - p.push(b'/'); - } - p.extend_from_slice(name); - p -} - -fn base_name(path: &[u8]) -> &[u8] { - let mut end = path.len(); - while end > 1 && path[end - 1] == b'/' { - end -= 1; - } - let p = &path[..end]; - match p.iter().rposition(|&b| b == b'/') { - Some(i) if p.len() > 1 => &p[i + 1..], - _ => p, - } -} - -static NAMES: Mutex, HashMap)>> = Mutex::new(None); - -fn lookup_name(group: bool, id: u32) -> String { - let mut g = NAMES.lock().unwrap(); - let (users, groups) = g.get_or_insert_with(Default::default); - let cache = if group { groups } else { users }; - if let Some(n) = cache.get(&id) { - return n.clone(); - } - let mut buf = vec![0u8; 16384]; - let name = unsafe { - if group { - let mut gr: libc::group = std::mem::zeroed(); - let mut res = std::ptr::null_mut(); - libc::getgrgid_r(id, &mut gr, buf.as_mut_ptr().cast(), buf.len(), &mut res); - (!res.is_null()).then(|| CStr::from_ptr(gr.gr_name).to_string_lossy().into_owned()) - } else { - let mut pw: libc::passwd = std::mem::zeroed(); - let mut res = std::ptr::null_mut(); - libc::getpwuid_r(id, &mut pw, buf.as_mut_ptr().cast(), buf.len(), &mut res); - (!res.is_null()).then(|| CStr::from_ptr(pw.pw_name).to_string_lossy().into_owned()) - } - } - .unwrap_or_else(|| id.to_string()); - cache.insert(id, name.clone()); - name -} - -/// Resolves a uid to a name (cached; falls back to the number). -pub fn user_name(uid: u32) -> String { - lookup_name(false, uid) -} - -/// Resolves a gid to a name (cached; falls back to the number). -pub fn group_name(gid: u32) -> String { - lookup_name(true, gid) -} - -/// Linux pseudo-filesystems whose contents are not disk usage. -#[cfg(target_os = "linux")] -const VIRTUAL_FS: [i64; 19] = [ - 0x9fa0, // proc - 0x62656572, // sysfs - 0x1cd1, // devpts - 0x27e0eb, // cgroup - 0x63677270, // cgroup2 - 0x64626720, // debugfs - 0x74726163, // tracefs - 0x73636673, // securityfs - 0x6165676c, // pstore - 0xcafe4a11, // bpf - 0x62656570, // configfs - 0x65735543, // fusectl - 0x19800202, // mqueue - 0xde5e81e4, // efivarfs - 0x42494e4d, // binfmt_misc - 0xf97cff8c, // selinuxfs - 0x958458f6, // hugetlbfs - 0x6e736673, // nsfs - 0x50495045, // pipefs -]; - -/// Whether path is the root of a pseudo filesystem (proc, sysfs, cgroup, …) -/// that should not be scanned as disk usage. -#[cfg(target_os = "linux")] -pub fn is_virtual_fs(path: &[u8]) -> bool { - let Ok(c) = cstr(path) else { return false }; - let mut st: libc::statfs = unsafe { std::mem::zeroed() }; - if unsafe { libc::statfs(c.as_ptr(), &mut st) } != 0 { - return false; - } - VIRTUAL_FS.contains(&(st.f_type as i64)) -} - -#[cfg(not(target_os = "linux"))] -pub fn is_virtual_fs(_: &[u8]) -> bool { - false -} - /// What the scan recorded for an item about to be trashed or deleted. The /// operation refuses an item whose owner or file type no longer matches. #[derive(Clone, Copy, Debug)] @@ -388,8 +136,6 @@ pub struct Expect { pub mode: u32, // only the type bits are compared } -const CHANGED: &str = "item changed since the scan (owner or type differs); rescan first"; - fn other(msg: impl Into) -> io::Error { io::Error::other(msg.into()) } @@ -403,22 +149,13 @@ fn check_abs(path: &Path) -> io::Result<()> { std::fs::symlink_metadata(path).map(|_| ()) } -/// Opens a file whose content is about to be read. It does not follow a -/// final symlink, and O_NONBLOCK keeps a FIFO swapped in since the scan from -/// blocking open(2) forever; callers still check the file type. -pub fn open_content(path: &Path) -> io::Result { - std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) - .open(path) -} - /// Runs a helper program with an argument vector (never a shell), detached /// from the terminal so it cannot disturb the TUI. -fn start_detached(name: &str, arg: &Path) -> io::Result<()> { +#[allow(dead_code)] // unused where reveal is unsupported +fn start_detached(name: &str, args: &[&OsStr]) -> io::Result<()> { use std::process::{Command, Stdio}; let mut child = Command::new(name) - .arg(arg) + .args(args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -427,327 +164,251 @@ fn start_detached(name: &str, arg: &Path) -> io::Result<()> { Ok(()) } -/// The user-facing name of the trash. -pub const TRASH_NAME: &str = "trash"; - -/// Opens the item's directory in the desktop file manager. -pub fn reveal(path: &Path) -> io::Result<()> { - check_abs(path)?; - let md = std::fs::symlink_metadata(path)?; - let target = if md.is_dir() { - path - } else { - path.parent().unwrap_or(path) - }; - let opener = if cfg!(target_os = "macos") { - "open" - } else { - "xdg-open" - }; - start_detached(opener, target) -} - -/// Whether there is no graphical session (no X or Wayland display), so there -/// is no file manager to open and no desktop trash anyone will empty. -pub fn headless() -> bool { - if cfg!(target_os = "macos") { - return false; - } - std::env::var_os("DISPLAY").is_none_or(|v| v.is_empty()) - && std::env::var_os("WAYLAND_DISPLAY").is_none_or(|v| v.is_empty()) -} - -fn split_parent(path: &Path) -> io::Result<(&Path, &OsStr)> { - match (path.parent(), path.file_name()) { - (Some(d), Some(b)) => Ok((d, b)), - _ => Err(other("refusing to operate on a filesystem root")), - } -} - -fn check_expect(dirfd: RawFd, base: &CStr, want: Expect) -> io::Result { - let mut st: libc::stat = unsafe { std::mem::zeroed() }; - cvt(unsafe { libc::fstatat(dirfd, base.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) })?; - if st.st_uid != want.uid - || mode_from_unix(st.st_mode as u32) & mode::TYPE != want.mode & mode::TYPE +/// Reads exactly buf.len() bytes at offset off, without relying on (or, on +/// Unix, moving) the file cursor. +pub fn read_exact_at(f: &File, buf: &mut [u8], off: u64) -> io::Result<()> { + #[cfg(unix)] { - return Err(other(CHANGED)); + use std::os::unix::fs::FileExt; + f.read_exact_at(buf, off) } - Ok(st) -} - -/// Permanently removes path (recursively for a directory) if it still -/// matches want. The check and the removal both go through descriptors -/// opened from the parent directory without following symlinks, so a -/// symlink swapped in since the scan cannot redirect the removal outside it. -pub fn delete(path: &Path, want: Expect) -> io::Result<()> { - check_abs(path)?; - let (dir, base) = split_parent(path)?; - let dfd = open_dir(dir.as_os_str().as_bytes())?; - let cbase = cstr(base.as_bytes())?; - let st = check_expect(dfd.as_raw_fd(), &cbase, want)?; - remove_at( - dfd.as_raw_fd(), - &cbase, - st.st_mode & libc::S_IFMT == libc::S_IFDIR, - ) -} - -fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> { - if !is_dir { - return cvt(unsafe { libc::unlinkat(dirfd, name.as_ptr(), 0) }).map(|_| ()); - } - let fd = cvt(unsafe { - libc::openat( - dirfd, - name.as_ptr(), - libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, - ) - })?; - let sub = unsafe { OwnedFd::from_raw_fd(fd) }; - // Collect names first: removing while iterating a DIR stream is unspecified. - let mut names = Vec::new(); - let dup = cvt(unsafe { libc::dup(sub.as_raw_fd()) })?; - let dir = unsafe { libc::fdopendir(dup) }; - if dir.is_null() { - unsafe { libc::close(dup) }; - return Err(io::Error::last_os_error()); - } - let ds = DirStream { dir }; - loop { - let ent = unsafe { libc::readdir(ds.dir) }; - if ent.is_null() { - break; - } - let n = unsafe { CStr::from_ptr((*ent).d_name.as_ptr()) }; - if n.to_bytes() != b"." && n.to_bytes() != b".." { - let t = unsafe { (*ent).d_type }; - let is_dir = match t { - libc::DT_UNKNOWN => fstat_at(sub.as_raw_fd(), n, libc::AT_SYMLINK_NOFOLLOW) - .is_ok_and(|m| mode::is_dir(m.mode)), - t => t == libc::DT_DIR, - }; - names.push((n.to_owned(), is_dir)); - } - } - drop(ds); - for (n, d) in names { - remove_at(sub.as_raw_fd(), &n, d)?; - } - cvt(unsafe { libc::unlinkat(dirfd, name.as_ptr(), libc::AT_REMOVEDIR) }).map(|_| ()) -} - -/// Moves path to dst if it still matches want. The parent directory is -/// opened once and both the check and the rename are relative to that -/// descriptor, so a parent swapped for a symlink since the scan (or between -/// check and rename) cannot redirect the move to another user's file. -fn rename_checked(path: &Path, dst: &Path, want: Expect) -> io::Result<()> { - let (dir, base) = split_parent(path)?; - let dfd = open_dir(dir.as_os_str().as_bytes())?; - let cbase = cstr(base.as_bytes())?; - check_expect(dfd.as_raw_fd(), &cbase, want)?; - let cdst = cstr(dst.as_os_str().as_bytes())?; - cvt(unsafe { - libc::renameat( - dfd.as_raw_fd(), - cbase.as_ptr(), - libc::AT_FDCWD, - cdst.as_ptr(), - ) - }) - .map(|_| ()) -} - -/// Creates dir (mode 0700) if missing; an existing entry must be a real -/// directory (not a symlink) owned by us. With strict (trash on a shared -/// filesystem) it must also be inaccessible to others, as the freedesktop.org -/// spec requires. -fn ensure_private_dir(dir: &Path, strict: bool) -> io::Result<()> { - use std::os::unix::fs::DirBuilderExt; - if let Err(e) = std::fs::DirBuilder::new().mode(0o700).create(dir) { - if e.kind() != io::ErrorKind::AlreadyExists { - return Err(e); - } - } - let md = std::fs::symlink_metadata(dir)?; - if !md.is_dir() { - return Err(other("not a directory (symlink?)")); - } - if md.uid() != unsafe { libc::getuid() } { - return Err(other("owned by another user")); - } - if strict && md.mode() & 0o077 != 0 { - return Err(other("accessible to other users")); - } - Ok(()) -} - -/// The top directory of the filesystem containing path. -fn mount_top(path: &Path, dev: u64) -> PathBuf { - let mut cur = path.parent().unwrap_or(path).to_path_buf(); - loop { - let Some(parent) = cur.parent() else { - return cur; - }; - match std::fs::metadata(parent) { - Ok(m) if m.dev() == dev => cur = parent.to_path_buf(), - _ => return cur, + #[cfg(windows)] + { + use std::os::windows::fs::FileExt; + let (mut buf, mut off) = (buf, off); + while !buf.is_empty() { + match f.seek_read(buf, off) { + Ok(0) => return Err(io::ErrorKind::UnexpectedEof.into()), + Ok(n) => { + buf = &mut buf[n..]; + off += n as u64; + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } } + Ok(()) } } -fn percent_encode_path(p: &[u8]) -> String { - let mut s = String::with_capacity(p.len()); - for &b in p { - if b.is_ascii_alphanumeric() || b"-._~/!$&'()*+,;=:@".contains(&b) { - s.push(b as char); - } else { - s.push_str(&format!("%{b:02X}")); - } - } - s +// ---- Paths ---------------------------------------------------------------- + +/// The path separator: '/' on Unix, '\\' on Windows. +pub const SEP: u8 = if cfg!(windows) { b'\\' } else { b'/' }; + +/// Whether b separates path components ('/' everywhere, also '\\' on +/// Windows). +pub fn is_sep(b: u8) -> bool { + b == b'/' || cfg!(windows) && b == b'\\' } -fn local_timestamp() -> String { - let mut t: libc::time_t = unsafe { libc::time(std::ptr::null_mut()) }; - let mut tm: libc::tm = unsafe { std::mem::zeroed() }; - unsafe { libc::localtime_r(&mut t, &mut tm) }; - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", - tm.tm_year + 1900, - tm.tm_mon + 1, - tm.tm_mday, - tm.tm_hour, - tm.tm_min, - tm.tm_sec - ) +/// The raw bytes of an OS string. On Windows this is UTF-8 (WTF-8 for +/// unpaired surrogates). +pub fn os_bytes(s: &OsStr) -> &[u8] { + s.as_encoded_bytes() } -/// Moves path to the freedesktop.org trash: the home trash when on the same -/// filesystem, otherwise $topdir/.Trash-$uid on the item's filesystem. It -/// never copies across filesystems and never deletes. -pub fn trash(path: &Path, want: Expect) -> io::Result<()> { - check_abs(path)?; - let dev = std::fs::symlink_metadata(path)?.dev(); - let data_home = match std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) { - Some(d) => PathBuf::from(d), - None => { - let home = std::env::var_os("HOME") - .filter(|v| !v.is_empty()) - .ok_or_else(|| other("$HOME is not defined"))?; - PathBuf::from(home).join(".local/share") - } - }; - let home = data_home.join("Trash"); - let (trash, info_path): (PathBuf, Vec) = match std::fs::metadata(&data_home) { - Ok(m) if m.dev() == dev => (home.clone(), path.as_os_str().as_bytes().to_vec()), - _ => { - let top = mount_top(path, dev); - let rel = path.strip_prefix(&top).map_err(|e| other(e.to_string()))?; - ( - top.join(format!(".Trash-{}", unsafe { libc::getuid() })), - rel.as_os_str().as_bytes().to_vec(), - ) - } - }; - let shared = trash != home; - if !shared { - use std::os::unix::fs::DirBuilderExt; - std::fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(&trash)?; - } else if let Err(e) = ensure_private_dir(&trash, true) { - // On a shared filesystem another user could plant a symlink or a - // world-writable directory here; refuse rather than follow it. - return Err(other(format!( - "trash directory {} is unsafe: {e}", - trash.display() - ))); +/// The path named by raw bytes. On Windows the bytes are decoded as UTF-8, +/// replacing invalid sequences. +pub fn path_from_bytes(b: &[u8]) -> PathBuf { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + PathBuf::from(OsStr::from_bytes(b)) } - let (files, info) = (trash.join("files"), trash.join("info")); - for d in [&files, &info] { - ensure_private_dir(d, shared) - .map_err(|e| other(format!("trash directory {} is unsafe: {e}", d.display())))?; - } - let base = path.file_name().ok_or_else(|| other("no file name"))?; - for i in 1..10000 { - let mut name = base.to_os_string(); - if i > 1 { - name.push(format!(".{i}")); - } - let dst = files.join(&name); - if std::fs::symlink_metadata(&dst).is_ok() { - continue; // never overwrite an earlier trashed item (even without .trashinfo) - } - let mut info_name = name.clone(); - info_name.push(".trashinfo"); - let ip = info.join(info_name); - let mut f = match std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&ip) - { - Ok(f) => f, - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(e), - }; - let w = write!( - f, - "[Trash Info]\nPath={}\nDeletionDate={}\n", - percent_encode_path(&info_path), - local_timestamp() - ) - .and_then(|_| f.sync_all()); - drop(f); - if let Err(e) = w.and_then(|_| rename_checked(path, &dst, want)) { - let _ = std::fs::remove_file(&ip); - return Err(e); - } - return Ok(()); - } - Err(other("trash: too many items with the same name")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mode_string_matches_go() { - assert_eq!(mode::string(mode::DIR | 0o755), "drwxr-xr-x"); - assert_eq!(mode::string(0o644), "-rw-r--r--"); - assert_eq!(mode::string(mode::SYMLINK | 0o777), "Lrwxrwxrwx"); - } - - #[test] - fn delete_refuses_changed_type() { - let d = std::env::temp_dir().join(format!("mapsize-del-{}", std::process::id())); - std::fs::create_dir_all(d.join("sub/inner")).unwrap(); - std::fs::write(d.join("sub/inner/f"), b"x").unwrap(); - let uid = unsafe { libc::getuid() }; - assert!(delete(&d.join("sub"), Expect { uid, mode: 0 }).is_err()); - delete( - &d.join("sub"), - Expect { - uid, - mode: mode::DIR, - }, - ) - .unwrap(); - assert!(!d.join("sub").exists()); - std::fs::remove_dir_all(&d).unwrap(); + #[cfg(windows)] + { + PathBuf::from(String::from_utf8_lossy(b).into_owned()) } } -/// Shell-style glob match, a port of Go's filepath.Match: `*` and `?` do not -/// match '/', `[^...]` negates, `\\` escapes. A malformed pattern matches -/// nothing. +/// Joins a directory and an entry name without cleaning. +pub fn join_path(dir: &[u8], name: &[u8]) -> Vec { + let mut p = Vec::with_capacity(dir.len() + name.len() + 1); + p.extend_from_slice(dir); + if !dir.last().is_some_and(|&b| is_sep(b)) { + p.push(SEP); + } + p.extend_from_slice(name); + p +} + +fn base_name(path: &[u8]) -> &[u8] { + let mut end = path.len(); + while end > 1 && is_sep(path[end - 1]) { + end -= 1; + } + let p = &path[..end]; + match p.iter().rposition(|&b| is_sep(b)) { + Some(i) if p.len() > 1 => &p[i + 1..], + _ => p, + } +} + +/// Lexically cleans a Unix path: collapses repeated separators, removes "." +/// and resolves ".." against the preceding component. +#[cfg(unix)] +pub fn clean_path(p: &[u8]) -> Vec { + if p.is_empty() { + return b".".to_vec(); + } + let rooted = p[0] == b'/'; + let mut parts: Vec<&[u8]> = Vec::new(); + for c in p.split(|&b| b == b'/') { + match c { + b"" | b"." => {} + b".." => { + if parts.last().is_some_and(|l| *l != b"..") { + parts.pop(); + } else if !rooted { + parts.push(c); + } + } + _ => parts.push(c), + } + } + let mut out = Vec::with_capacity(p.len()); + if rooted { + out.push(b'/'); + } + out.extend_from_slice(&parts.join(&b'/')); + if out.is_empty() { + out.push(b'.'); + } + out +} + +/// Makes a path absolute (relative to the working directory) and cleans it. +pub fn abs_path(p: &[u8]) -> io::Result> { + #[cfg(unix)] + { + if p.first() == Some(&b'/') { + return Ok(clean_path(p)); + } + let cwd = std::env::current_dir()?; + Ok(clean_path(&join_path(os_bytes(cwd.as_os_str()), p))) + } + #[cfg(windows)] + { + // GetFullPathNameW resolves "." and "..", the drive-relative forms + // and '/'; collecting the components drops a trailing separator. + let a: PathBuf = std::path::absolute(path_from_bytes(p))? + .components() + .collect(); + Ok(os_bytes(a.as_os_str()).to_vec()) + } +} + +// ---- Owner names ---------------------------------------------------------- + +type NameCache = (HashMap, HashMap); +static NAMES: Mutex> = Mutex::new(None); + +fn lookup_name(group: bool, id: u32) -> String { + let mut g = NAMES.lock().unwrap(); + let (users, groups) = g.get_or_insert_with(Default::default); + let cache = if group { groups } else { users }; + if let Some(n) = cache.get(&id) { + return n.clone(); + } + let name = lookup_os_name(group, id).unwrap_or_else(|| id.to_string()); + cache.insert(id, name.clone()); + name +} + +/// Resolves a uid to a name (cached; falls back to the number). +pub fn user_name(uid: u32) -> String { + lookup_name(false, uid) +} + +/// Resolves a gid to a name (cached; falls back to the number). +pub fn group_name(gid: u32) -> String { + lookup_name(true, gid) +} + +// ---- Local time ----------------------------------------------------------- + +/// A broken-down local time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LocalTime { + pub year: i64, + pub month: i64, // 1-12 + pub day: i64, // 1-31 + pub hour: i64, + pub min: i64, + pub sec: i64, + pub off: i32, // seconds east of UTC +} + +/// Converts unix seconds to local time in the system time zone. +pub fn local_time(secs: i64) -> LocalTime { + let off = local_offset(secs); + let s = secs + off as i64; + let (year, month, day) = civil_from_days(s.div_euclid(86400)); + let rem = s.rem_euclid(86400); + LocalTime { + year, + month, + day, + hour: rem / 3600, + min: rem / 60 % 60, + sec: rem % 60, + off, + } +} + +/// Unix seconds of a local wall-clock time in the system time zone. A time +/// skipped or repeated by a DST change resolves to one of its neighbours. +pub fn local_to_unix(y: i64, mo: i64, d: i64, h: i64, mi: i64) -> i64 { + let wall = days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60; + let off = local_offset(wall) as i64; + let t = wall - off; + let off2 = local_offset(t) as i64; + if off2 == off { + t + } else { + wall - off2 + } +} + +/// Year, month (1-12) and day of days since 1970-01-01 in the proleptic +/// Gregorian calendar (Howard Hinnant's civil_from_days). +pub fn civil_from_days(days: i64) -> (i64, i64, i64) { + let z = days + 719468; + let era = z.div_euclid(146097); + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (yoe + era * 400 + (m <= 2) as i64, m, d) +} + +/// Days since 1970-01-01 of a proleptic Gregorian date. +pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let doy = (153 * ((m + 9) % 12) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719468 +} + +// ---- Glob ----------------------------------------------------------------- + +// On Windows '\\' is the separator and cannot escape. +const ESCAPE: bool = cfg!(unix); + +/// Shell-style glob match: `*` matches any run of non-separator bytes, `?` +/// one non-separator character, `[...]` a character class (`[^...]` +/// negates, `a-z` ranges), and on Unix `\\` escapes the next character. The +/// whole name must match. A malformed pattern matches nothing. pub fn glob_match(pattern: &[u8], name: &[u8]) -> bool { - // Fast path for the common "*.ext" shape: * cannot cross '/'. + // Fast path for the common "*.ext" shape: * cannot cross a separator. if let Some(suffix) = pattern.strip_prefix(b"*") { if !suffix.iter().any(|b| b"*?[\\".contains(b)) { - return name.ends_with(suffix) && !name[..name.len() - suffix.len()].contains(&b'/'); + return name.ends_with(suffix) && !name[..name.len() - suffix.len()].contains(&SEP); } } glob(pattern, name).unwrap_or(false) @@ -770,8 +431,8 @@ fn glob(mut pattern: &[u8], mut name: &[u8]) -> Result { let (star, chunk, rest) = scan_chunk(pattern); pattern = rest; if star && chunk.is_empty() { - // Trailing * matches the rest of the string unless it has a /. - return Ok(!name.contains(&b'/')); + // Trailing * matches the rest of the string unless it has a separator. + return Ok(!name.contains(&SEP)); } let r = match_chunk(chunk, name); if let Ok(Some(t)) = r { @@ -783,11 +444,11 @@ fn glob(mut pattern: &[u8], mut name: &[u8]) -> Result { } r?; if star { - // Look for a match skipping i+1 bytes; cannot skip /. + // Look for a match skipping i+1 bytes; cannot skip a separator. let mut i = 0; - while i < name.len() && name[i] != b'/' { + while i < name.len() && name[i] != SEP { if let Some(t) = match_chunk(chunk, &name[i + 1..])? { - if !(pattern.is_empty() && !t.is_empty()) { + if !pattern.is_empty() || t.is_empty() { name = t; continue 'pattern; } @@ -816,7 +477,7 @@ fn scan_chunk(mut pattern: &[u8]) -> (bool, &[u8], &[u8]) { let mut i = 0; while i < pattern.len() { match pattern[i] { - b'\\' if i + 1 < pattern.len() => i += 1, + b'\\' if ESCAPE && i + 1 < pattern.len() => i += 1, b'[' => in_range = true, b']' => in_range = false, b'*' if !in_range => break, @@ -871,7 +532,7 @@ fn match_chunk<'a>(mut chunk: &[u8], mut s: &'a [u8]) -> Result } b'?' => { if !failed { - if s[0] == b'/' { + if s[0] == SEP { failed = true; } s = &s[decode_rune(s).1..]; @@ -879,7 +540,7 @@ fn match_chunk<'a>(mut chunk: &[u8], mut s: &'a [u8]) -> Result chunk = &chunk[1..]; } c => { - let c = if c == b'\\' { + let c = if ESCAPE && c == b'\\' { chunk = &chunk[1..]; *chunk.first().ok_or(BadPattern)? } else { @@ -902,7 +563,7 @@ fn get_esc(mut chunk: &[u8]) -> Result<(u32, &[u8]), BadPattern> { if chunk.is_empty() || chunk[0] == b'-' || chunk[0] == b']' { return Err(BadPattern); } - if chunk[0] == b'\\' { + if ESCAPE && chunk[0] == b'\\' { chunk = &chunk[1..]; if chunk.is_empty() { return Err(BadPattern); @@ -915,50 +576,49 @@ fn get_esc(mut chunk: &[u8]) -> Result<(u32, &[u8]), BadPattern> { Ok((r, &chunk[n..])) } -/// Lexically cleans a path like Go's filepath.Clean. -pub fn clean_path(p: &[u8]) -> Vec { - if p.is_empty() { - return b".".to_vec(); - } - let rooted = p[0] == b'/'; - let mut parts: Vec<&[u8]> = Vec::new(); - for c in p.split(|&b| b == b'/') { - match c { - b"" | b"." => {} - b".." => { - if parts.last().is_some_and(|l| *l != b"..") { - parts.pop(); - } else if !rooted { - parts.push(c); - } - } - _ => parts.push(c), - } - } - let mut out = Vec::with_capacity(p.len()); - if rooted { - out.push(b'/'); - } - out.extend_from_slice(&parts.join(&b'/')); - if out.is_empty() { - out.push(b'.'); - } - out -} - -/// Makes a path absolute (relative to the working directory) and cleans it. -pub fn abs_path(p: &[u8]) -> io::Result> { - if p.first() == Some(&b'/') { - return Ok(clean_path(p)); - } - let cwd = std::env::current_dir()?; - Ok(clean_path(&join_path(cwd.as_os_str().as_bytes(), p))) -} - #[cfg(test)] -mod path_tests { +mod tests { use super::*; + #[test] + fn mode_string() { + assert_eq!(mode::string(mode::DIR | 0o755), "drwxr-xr-x"); + assert_eq!(mode::string(0o644), "-rw-r--r--"); + assert_eq!(mode::string(mode::SYMLINK | 0o777), "Lrwxrwxrwx"); + } + + #[test] + fn civil_round_trip() { + for days in [-719468, -1, 0, 59, 11016, 20000, 2932896] { + let (y, m, d) = civil_from_days(days); + assert_eq!(days_from_civil(y, m, d), days); + } + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(days_from_civil(2000, 3, 1), 11017); + } + + #[test] + fn local_time_round_trip() { + let t = 1_780_000_000; // 2026-05-28 + let lt = local_time(t); + assert_eq!( + local_to_unix(lt.year, lt.month, lt.day, lt.hour, lt.min) + lt.sec, + t + ); + } + + #[test] + fn join_and_base() { + let s = SEP as char; + assert_eq!(join_path(b"dir", b"x"), format!("dir{s}x").into_bytes()); + assert_eq!( + join_path(format!("dir{s}").as_bytes(), b"x"), + format!("dir{s}x").into_bytes() + ); + assert_eq!(base_name(format!("a{s}b{s}").as_bytes()), b"b"); + } + + #[cfg(unix)] #[test] fn clean_and_glob() { assert_eq!(clean_path(b"/a/b/../c/./"), b"/a/c"); @@ -978,4 +638,14 @@ mod path_tests { assert!(glob_match(b"*", b"abc") && !glob_match(b"*", b"a/c")); assert!(!glob_match(b"*.dat", b"x.dat/y.da") && !glob_match(b"*.dat", b"d/x.dat")); } + + #[cfg(windows)] + #[test] + fn windows_glob_and_abs() { + assert!(glob_match(br"C:\tmp\*\x", br"C:\tmp\a\x")); + assert!(!glob_match(b"*.log", br"d\x.log")); + assert!(glob_match(b"*.log", b"d/x.log")); + let a = abs_path(br"C:\a\b\..\c\").unwrap(); + assert_eq!(a, br"C:\a\c"); + } } diff --git a/src/platform/unix.rs b/src/platform/unix.rs new file mode 100644 index 0000000..56bdf05 --- /dev/null +++ b/src/platform/unix.rs @@ -0,0 +1,797 @@ +//! Unix: directory reads and stat calls relative to the directory fd, owner +//! names, local time, and the trash/delete/reveal operations. + +use super::{base_name, check_abs, mode, other, Expect, Meta}; +use std::ffi::{CStr, CString, OsStr}; +use std::fs::File; +use std::io; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; + +/// Converts a Unix st_mode to the portable layout in `mode`. +#[allow(clippy::unnecessary_cast)] // mode_t is u16 on some systems +pub fn mode_from_unix(m: u32) -> u32 { + let mut out = m & 0o777; + let fmt = m & libc::S_IFMT as u32; + out |= if fmt == libc::S_IFDIR as u32 { + mode::DIR + } else if fmt == libc::S_IFLNK as u32 { + mode::SYMLINK + } else if fmt == libc::S_IFIFO as u32 { + mode::NAMED_PIPE + } else if fmt == libc::S_IFSOCK as u32 { + mode::SOCKET + } else if fmt == libc::S_IFCHR as u32 { + mode::DEVICE | mode::CHAR_DEVICE + } else if fmt == libc::S_IFBLK as u32 { + mode::DEVICE + } else { + 0 + }; + if m & libc::S_ISUID as u32 != 0 { + out |= mode::SETUID; + } + if m & libc::S_ISGID as u32 != 0 { + out |= mode::SETGID; + } + if m & libc::S_ISVTX as u32 != 0 { + out |= mode::STICKY; + } + out +} + +fn cstr(b: &[u8]) -> io::Result { + CString::new(b).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL)) +} + +fn cvt(r: libc::c_int) -> io::Result { + if r < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(r) + } +} + +fn open_dir(path: &[u8]) -> io::Result { + let c = cstr(path)?; + let fd = cvt(unsafe { + libc::open( + c.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + })?; + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +#[cfg(target_os = "linux")] +static NO_STATX: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// statx (one call, includes birth time), falling back to fstatat on kernels +/// before 4.11. Called through syscall(2) because not every libc (musl) +/// provides a wrapper. +#[cfg(target_os = "linux")] +fn stat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { + use std::sync::atomic::Ordering; + if !NO_STATX.load(Ordering::Relaxed) { + let mut st: libc::statx = unsafe { std::mem::zeroed() }; + let mask = libc::STATX_BASIC_STATS | libc::STATX_BTIME; + let r = unsafe { + libc::syscall( + libc::SYS_statx, + dirfd, + name.as_ptr(), + flags | libc::AT_STATX_DONT_SYNC, + mask, + &mut st as *mut libc::statx, + ) + }; + if r == 0 { + let mut m = Meta { + mode: mode_from_unix(st.stx_mode as u32), + size: st.stx_size as i64, + alloc: st.stx_blocks as i64 * 512, + alloc_known: true, + mtime: st.stx_mtime.tv_sec * 1_000_000_000 + st.stx_mtime.tv_nsec as i64, + uid: st.stx_uid, + gid: st.stx_gid, + nlink: st.stx_nlink, + dev: libc::makedev(st.stx_dev_major, st.stx_dev_minor), + ino: st.stx_ino, + has_ino: true, + ..Default::default() + }; + if st.stx_mask & libc::STATX_BTIME != 0 { + m.btime = st.stx_btime.tv_sec * 1_000_000_000 + st.stx_btime.tv_nsec as i64; + } + return Ok(m); + } + let err = io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::ENOSYS) { + return Err(err); + } + NO_STATX.store(true, Ordering::Relaxed); + } + fstat_at(dirfd, name, flags) +} + +#[cfg(not(target_os = "linux"))] +fn stat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { + fstat_at(dirfd, name, flags) +} + +fn fstat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + cvt(unsafe { libc::fstatat(dirfd, name.as_ptr(), &mut st, flags) })?; + Ok(from_stat(&st)) +} + +#[allow(clippy::unnecessary_cast)] +fn from_stat(st: &libc::stat) -> Meta { + #[cfg(target_os = "netbsd")] + let mtime_nsec = st.st_mtimensec; + #[cfg(not(target_os = "netbsd"))] + let mtime_nsec = st.st_mtime_nsec; + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + let btime = st.st_birthtime as i64 * 1_000_000_000 + st.st_birthtime_nsec as i64; + #[cfg(target_os = "netbsd")] + let btime = st.st_birthtime as i64 * 1_000_000_000 + st.st_birthtimensec as i64; + #[cfg(not(any(target_os = "macos", target_os = "freebsd", target_os = "netbsd")))] + let btime = 0; + Meta { + mode: mode_from_unix(st.st_mode as u32), + size: st.st_size as i64, + alloc: st.st_blocks as i64 * 512, + alloc_known: true, + mtime: st.st_mtime as i64 * 1_000_000_000 + mtime_nsec as i64, + btime, + uid: st.st_uid, + gid: st.st_gid, + nlink: st.st_nlink as u32, + dev: st.st_dev as u64, + ino: st.st_ino as u64, + has_ino: true, + ..Default::default() + } +} + +/// An open directory stream (readdir over the fd we also stat relative to). +struct DirStream { + dir: *mut libc::DIR, +} + +impl Drop for DirStream { + fn drop(&mut self) { + unsafe { libc::closedir(self.dir) }; + } +} + +fn errno_location() -> *mut libc::c_int { + #[cfg(any(target_os = "linux", target_os = "android"))] + return unsafe { libc::__errno_location() }; + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))] + return unsafe { libc::__error() }; + #[cfg(any(target_os = "netbsd", target_os = "openbsd"))] + return unsafe { libc::__errno() }; +} + +/// Streams the entries of a directory in chunks of up to `chunk`, stat-ing +/// each entry relative to the directory fd (no repeated path resolution). +/// Returning an error from f stops the read and returns it. +pub fn read_dir>( + path: &[u8], + chunk: usize, + mut f: impl FnMut(Vec) -> Result<(), E>, +) -> Result<(), E> { + let fd = open_dir(path)?; + let raw = fd.as_raw_fd(); + let dir = unsafe { libc::fdopendir(raw) }; + if dir.is_null() { + return Err(io::Error::last_os_error().into()); + } + std::mem::forget(fd); // owned by the DIR stream now + let ds = DirStream { dir }; + let mut out = Vec::with_capacity(chunk); + loop { + // readdir signals an error only through errno. + unsafe { *errno_location() = 0 }; + let ent = unsafe { libc::readdir(ds.dir) }; + if ent.is_null() { + let e = io::Error::last_os_error(); + if e.raw_os_error().unwrap_or(0) != 0 { + return Err(e.into()); + } + break; + } + let name = unsafe { CStr::from_ptr((*ent).d_name.as_ptr()) }; + let nb = name.to_bytes(); + if nb == b"." || nb == b".." { + continue; + } + let mut m = stat_at(raw, name, libc::AT_SYMLINK_NOFOLLOW).unwrap_or_else(|e| Meta { + err: Some(e), + ..Default::default() + }); + m.name = nb.to_vec(); + out.push(m); + if out.len() >= chunk { + f(std::mem::replace(&mut out, Vec::with_capacity(chunk)))?; + } + } + if !out.is_empty() { + f(out)?; + } + Ok(()) +} + +pub(super) fn stat_path(path: &[u8], follow: bool) -> io::Result { + let c = cstr(path)?; + let flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW }; + let mut m = stat_at(libc::AT_FDCWD, &c, flags)?; + m.name = base_name(path).to_vec(); + Ok(m) +} + +pub(super) fn lookup_os_name(group: bool, id: u32) -> Option { + let mut buf = vec![0u8; 16384]; + unsafe { + if group { + let mut gr: libc::group = std::mem::zeroed(); + let mut res = std::ptr::null_mut(); + libc::getgrgid_r(id, &mut gr, buf.as_mut_ptr().cast(), buf.len(), &mut res); + (!res.is_null()).then(|| CStr::from_ptr(gr.gr_name).to_string_lossy().into_owned()) + } else { + let mut pw: libc::passwd = std::mem::zeroed(); + let mut res = std::ptr::null_mut(); + libc::getpwuid_r(id, &mut pw, buf.as_mut_ptr().cast(), buf.len(), &mut res); + (!res.is_null()).then(|| CStr::from_ptr(pw.pw_name).to_string_lossy().into_owned()) + } + } +} + +/// The system time zone's offset from UTC (seconds east) at unix time secs. +#[allow(deprecated)] // libc::time_t on 32-bit musl +#[allow(clippy::unnecessary_cast)] +pub(super) fn local_offset(secs: i64) -> i32 { + let t = secs as libc::time_t; + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + if unsafe { libc::localtime_r(&t, &mut tm) }.is_null() { + return 0; + } + tm.tm_gmtoff as i32 +} + +/// The host name, or "" if unknown. +pub fn hostname() -> String { + let mut b = [0u8; 256]; + if unsafe { libc::gethostname(b.as_mut_ptr() as *mut libc::c_char, b.len()) } != 0 { + return String::new(); + } + let n = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(&b[..n]).into_owned() +} + +/// Linux pseudo-filesystems whose contents are not disk usage. +#[cfg(target_os = "linux")] +const VIRTUAL_FS: [i64; 19] = [ + 0x9fa0, // proc + 0x62656572, // sysfs + 0x1cd1, // devpts + 0x27e0eb, // cgroup + 0x63677270, // cgroup2 + 0x64626720, // debugfs + 0x74726163, // tracefs + 0x73636673, // securityfs + 0x6165676c, // pstore + 0xcafe4a11, // bpf + 0x62656570, // configfs + 0x65735543, // fusectl + 0x19800202, // mqueue + 0xde5e81e4, // efivarfs + 0x42494e4d, // binfmt_misc + 0xf97cff8c, // selinuxfs + 0x958458f6, // hugetlbfs + 0x6e736673, // nsfs + 0x50495045, // pipefs +]; + +/// Whether path is the root of a pseudo filesystem (proc, sysfs, cgroup, …) +/// that should not be scanned as disk usage. Only Linux has these. +#[cfg(target_os = "linux")] +#[allow(clippy::unnecessary_cast)] +pub fn is_virtual_fs(path: &[u8]) -> bool { + let Ok(c) = cstr(path) else { return false }; + let mut st: libc::statfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::statfs(c.as_ptr(), &mut st) } != 0 { + return false; + } + // f_type is 32-bit on some architectures: compare as unsigned 32-bit. + VIRTUAL_FS.contains(&(st.f_type as u32 as i64)) +} + +/// Whether path is the root of a pseudo filesystem. Only Linux has these. +#[cfg(not(target_os = "linux"))] +pub fn is_virtual_fs(_: &[u8]) -> bool { + false +} + +/// Opens a file whose content is about to be read. It does not follow a +/// final symlink, and O_NONBLOCK keeps a FIFO swapped in since the scan from +/// blocking open(2) forever; callers still check the file type. +pub fn open_content(path: &Path) -> io::Result { + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) +} + +/// Creates a new file readable and writable only by the owner. +pub fn create_private(path: &Path) -> io::Result { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) +} + +const CHANGED: &str = "item changed since the scan (owner or type differs); rescan first"; + +fn split_parent(path: &Path) -> io::Result<(&Path, &OsStr)> { + match (path.parent(), path.file_name()) { + (Some(d), Some(b)) => Ok((d, b)), + _ => Err(other("refusing to operate on a filesystem root")), + } +} + +fn check_expect(dirfd: RawFd, base: &CStr, want: Expect) -> io::Result { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + cvt(unsafe { libc::fstatat(dirfd, base.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) })?; + #[allow(clippy::unnecessary_cast)] + if st.st_uid != want.uid + || mode_from_unix(st.st_mode as u32) & mode::TYPE != want.mode & mode::TYPE + { + return Err(other(CHANGED)); + } + Ok(st) +} + +/// Permanently removes path (recursively for a directory) if it still +/// matches want. The check and the removal both go through descriptors +/// opened from the parent directory without following symlinks, so a +/// symlink swapped in since the scan cannot redirect the removal outside it. +pub fn delete(path: &Path, want: Expect) -> io::Result<()> { + check_abs(path)?; + let (dir, base) = split_parent(path)?; + let dfd = open_dir(dir.as_os_str().as_bytes())?; + let cbase = cstr(base.as_bytes())?; + let st = check_expect(dfd.as_raw_fd(), &cbase, want)?; + remove_at( + dfd.as_raw_fd(), + &cbase, + st.st_mode & libc::S_IFMT == libc::S_IFDIR, + ) +} + +fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> { + if !is_dir { + return cvt(unsafe { libc::unlinkat(dirfd, name.as_ptr(), 0) }).map(|_| ()); + } + let fd = cvt(unsafe { + libc::openat( + dirfd, + name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + })?; + let sub = unsafe { OwnedFd::from_raw_fd(fd) }; + // Collect names first: removing while iterating a DIR stream is unspecified. + let mut names = Vec::new(); + let dup = cvt(unsafe { libc::dup(sub.as_raw_fd()) })?; + let dir = unsafe { libc::fdopendir(dup) }; + if dir.is_null() { + unsafe { libc::close(dup) }; + return Err(io::Error::last_os_error()); + } + let ds = DirStream { dir }; + loop { + let ent = unsafe { libc::readdir(ds.dir) }; + if ent.is_null() { + break; + } + let n = unsafe { CStr::from_ptr((*ent).d_name.as_ptr()) }; + if n.to_bytes() != b"." && n.to_bytes() != b".." { + let t = unsafe { (*ent).d_type }; + let is_dir = match t { + libc::DT_UNKNOWN => fstat_at(sub.as_raw_fd(), n, libc::AT_SYMLINK_NOFOLLOW) + .is_ok_and(|m| mode::is_dir(m.mode)), + t => t == libc::DT_DIR, + }; + names.push((n.to_owned(), is_dir)); + } + } + drop(ds); + for (n, d) in names { + remove_at(sub.as_raw_fd(), &n, d)?; + } + cvt(unsafe { libc::unlinkat(dirfd, name.as_ptr(), libc::AT_REMOVEDIR) }).map(|_| ()) +} + +/// Moves path to dst if it still matches want. The parent directory is +/// opened once and both the check and the rename are relative to that +/// descriptor, so a parent swapped for a symlink since the scan (or between +/// check and rename) cannot redirect the move to another user's file. +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn rename_checked(path: &Path, dst: &Path, want: Expect) -> io::Result<()> { + let (dir, base) = split_parent(path)?; + let dfd = open_dir(dir.as_os_str().as_bytes())?; + let cbase = cstr(base.as_bytes())?; + check_expect(dfd.as_raw_fd(), &cbase, want)?; + let cdst = cstr(dst.as_os_str().as_bytes())?; + cvt(unsafe { + libc::renameat( + dfd.as_raw_fd(), + cbase.as_ptr(), + libc::AT_FDCWD, + cdst.as_ptr(), + ) + }) + .map(|_| ()) +} + +/// Creates dir (mode 0700) if missing; an existing entry must be a real +/// directory (not a symlink) owned by us. With strict (trash on a shared +/// filesystem) it must also be inaccessible to others, as the freedesktop.org +/// spec requires; home trash directories made by desktop environments are +/// sometimes 0755, which is harmless there. +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn ensure_private_dir(dir: &Path, strict: bool) -> io::Result<()> { + use std::os::unix::fs::{DirBuilderExt, MetadataExt}; + if let Err(e) = std::fs::DirBuilder::new().mode(0o700).create(dir) { + if e.kind() != io::ErrorKind::AlreadyExists { + return Err(e); + } + } + let md = std::fs::symlink_metadata(dir)?; + if !md.is_dir() { + return Err(other("not a directory (symlink?)")); + } + if md.uid() != unsafe { libc::getuid() } { + return Err(other("owned by another user")); + } + if strict && md.mode() & 0o077 != 0 { + return Err(other("accessible to other users")); + } + Ok(()) +} + +/// Whether there is no graphical session (no X or Wayland display), so there +/// is no file manager to open and no desktop trash anyone will empty. +#[cfg(not(target_os = "macos"))] +pub fn headless() -> bool { + std::env::var_os("DISPLAY").is_none_or(|v| v.is_empty()) + && std::env::var_os("WAYLAND_DISPLAY").is_none_or(|v| v.is_empty()) +} + +#[cfg(target_os = "linux")] +pub use linux::*; + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + use std::io::Write; + use std::os::unix::fs::MetadataExt; + use std::path::PathBuf; + + /// The user-facing name of the trash. + pub const TRASH_NAME: &str = "trash"; + + /// Opens the item's directory in the desktop file manager. + pub fn reveal(path: &Path) -> io::Result<()> { + check_abs(path)?; + let md = std::fs::symlink_metadata(path)?; + let target = if md.is_dir() { + path + } else { + path.parent().unwrap_or(path) + }; + crate::platform::start_detached("xdg-open", &[target.as_os_str()]) + } + + /// The top directory of the filesystem containing path. + fn mount_top(path: &Path, dev: u64) -> PathBuf { + let mut cur = path.parent().unwrap_or(path).to_path_buf(); + loop { + let Some(parent) = cur.parent() else { + return cur; + }; + match std::fs::metadata(parent) { + Ok(m) if m.dev() == dev => cur = parent.to_path_buf(), + _ => return cur, + } + } + } + + fn percent_encode_path(p: &[u8]) -> String { + let mut s = String::with_capacity(p.len()); + for &b in p { + if b.is_ascii_alphanumeric() || b"-._~/!$&'()*+,;=:@".contains(&b) { + s.push(b as char); + } else { + s.push_str(&format!("%{b:02X}")); + } + } + s + } + + fn local_timestamp() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let t = crate::platform::local_time(now.as_secs() as i64); + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", + t.year, t.month, t.day, t.hour, t.min, t.sec + ) + } + + /// Moves path to the freedesktop.org trash: the home trash when on the + /// same filesystem, otherwise $topdir/.Trash-$uid on the item's + /// filesystem. It never copies across filesystems and never deletes. + pub fn trash(path: &Path, want: Expect) -> io::Result<()> { + check_abs(path)?; + let dev = std::fs::symlink_metadata(path)?.dev(); + let data_home = match std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) { + Some(d) => PathBuf::from(d), + None => { + let home = std::env::var_os("HOME") + .filter(|v| !v.is_empty()) + .ok_or_else(|| other("$HOME is not defined"))?; + PathBuf::from(home).join(".local/share") + } + }; + let home = data_home.join("Trash"); + let (trash, info_path): (PathBuf, Vec) = match std::fs::metadata(&data_home) { + Ok(m) if m.dev() == dev => (home.clone(), path.as_os_str().as_bytes().to_vec()), + _ => { + let top = mount_top(path, dev); + let rel = path.strip_prefix(&top).map_err(|e| other(e.to_string()))?; + ( + top.join(format!(".Trash-{}", unsafe { libc::getuid() })), + rel.as_os_str().as_bytes().to_vec(), + ) + } + }; + let shared = trash != home; + if !shared { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&trash)?; + } else if let Err(e) = ensure_private_dir(&trash, true) { + // On a shared filesystem another user could plant a symlink or a + // world-writable directory here; refuse rather than follow it. + return Err(other(format!( + "trash directory {} is unsafe: {e}", + trash.display() + ))); + } + let (files, info) = (trash.join("files"), trash.join("info")); + for d in [&files, &info] { + ensure_private_dir(d, shared) + .map_err(|e| other(format!("trash directory {} is unsafe: {e}", d.display())))?; + } + let base = path.file_name().ok_or_else(|| other("no file name"))?; + for i in 1..10000 { + let mut name = base.to_os_string(); + if i > 1 { + name.push(format!(".{i}")); + } + let dst = files.join(&name); + if std::fs::symlink_metadata(&dst).is_ok() { + continue; // never overwrite an earlier trashed item (even without .trashinfo) + } + let mut info_name = name.clone(); + info_name.push(".trashinfo"); + let ip = info.join(info_name); + let mut f = match create_private(&ip) { + Ok(f) => f, + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + }; + let w = write!( + f, + "[Trash Info]\nPath={}\nDeletionDate={}\n", + percent_encode_path(&info_path), + local_timestamp() + ) + .and_then(|_| f.sync_all()); + drop(f); + if let Err(e) = w.and_then(|_| rename_checked(path, &dst, want)) { + let _ = std::fs::remove_file(&ip); + return Err(e); + } + return Ok(()); + } + Err(other("trash: too many items with the same name")) + } +} + +#[cfg(target_os = "macos")] +pub use macos::*; + +#[cfg(target_os = "macos")] +mod macos { + use super::*; + use std::path::PathBuf; + + /// The user-facing name of the trash. + pub const TRASH_NAME: &str = "Trash"; + + /// Selects the item in Finder. + pub fn reveal(path: &Path) -> io::Result<()> { + check_abs(path)?; + crate::platform::start_detached("open", &[OsStr::new("-R"), path.as_os_str()]) + } + + /// Always false: macOS has Finder and the Trash without an X display. + pub fn headless() -> bool { + false + } + + /// "/Volumes/" for a path on a mounted volume, else None. + fn volume_of(path: &Path) -> Option { + let rest = path.strip_prefix("/Volumes/").ok()?; + rest.components() + .next() + .map(|c| Path::new("/Volumes").join(c)) + } + + /// Moves path into ~/.Trash (same volume) or the volume's .Trashes/ + /// directory. It never copies and never deletes. + pub fn trash(path: &Path, want: Expect) -> io::Result<()> { + check_abs(path)?; + let home = std::env::var_os("HOME") + .filter(|v| !v.is_empty()) + .ok_or_else(|| other("$HOME is not defined"))?; + let mut dirs = vec![PathBuf::from(home).join(".Trash")]; + if let Some(vol) = volume_of(path) { + dirs.push( + vol.join(".Trashes") + .join(unsafe { libc::getuid() }.to_string()), + ); + } + let base = path.file_name().ok_or_else(|| other("no file name"))?; + for (vi, dir) in dirs.iter().enumerate() { + if dir + .parent() + .is_some_and(|p| std::fs::create_dir_all(p).is_err()) + { + continue; + } + // A volume trash is on a shared filesystem: another user could + // plant a symlink or an open directory there, so insist on a + // private one. + if ensure_private_dir(dir, vi > 0).is_err() { + continue; + } + for i in 1..10000 { + let mut name = base.to_os_string(); + if i > 1 { + name.push(format!(" {i}")); + } + let dst = dir.join(&name); + if std::fs::symlink_metadata(&dst).is_ok() { + continue; + } + match rename_checked(path, &dst, want) { + Err(e) if e.raw_os_error() == Some(libc::EXDEV) => break, // try the next trash location + r => return r, + } + } + } + Err(other("trash: item is on a volume without a usable trash")) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub use bsd::*; + +/// FreeBSD, NetBSD and other Unix systems: no desktop trash or file +/// manager integration. Permanent delete is still available. +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +mod bsd { + use super::*; + + /// The user-facing name of the trash. + pub const TRASH_NAME: &str = "trash"; + + /// Unsupported on this platform. + pub fn reveal(_: &Path) -> io::Result<()> { + Err(other("not supported on this platform")) + } + + /// Unsupported on this platform. + pub fn trash(_: &Path, _: Expect) -> io::Result<()> { + Err(other("trash not supported on this platform")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn uid() -> u32 { + unsafe { libc::getuid() } + } + + fn tmp(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("mapsize-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn delete_refuses_changed_type() { + let d = tmp("del"); + std::fs::create_dir_all(d.join("sub/inner")).unwrap(); + std::fs::write(d.join("sub/inner/f"), b"x").unwrap(); + // A symlink inside must be removed, not followed. + std::fs::write(d.join("keep"), b"k").unwrap(); + std::os::unix::fs::symlink(d.join("keep"), d.join("sub/link")).unwrap(); + assert!(delete( + &d.join("sub"), + Expect { + uid: uid(), + mode: 0 + } + ) + .is_err()); + delete( + &d.join("sub"), + Expect { + uid: uid(), + mode: mode::DIR, + }, + ) + .unwrap(); + assert!(!d.join("sub").exists()); + assert!(d.join("keep").exists()); + assert!(delete(Path::new("relative/path"), Expect { uid: 0, mode: 0 }).is_err()); + std::fs::remove_dir_all(&d).unwrap(); + } + + #[test] + #[allow(clippy::unnecessary_cast)] + fn mode_bits() { + assert_eq!( + mode_from_unix(libc::S_IFDIR as u32 | 0o755), + mode::DIR | 0o755 + ); + assert_eq!( + mode_from_unix(libc::S_IFCHR as u32 | 0o600), + mode::DEVICE | mode::CHAR_DEVICE | 0o600 + ); + assert_eq!( + mode_from_unix(libc::S_IFREG as u32 | libc::S_ISUID as u32 | 0o4755), + mode::SETUID | 0o755 + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn ensure_private_dir_rejects_symlink_and_open_dir() { + use std::os::unix::fs::PermissionsExt; + let d = tmp("priv"); + std::fs::create_dir(d.join("attacker")).unwrap(); + std::os::unix::fs::symlink(d.join("attacker"), d.join(".Trash-1000")).unwrap(); + assert!(ensure_private_dir(&d.join(".Trash-1000"), false).is_err()); + std::fs::create_dir(d.join("open")).unwrap(); + std::fs::set_permissions(d.join("open"), std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(ensure_private_dir(&d.join("open"), true).is_err()); + ensure_private_dir(&d.join("fresh"), true).unwrap(); + std::fs::remove_dir_all(&d).unwrap(); + } +} diff --git a/src/platform/windows.rs b/src/platform/windows.rs new file mode 100644 index 0000000..712aa01 --- /dev/null +++ b/src/platform/windows.rs @@ -0,0 +1,200 @@ +//! Windows: std directory listing and metadata, Recycle Bin and Explorer. +//! +//! Allocated size is reported as unknown (it would cost an extra handle open +//! per file), and there is no inode identity, owner or link count: symlinks +//! are never followed and hard links are not deduplicated. + +use super::{base_name, check_abs, mode, os_bytes, other, path_from_bytes, Expect, Meta}; +use std::fs::{File, Metadata}; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn nanos(t: io::Result) -> i64 { + match t { + Ok(t) => match t.duration_since(UNIX_EPOCH) { + Ok(d) => d.as_nanos() as i64, + Err(e) => -(e.duration().as_nanos() as i64), + }, + Err(_) => 0, + } +} + +/// Permissions follow the read-only attribute (0444 or 0666, plus 0111 for +/// directories). Symlinks and junctions both report as symlinks. +fn mode_of(md: &Metadata) -> u32 { + let ft = md.file_type(); + let mut m = if md.permissions().readonly() { + 0o444 + } else { + 0o666 + }; + if ft.is_symlink() { + m |= mode::SYMLINK; + } else if ft.is_dir() { + m |= mode::DIR | 0o111; + } + m +} + +fn from_metadata(md: &Metadata) -> Meta { + let size = md.len() as i64; + Meta { + mode: mode_of(md), + size, + alloc: size, + mtime: nanos(md.modified()), + btime: nanos(md.created()), + nlink: 1, + ..Default::default() + } +} + +/// Streams the entries of a directory in chunks of up to `chunk`. Entry +/// metadata comes from the directory listing itself and does not follow +/// symlinks. Returning an error from f stops the read and returns it. +pub fn read_dir>( + path: &[u8], + chunk: usize, + mut f: impl FnMut(Vec) -> Result<(), E>, +) -> Result<(), E> { + let mut out = Vec::with_capacity(chunk); + for de in std::fs::read_dir(path_from_bytes(path))? { + let de = de?; + let mut m = de + .metadata() + .map(|md| from_metadata(&md)) + .unwrap_or_else(|e| Meta { + err: Some(e), + ..Default::default() + }); + m.name = os_bytes(&de.file_name()).to_vec(); + out.push(m); + if out.len() >= chunk { + f(std::mem::replace(&mut out, Vec::with_capacity(chunk)))?; + } + } + if !out.is_empty() { + f(out)?; + } + Ok(()) +} + +pub(super) fn stat_path(path: &[u8], follow: bool) -> io::Result { + let p = path_from_bytes(path); + let md = if follow { + std::fs::metadata(&p) + } else { + std::fs::symlink_metadata(&p) + }?; + let mut m = from_metadata(&md); + m.name = base_name(path).to_vec(); + Ok(m) +} + +/// Windows has no numeric uid/gid; names fall back to the number. +pub(super) fn lookup_os_name(_: bool, _: u32) -> Option { + None +} + +/// The system time zone's offset from UTC (seconds east) at unix time secs. +pub(super) fn local_offset(secs: i64) -> i32 { + use windows_sys::Win32::Foundation::SYSTEMTIME; + use windows_sys::Win32::System::Time::SystemTimeToTzSpecificLocalTime; + let (y, mo, d) = super::civil_from_days(secs.div_euclid(86400)); + if !(1601..=30827).contains(&y) { + return 0; + } + let rem = secs.rem_euclid(86400); + let utc = SYSTEMTIME { + wYear: y as u16, + wMonth: mo as u16, + wDayOfWeek: 0, + wDay: d as u16, + wHour: (rem / 3600) as u16, + wMinute: (rem / 60 % 60) as u16, + wSecond: (rem % 60) as u16, + wMilliseconds: 0, + }; + let mut local: SYSTEMTIME = unsafe { std::mem::zeroed() }; + if unsafe { SystemTimeToTzSpecificLocalTime(std::ptr::null(), &utc, &mut local) } == 0 { + return 0; + } + let l = super::days_from_civil(local.wYear as i64, local.wMonth as i64, local.wDay as i64) + * 86400 + + local.wHour as i64 * 3600 + + local.wMinute as i64 * 60 + + local.wSecond as i64; + (l - secs) as i32 +} + +/// The host name, or "" if unknown. +pub fn hostname() -> String { + std::env::var("COMPUTERNAME").unwrap_or_default() +} + +/// Whether path is the root of a pseudo filesystem. Windows has none. +pub fn is_virtual_fs(_: &[u8]) -> bool { + false +} + +/// Opens a file whose content is about to be read; callers check the type. +pub fn open_content(path: &Path) -> io::Result { + File::open(path) +} + +/// Creates a new file (it inherits the directory's access control list). +pub fn create_private(path: &Path) -> io::Result { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) +} + +/// The user-facing name of the trash. +pub const TRASH_NAME: &str = "Recycle Bin"; + +/// Always false: Windows always has Explorer and the Recycle Bin. +pub fn headless() -> bool { + false +} + +/// Selects the item in Explorer. +pub fn reveal(path: &Path) -> io::Result<()> { + check_abs(path)?; + super::start_detached("explorer.exe", &["/select,".as_ref(), path.as_os_str()]) +} + +/// Moves path to the Recycle Bin (SHFileOperationW with FOF_ALLOWUNDO). It +/// never deletes permanently. want is not checked: the scan records no owner +/// or inode identity on Windows. +pub fn trash(path: &Path, _: Expect) -> io::Result<()> { + use windows_sys::Win32::UI::Shell::{ + SHFileOperationW, FOF_ALLOWUNDO, FOF_NO_UI, FO_DELETE, SHFILEOPSTRUCTW, + }; + check_abs(path)?; + // A list of paths, each NUL-terminated, ending with an extra NUL. + let mut from: Vec = path.as_os_str().encode_wide().collect(); + if from.contains(&0) { + return Err(other("path contains NUL")); + } + from.extend([0, 0]); + let mut op: SHFILEOPSTRUCTW = unsafe { std::mem::zeroed() }; + op.wFunc = FO_DELETE; + op.pFrom = from.as_ptr(); + op.fFlags = (FOF_ALLOWUNDO | FOF_NO_UI) as u16; + let r = unsafe { SHFileOperationW(&mut op) }; + if r != 0 { + return Err(other(format!("SHFileOperation failed: code {r}"))); + } + if op.fAnyOperationsAborted != 0 { + return Err(other("operation aborted")); + } + Ok(()) +} + +/// Permanent delete is not offered on Windows; items go to the Recycle Bin. +pub fn delete(_: &Path, _: Expect) -> io::Result<()> { + Err(other("permanent delete not supported on Windows")) +} diff --git a/src/scan.rs b/src/scan.rs index ef6852f..812c565 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -484,7 +484,8 @@ impl Controller { self.excludes.iter().any(|(is_path, p)| { if *is_path { path == p.as_slice() - || path.starts_with(p) && path.get(p.len()) == Some(&b'/') + || path.starts_with(p) + && path.get(p.len()).is_some_and(|&b| platform::is_sep(b)) || platform::glob_match(p, path) } else { platform::glob_match(p, name) @@ -662,7 +663,6 @@ fn err_msg(e: &io::Error) -> String { #[cfg(test)] pub(crate) mod tests { use super::*; - use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; pub struct TempDir(pub PathBuf); @@ -716,7 +716,7 @@ pub(crate) mod tests { } pub fn run_scan(root: &Path, o: Options) -> Tree { - let s = start(root.as_os_str().as_bytes(), o, Cancel::new()).unwrap(); + let s = start(root.as_os_str().as_encoded_bytes(), o, Cancel::new()).unwrap(); s.done() .recv_timeout(std::time::Duration::from_secs(60)) .expect_err("scan did not finish (deadlock?)"); @@ -797,7 +797,7 @@ pub(crate) mod tests { mk_tree(&root.0, 8, 3, 2); let c = Cancel::new(); let s = start( - root.0.as_os_str().as_bytes(), + root.0.as_os_str().as_encoded_bytes(), Options { workers: 2, job_buffer: 1, @@ -816,6 +816,7 @@ pub(crate) mod tests { assert!(t.stats.cancelled && t.stats.incomplete()); } + #[cfg(unix)] #[test] fn symlink_loops() { let root = TempDir::new("loop"); @@ -845,6 +846,7 @@ pub(crate) mod tests { } } + #[cfg(unix)] #[test] fn hardlinks_counted_once() { let root = TempDir::new("hl"); @@ -863,6 +865,7 @@ pub(crate) mod tests { assert!(t.node(0).tot_size < 200000); } + #[cfg(unix)] #[test] fn permission_errors_surface() { if unsafe { libc::geteuid() } == 0 { @@ -873,7 +876,7 @@ pub(crate) mod tests { let locked = root.0.join("locked"); std::fs::create_dir(&locked).unwrap(); std::fs::write(locked.join("f"), b"x").unwrap(); - std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0)).unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); let t = run_scan( &root.0, Options { @@ -907,8 +910,10 @@ pub(crate) mod tests { assert_eq!((t.stats.excluded, t.node(0).files), (2, 1)); } + #[cfg(unix)] #[test] fn hostile_names_preserved() { + use std::os::unix::ffi::OsStrExt; let root = TempDir::new("names"); let names: [&[u8]; 6] = [ "日本語.txt".as_bytes(), diff --git a/src/snapshot/mod.rs b/src/snapshot/mod.rs index b935b57..cd5abf5 100644 --- a/src/snapshot/mod.rs +++ b/src/snapshot/mod.rs @@ -34,13 +34,13 @@ use crate::brand; use crate::inventory::{ Category, ErrKind, ErrorRecord, Kind, NodeId, Tree, MAX_ERROR_RECORDS, NUM_CATEGORIES, }; +use crate::platform::{civil_from_days, days_from_civil}; use crossbeam_channel::Receiver; use flate2::{bufread::GzDecoder, write::GzEncoder, Compression}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::fs::{self, File, OpenOptions}; +use std::fs::{self, File}; use std::io::{self, BufReader, BufWriter, Read, Write}; -use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -178,7 +178,7 @@ pub fn save(out: &mut dyn Write, t: &Tree) -> io::Result<()> { one_file_system: st.one_file_system, follow: st.follow.clone(), workers: st.workers as i64, - host: hostname(), + host: crate::platform::hostname(), generator: format!("{} {}", brand::NAME, brand::VERSION), }; let mb = serde_json::to_vec(&m)?; @@ -245,12 +245,7 @@ fn create_temp(dir: &Path) -> io::Result<(PathBuf, File)> { ".mapsize-{}.tmp", seed.wrapping_add(i.wrapping_mul(7919)) )); - match OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&p) - { + match crate::platform::create_private(&p) { Ok(f) => return Ok((p, f)), Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, Err(e) => return Err(e), @@ -262,15 +257,6 @@ fn create_temp(dir: &Path) -> io::Result<(PathBuf, File)> { )) } -fn hostname() -> String { - let mut b = [0u8; 256]; - if unsafe { libc::gethostname(b.as_mut_ptr() as *mut libc::c_char, b.len()) } != 0 { - return String::new(); - } - let n = b.iter().position(|&c| c == 0).unwrap_or(b.len()); - String::from_utf8_lossy(&b[..n]).into_owned() -} - /// The decompressed body, fed in chunks by the decompressing thread. struct Body { rx: Receiver, String>>, @@ -674,7 +660,7 @@ impl GoTime { /// The same instant in the local time zone (as Go's time.Now()). fn local(self) -> GoTime { - let off = localtime(self.secs).map_or(0, |tm| tm.tm_gmtoff as i32); + let off = crate::platform::local_time(self.secs).off; GoTime { off, ..self } } @@ -785,26 +771,14 @@ impl GoTime { /// "2006-01-02 15:04" in local time. fn local_minutes(self) -> String { - let Some(tm) = localtime(self.secs) else { - return self.format(); - }; + let t = crate::platform::local_time(self.secs); format!( "{:04}-{:02}-{:02} {:02}:{:02}", - tm.tm_year as i64 + 1900, - tm.tm_mon + 1, - tm.tm_mday, - tm.tm_hour, - tm.tm_min + t.year, t.month, t.day, t.hour, t.min ) } } -fn localtime(secs: i64) -> Option { - let t = secs as libc::time_t; - let mut tm: libc::tm = unsafe { std::mem::zeroed() }; - (!unsafe { libc::localtime_r(&t, &mut tm) }.is_null()).then_some(tm) -} - impl Serialize for GoTime { fn serialize(&self, s: S) -> Result { s.serialize_str(&self.format()) @@ -822,28 +796,6 @@ impl<'de> Deserialize<'de> for GoTime { } } -/// Civil-from-days (Howard Hinnant). -fn civil_from_days(days: i64) -> (i64, i64, i64) { - let z = days + 719468; - let era = z.div_euclid(146097); - let doe = z - era * 146097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - (yoe + era * 400 + (m <= 2) as i64, m, d) -} - -fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * ((m + 9) % 12) + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146097 + doe - 719468 -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/textutil.rs b/src/textutil.rs index 0a2784f..82c822d 100644 --- a/src/textutil.rs +++ b/src/textutil.rs @@ -240,7 +240,7 @@ pub fn count(n: i64) -> String { out.push('-'); } for (i, c) in s.chars().enumerate() { - if i > 0 && (s.len() - i) % 3 == 0 { + if i > 0 && (s.len() - i).is_multiple_of(3) { out.push(','); } out.push(c); diff --git a/src/tui/keys.rs b/src/tui/keys.rs index 8adf699..ee1177e 100644 --- a/src/tui/keys.rs +++ b/src/tui/keys.rs @@ -10,8 +10,6 @@ use crate::inventory::{NodeId, SizeMode, Tree, NO_NODE}; use crate::platform; use crate::snapshot; use crate::textutil; -use std::ffi::OsStr; -use std::os::unix::ffi::OsStrExt; use std::path::Path; use std::rc::Rc; @@ -232,9 +230,9 @@ impl Model { } pub(crate) fn default_snapshot_name(root: &[u8]) -> String { - let p = Path::new(OsStr::from_bytes(root)); + let p = crate::platform::path_from_bytes(root); let base = match p.file_name() { - Some(b) => String::from_utf8_lossy(b.as_bytes()).into_owned(), + Some(b) => b.to_string_lossy().into_owned(), None => "root".into(), }; let wd = std::env::current_dir().unwrap_or_default(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index a4558f5..cb4826b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -55,22 +55,14 @@ pub(crate) fn san(b: &[u8]) -> String { textutil::sanitize_bytes(b) } -fn local_tm(unix_nanos: i64) -> libc::tm { - let secs = unix_nanos.div_euclid(1_000_000_000) as libc::time_t; - let mut tm: libc::tm = unsafe { std::mem::zeroed() }; - unsafe { libc::localtime_r(&secs, &mut tm) }; - tm +fn local_tm(unix_nanos: i64) -> crate::platform::LocalTime { + crate::platform::local_time(unix_nanos.div_euclid(1_000_000_000)) } /// Formats a timestamp as a local date ("2006-01-02"). pub(crate) fn fmt_date(unix_nanos: i64) -> String { let t = local_tm(unix_nanos); - format!( - "{:04}-{:02}-{:02}", - t.tm_year + 1900, - t.tm_mon + 1, - t.tm_mday - ) + format!("{:04}-{:02}-{:02}", t.year, t.month, t.day) } /// Formats a timestamp as local date and time ("2006-01-02 15:04:05"). @@ -78,12 +70,7 @@ pub(crate) fn fmt_time(unix_nanos: i64) -> String { let t = local_tm(unix_nanos); format!( "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", - t.tm_year + 1900, - t.tm_mon + 1, - t.tm_mday, - t.tm_hour, - t.tm_min, - t.tm_sec + t.year, t.month, t.day, t.hour, t.min, t.sec ) } @@ -95,10 +82,6 @@ pub(crate) fn fmt_stamp_now() -> String { let t = local_tm(now.as_nanos() as i64); format!( "{:04}{:02}{:02}-{:02}{:02}", - t.tm_year + 1900, - t.tm_mon + 1, - t.tm_mday, - t.tm_hour, - t.tm_min + t.year, t.month, t.day, t.hour, t.min ) } diff --git a/src/tui/model.rs b/src/tui/model.rs index b1f1873..fbba738 100644 --- a/src/tui/model.rs +++ b/src/tui/model.rs @@ -21,6 +21,7 @@ use crate::config::Settings; use crate::duplicate; use crate::filter::FilterResult; use crate::inventory::{NodeId, SizeMode, Tree}; +use crate::platform; use crate::scan::Scanner; use crate::snapshot; use crate::textutil; @@ -612,17 +613,20 @@ impl Model { let root = t.path_bytes(0); let rel: &[u8] = if pz == root { b"" - } else if let Some(r) = pz - .strip_prefix(root.as_slice()) - .filter(|r| root.ends_with(b"/") || r.starts_with(b"/")) - { + } else if let Some(r) = pz.strip_prefix(root.as_slice()).filter(|r| { + root.last().is_some_and(|&b| platform::is_sep(b)) + || r.first().is_some_and(|&b| platform::is_sep(b)) + }) { r } else { self.pending_zoom = None; return; }; let mut id = 0; - for part in rel.split(|&b| b == b'/').filter(|p| !p.is_empty()) { + for part in rel + .split(|&b| platform::is_sep(b)) + .filter(|p| !p.is_empty()) + { match t.children(id).find(|(_, n)| &*n.name == part) { Some((c, _)) => id = c, None => return, // not discovered yet diff --git a/src/tui/render.rs b/src/tui/render.rs index 73a7e13..1352ffb 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -272,9 +272,9 @@ impl Model { // Drop leading segments until the rest fits; keep at least the last. let total = |from: usize| { let mut w = 0; - for i in from..segs.len() { - w += tw(&segs[i].1); - if i > from { + for (k, (_, s)) in segs[from..].iter().enumerate() { + w += tw(s); + if k > 0 { w += tw(sep); } } diff --git a/src/tui/run.rs b/src/tui/run.rs index 2f683d6..b14e4e5 100644 --- a/src/tui/run.rs +++ b/src/tui/run.rs @@ -34,7 +34,7 @@ const RESTORE: &str = fn write_raw(s: &str) { let mut b = s.as_bytes(); while !b.is_empty() { - let n = unsafe { libc::write(1, b.as_ptr().cast(), b.len()) }; + let n = unsafe { libc::write(1, b.as_ptr().cast(), b.len().min(1 << 30) as _) }; if n <= 0 { return; } diff --git a/src/tui/tests.rs b/src/tui/tests.rs index e6ecb1a..c36ccc5 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -11,7 +11,6 @@ use crate::inventory::{category_for, ext, Kind, NodeId, Tree, FLAG_SCANNED}; use crate::scan; use crate::textutil; use crate::treemap::Block; -use std::os::unix::ffi::OsStrExt; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; @@ -630,7 +629,7 @@ fn live_scan_rendering() { .unwrap(); } } - let path = root.0.as_os_str().as_bytes().to_vec(); + let path = root.0.as_os_str().as_encoded_bytes().to_vec(); let start: StartFn = Arc::new(move |c: Cancel| { let s = scan::start( &path,