Files
mapsize/src/platform.rs
T

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"));
}
}