886 lines
30 KiB
Rust
886 lines
30 KiB
Rust
//! OS-specific filesystem metadata and file operations (Unix; Linux first).
|
|
//! Nothing else in the program calls libc for filesystem work.
|
|
|
|
use std::collections::HashMap;
|
|
use std::ffi::{CStr, CString, 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::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.
|
|
pub mod mode {
|
|
pub const DIR: u32 = 1 << 31;
|
|
pub const APPEND: u32 = 1 << 30;
|
|
pub const EXCLUSIVE: u32 = 1 << 29;
|
|
pub const TEMPORARY: u32 = 1 << 28;
|
|
pub const SYMLINK: u32 = 1 << 27;
|
|
pub const DEVICE: u32 = 1 << 26;
|
|
pub const NAMED_PIPE: u32 = 1 << 25;
|
|
pub const SOCKET: u32 = 1 << 24;
|
|
pub const SETUID: u32 = 1 << 23;
|
|
pub const SETGID: u32 = 1 << 22;
|
|
pub const CHAR_DEVICE: u32 = 1 << 21;
|
|
pub const STICKY: u32 = 1 << 20;
|
|
pub const IRREGULAR: u32 = 1 << 19;
|
|
pub const TYPE: u32 = DIR | SYMLINK | NAMED_PIPE | SOCKET | DEVICE | CHAR_DEVICE | IRREGULAR;
|
|
pub const PERM: u32 = 0o777;
|
|
|
|
pub fn is_dir(m: u32) -> bool {
|
|
m & DIR != 0
|
|
}
|
|
pub fn is_regular(m: u32) -> bool {
|
|
m & TYPE == 0
|
|
}
|
|
pub fn is_symlink(m: u32) -> bool {
|
|
m & SYMLINK != 0
|
|
}
|
|
|
|
/// Formats like Go's FileMode.String, e.g. "drwxr-xr-x".
|
|
pub fn string(m: u32) -> String {
|
|
const STR: &[u8] = b"dalTLDpSugct?";
|
|
let mut out = String::with_capacity(12);
|
|
for (i, &c) in STR.iter().enumerate() {
|
|
if m & (1 << (31 - i)) != 0 {
|
|
out.push(c as char);
|
|
}
|
|
}
|
|
if out.is_empty() {
|
|
out.push('-');
|
|
}
|
|
for (i, c) in "rwxrwxrwx".chars().enumerate() {
|
|
out.push(if m & (1 << (8 - i)) != 0 { c } else { '-' });
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// 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<u8>,
|
|
pub mode: u32, // Go FileMode layout
|
|
pub size: i64,
|
|
pub alloc: i64,
|
|
pub alloc_known: bool,
|
|
pub mtime: i64, // unix nanoseconds
|
|
pub btime: i64, // birth time, 0 if unknown
|
|
pub uid: u32,
|
|
pub gid: u32,
|
|
pub nlink: u32,
|
|
pub dev: u64,
|
|
pub ino: u64,
|
|
pub has_ino: bool,
|
|
pub err: Option<io::Error>, // stat failed; only name is valid
|
|
}
|
|
|
|
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: AtomicBool = AtomicBool::new(false);
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn stat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result<Meta> {
|
|
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<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 {
|
|
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<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 {
|
|
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<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)
|
|
}
|
|
|
|
/// Metadata for path without following a final symlink.
|
|
pub fn lstat(path: &[u8]) -> io::Result<Meta> {
|
|
stat_path(path, false)
|
|
}
|
|
|
|
/// Metadata for path, following symlinks.
|
|
pub fn stat(path: &[u8]) -> io::Result<Meta> {
|
|
stat_path(path, true)
|
|
}
|
|
|
|
/// Joins a directory and an entry name without cleaning.
|
|
pub fn join_path(dir: &[u8], name: &[u8]) -> Vec<u8> {
|
|
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<Option<(HashMap<u32, String>, HashMap<u32, String>)>> = 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)]
|
|
pub struct Expect {
|
|
pub uid: u32,
|
|
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<String>) -> io::Error {
|
|
io::Error::other(msg.into())
|
|
}
|
|
|
|
/// Guards helper invocations: an absolute path can never be parsed as an
|
|
/// option by the helper program.
|
|
fn check_abs(path: &Path) -> io::Result<()> {
|
|
if !path.is_absolute() {
|
|
return Err(other("refusing relative path"));
|
|
}
|
|
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<File> {
|
|
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<()> {
|
|
use std::process::{Command, Stdio};
|
|
let mut child = Command::new(name).arg(arg).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn()?;
|
|
std::thread::spawn(move || child.wait());
|
|
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<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) })?;
|
|
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.
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
|
|
/// 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 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();
|
|
}
|
|
}
|
|
|
|
/// Shell-style glob match, a port of Go's filepath.Match: `*` and `?` do not
|
|
/// match '/', `[^...]` negates, `\\` escapes. A malformed pattern matches
|
|
/// nothing.
|
|
pub fn glob_match(pattern: &[u8], name: &[u8]) -> bool {
|
|
// Fast path for the common "*.ext" shape: * cannot cross '/'.
|
|
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'/');
|
|
}
|
|
}
|
|
glob(pattern, name).unwrap_or(false)
|
|
}
|
|
|
|
struct BadPattern;
|
|
|
|
fn decode_rune(s: &[u8]) -> (u32, usize) {
|
|
match s.utf8_chunks().next() {
|
|
Some(c) if !c.valid().is_empty() => {
|
|
let ch = c.valid().chars().next().unwrap();
|
|
(ch as u32, ch.len_utf8())
|
|
}
|
|
_ => (0xFFFD, 1),
|
|
}
|
|
}
|
|
|
|
fn glob(mut pattern: &[u8], mut name: &[u8]) -> Result<bool, BadPattern> {
|
|
'pattern: while !pattern.is_empty() {
|
|
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'/'));
|
|
}
|
|
let r = match_chunk(chunk, name);
|
|
if let Ok(Some(t)) = r {
|
|
// The last chunk must exhaust the name.
|
|
if t.is_empty() || !pattern.is_empty() {
|
|
name = t;
|
|
continue;
|
|
}
|
|
}
|
|
r?;
|
|
if star {
|
|
// Look for a match skipping i+1 bytes; cannot skip /.
|
|
let mut i = 0;
|
|
while i < name.len() && name[i] != b'/' {
|
|
if let Some(t) = match_chunk(chunk, &name[i + 1..])? {
|
|
if !(pattern.is_empty() && !t.is_empty()) {
|
|
name = t;
|
|
continue 'pattern;
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
}
|
|
// Before failing, check the rest of the pattern is well-formed.
|
|
while !pattern.is_empty() {
|
|
let (_, chunk, rest) = scan_chunk(pattern);
|
|
pattern = rest;
|
|
match_chunk(chunk, b"")?;
|
|
}
|
|
return Ok(false);
|
|
}
|
|
Ok(name.is_empty())
|
|
}
|
|
|
|
fn scan_chunk(mut pattern: &[u8]) -> (bool, &[u8], &[u8]) {
|
|
let mut star = false;
|
|
while pattern.first() == Some(&b'*') {
|
|
pattern = &pattern[1..];
|
|
star = true;
|
|
}
|
|
let mut in_range = false;
|
|
let mut i = 0;
|
|
while i < pattern.len() {
|
|
match pattern[i] {
|
|
b'\\' if i + 1 < pattern.len() => i += 1,
|
|
b'[' => in_range = true,
|
|
b']' => in_range = false,
|
|
b'*' if !in_range => break,
|
|
_ => {}
|
|
}
|
|
i += 1;
|
|
}
|
|
(star, &pattern[..i], &pattern[i..])
|
|
}
|
|
|
|
/// Matches one chunk against the start of s, returning the rest of s.
|
|
fn match_chunk<'a>(mut chunk: &[u8], mut s: &'a [u8]) -> Result<Option<&'a [u8]>, BadPattern> {
|
|
// After a failure, keep parsing chunk to validate it, but stop reading s.
|
|
let mut failed = false;
|
|
while !chunk.is_empty() {
|
|
if !failed && s.is_empty() {
|
|
failed = true;
|
|
}
|
|
match chunk[0] {
|
|
b'[' => {
|
|
let mut r = 0;
|
|
if !failed {
|
|
let (c, n) = decode_rune(s);
|
|
r = c;
|
|
s = &s[n..];
|
|
}
|
|
chunk = &chunk[1..];
|
|
let negated = chunk.first() == Some(&b'^');
|
|
if negated {
|
|
chunk = &chunk[1..];
|
|
}
|
|
let (mut matched, mut nrange) = (false, 0);
|
|
loop {
|
|
if chunk.first() == Some(&b']') && nrange > 0 {
|
|
chunk = &chunk[1..];
|
|
break;
|
|
}
|
|
let (lo, rest) = get_esc(chunk)?;
|
|
chunk = rest;
|
|
let mut hi = lo;
|
|
if chunk[0] == b'-' {
|
|
(hi, chunk) = get_esc(&chunk[1..])?;
|
|
}
|
|
if lo <= r && r <= hi {
|
|
matched = true;
|
|
}
|
|
nrange += 1;
|
|
}
|
|
if matched == negated {
|
|
failed = true;
|
|
}
|
|
}
|
|
b'?' => {
|
|
if !failed {
|
|
if s[0] == b'/' {
|
|
failed = true;
|
|
}
|
|
s = &s[decode_rune(s).1..];
|
|
}
|
|
chunk = &chunk[1..];
|
|
}
|
|
c => {
|
|
let c = if c == b'\\' {
|
|
chunk = &chunk[1..];
|
|
*chunk.first().ok_or(BadPattern)?
|
|
} else {
|
|
c
|
|
};
|
|
if !failed {
|
|
if c != s[0] {
|
|
failed = true;
|
|
}
|
|
s = &s[1..];
|
|
}
|
|
chunk = &chunk[1..];
|
|
}
|
|
}
|
|
}
|
|
Ok((!failed).then_some(s))
|
|
}
|
|
|
|
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'\\' {
|
|
chunk = &chunk[1..];
|
|
if chunk.is_empty() {
|
|
return Err(BadPattern);
|
|
}
|
|
}
|
|
let (r, n) = decode_rune(chunk);
|
|
if (r == 0xFFFD && n == 1) || chunk.len() == n {
|
|
return Err(BadPattern);
|
|
}
|
|
Ok((r, &chunk[n..]))
|
|
}
|
|
|
|
/// Lexically cleans a path like Go's filepath.Clean.
|
|
pub fn clean_path(p: &[u8]) -> Vec<u8> {
|
|
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<Vec<u8>> {
|
|
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 {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn clean_and_glob() {
|
|
assert_eq!(clean_path(b"/a/b/../c/./"), b"/a/c");
|
|
assert_eq!(clean_path(b"/.."), b"/");
|
|
assert_eq!(clean_path(b"a/../../b"), b"../b");
|
|
assert!(glob_match(b"*.log", b"x.log"));
|
|
assert!(!glob_match(b"*.log", b"d/x.log"));
|
|
assert!(glob_match(b"/tmp/*/x", b"/tmp/a/x"));
|
|
assert!(!glob_match(b"[", b"["));
|
|
assert!(glob_match(b"a[^b-d]?", "ax\u{e9}".as_bytes()));
|
|
assert!(!glob_match(b"a[^b-d]c", b"abc"));
|
|
assert!(glob_match(b"\\*x", b"*x"));
|
|
assert!(glob_match(b"*.dat", b"f1.dat"));
|
|
assert!(!glob_match(b"*.dat", b"f1.dax"));
|
|
assert!(glob_match(b"a*b*c", b"axxbyyc"));
|
|
assert!(glob_match(b"*/x", b"a/x") && !glob_match(b"*/x", b"a/b/x"));
|
|
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"));
|
|
}
|
|
}
|