Build and behave correctly on every release target

Split platform into a portable core (mod.rs) plus unix.rs and windows.rs.

- Linux: statx through syscall(2) so static musl links (fstatat fallback
  on ENOSYS); virtual-fs detection compares f_type as u32.
- macOS: Finder trash (~/.Trash or /Volumes/X/.Trashes/<uid>, "name N"
  collisions, EXDEV falls through to the next location) and `open -R`
  reveal; never headless.
- FreeBSD/NetBSD: fstatat with birth time; trash and reveal unsupported,
  permanent delete still available on headless systems.
- Windows: std read_dir and metadata, allocated size unknown, no inode
  identity, Recycle Bin via SHFileOperationW (FOF_ALLOWUNDO, all
  architectures), Explorer /select reveal, no permanent delete, '\\'
  separator with Windows glob semantics, abs paths via GetFullPathNameW,
  config in %AppData%.
- Local time via a portable LocalTime helper (localtime_r tm_gmtoff on
  Unix, SystemTimeToTzSpecificLocalTime on Windows) replacing libc tm and
  mktime in the TUI, snapshot and filter code.
- errno, stat field names and mode_t width handled per OS; Unix-only
  tests gated; clippy clean on all targets.

Adds windows-sys =0.61.2 (Windows only; Win32_Foundation,
Win32_System_Time, Win32_UI_Shell), already in the lockfile via crossterm.
This commit is contained in:
Andy Dixon
2026-10-08 20:06:22 +01:00
parent a16c00671b
commit fcac50bae6
20 changed files with 1412 additions and 812 deletions
+6 -2
View File
@@ -44,14 +44,18 @@ impl Default for Settings {
}
}
/// The config file location.
/// The config file location: `<dir>/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<PathBuf> {
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")))?
+5 -5
View File
@@ -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<RwLock<Tree>> {
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"] {
+2 -2
View File
@@ -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;
+2 -19
View File
@@ -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<i64> {
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)
}
+4 -5
View File
@@ -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.
+11 -9
View File
@@ -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,
}
}
+342 -672
View File
File diff suppressed because it is too large Load Diff
+797
View File
@@ -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> {
CString::new(b).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))
}
fn cvt(r: libc::c_int) -> io::Result<libc::c_int> {
if r < 0 {
Err(io::Error::last_os_error())
} else {
Ok(r)
}
}
fn open_dir(path: &[u8]) -> io::Result<OwnedFd> {
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<Meta> {
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<Meta> {
fstat_at(dirfd, name, flags)
}
fn fstat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result<Meta> {
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<E: From<io::Error>>(
path: &[u8],
chunk: usize,
mut f: impl FnMut(Vec<Meta>) -> 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<Meta> {
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<String> {
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<File> {
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<File> {
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<libc::stat> {
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<u8>) = 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/<name>" for a path on a mounted volume, else None.
fn volume_of(path: &Path) -> Option<PathBuf> {
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/<uid>
/// 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();
}
}
+200
View File
@@ -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<SystemTime>) -> 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<E: From<io::Error>>(
path: &[u8],
chunk: usize,
mut f: impl FnMut(Vec<Meta>) -> 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<Meta> {
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<String> {
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> {
File::open(path)
}
/// Creates a new file (it inherits the directory's access control list).
pub fn create_private(path: &Path) -> io::Result<File> {
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<u16> = 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"))
}
+10 -5
View File
@@ -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(),
+7 -55
View File
@@ -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<Result<Vec<u8>, 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<libc::tm> {
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<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
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::*;
+1 -1
View File
@@ -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);
+2 -4
View File
@@ -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();
+5 -22
View File
@@ -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
)
}
+9 -5
View File
@@ -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
+3 -3
View File
@@ -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);
}
}
+1 -1
View File
@@ -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;
}
+1 -2
View File
@@ -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,