use std::collections::VecDeque;
use std::ffi::{CStr, CString, c_char, c_int, c_void};
use std::fs;
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd, RawFd};
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
use std::path::PathBuf;
use super::{Attrs, DirEntry, MountFs, NodeKind};
#[cfg(target_os = "linux")]
mod sys {
pub const O_RDONLY: c_int = 0o0;
pub const O_WRONLY: c_int = 0o1;
pub const O_CREAT: c_int = 0o100;
pub const O_TRUNC: c_int = 0o1000;
pub const O_NONBLOCK: c_int = 0o4000;
pub const O_DIRECTORY: c_int = 0o200_000;
pub const O_NOFOLLOW: c_int = 0o400_000;
pub const O_CLOEXEC: c_int = 0o2_000_000;
pub const O_PATH: c_int = 0o10_000_000;
pub const AT_REMOVEDIR: c_int = 0x200;
pub const ELOOP: i32 = 40;
use std::ffi::c_int;
}
#[cfg(target_os = "macos")]
mod sys {
pub const O_RDONLY: c_int = 0x0000;
pub const O_WRONLY: c_int = 0x0001;
pub const O_CREAT: c_int = 0x0200;
pub const O_TRUNC: c_int = 0x0400;
pub const O_NONBLOCK: c_int = 0x0004;
pub const O_DIRECTORY: c_int = 0x0010_0000;
pub const O_NOFOLLOW: c_int = 0x0000_0100;
pub const O_CLOEXEC: c_int = 0x0100_0000;
pub const O_SYMLINK: c_int = 0x0020_0000;
pub const AT_REMOVEDIR: c_int = 0x0080;
pub const ELOOP: i32 = 62;
use std::ffi::c_int;
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
compile_error!("passthrough: openat(2) flag/errno constants are only defined for linux and macos");
unsafe extern "C" {
fn openat(dirfd: c_int, path: *const c_char, flags: c_int, mode: u32) -> c_int;
fn unlinkat(dirfd: c_int, path: *const c_char, flags: c_int) -> c_int;
fn mkdirat(dirfd: c_int, path: *const c_char, mode: u32) -> c_int;
fn symlinkat(target: *const c_char, dirfd: c_int, linkpath: *const c_char) -> c_int;
fn renameat(
olddirfd: c_int,
oldpath: *const c_char,
newdirfd: c_int,
newpath: *const c_char,
) -> c_int;
fn readlinkat(dirfd: c_int, path: *const c_char, buf: *mut c_char, bufsiz: usize) -> isize;
fn fdopendir(fd: c_int) -> *mut c_void;
fn readdir(dirp: *mut c_void) -> *mut c_void;
fn closedir(dirp: *mut c_void) -> c_int;
}
#[cfg(target_os = "macos")]
#[repr(C)]
#[allow(clippy::struct_field_names)] struct RawDirentHeader {
d_ino: u64,
d_seekoff: u64,
d_reclen: u16,
d_namlen: u16,
d_type: u8,
d_name: [c_char; 1],
}
#[cfg(target_os = "linux")]
#[repr(C)]
#[allow(clippy::struct_field_names)] struct RawDirentHeader {
d_ino: u64,
d_off: i64,
d_reclen: u16,
d_type: u8,
d_name: [c_char; 1],
}
const MAX_SYMLINKS: u32 = 40;
const MAX_COMPONENTS: u32 = 4096;
fn cstr(component: &str) -> io::Result<CString> {
CString::new(component).map_err(|_| io::Error::from_raw_os_error(22)) }
const LINUX_ELOOP: i32 = 40;
fn eloop() -> io::Error {
io::Error::from_raw_os_error(LINUX_ELOOP)
}
#[cfg(target_os = "macos")]
fn normalize_errno(err: io::Error) -> io::Error {
let Some(raw) = err.raw_os_error() else {
return err;
};
let linux = match raw {
35 => 11, 11 => 35, 63 => 36, 77 => 37, 78 => 38, 66 => 39, 62 => LINUX_ELOOP, 91 => 42, 90 => 43, 92 => 84, 94 => 74, 84 => 75, 70 => 116, 69 => 122, 89 => 125, 105 => 130, 104 => 131, 45 | 102 => 95, other => other,
};
io::Error::from_raw_os_error(linux)
}
#[cfg(target_os = "linux")]
fn normalize_errno(err: io::Error) -> io::Error {
err
}
fn raw_openat(dirfd: RawFd, name: &CStr, flags: c_int, mode: u32) -> io::Result<OwnedFd> {
let fd = unsafe { openat(dirfd, name.as_ptr(), flags | sys::O_CLOEXEC, mode) };
if fd < 0 {
Err(io::Error::last_os_error())
} else {
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
}
fn raw_readlinkat(dirfd: RawFd, name: &CStr) -> io::Result<Option<String>> {
let mut buf = [0u8; 4096];
let n = unsafe {
readlinkat(
dirfd,
name.as_ptr(),
buf.as_mut_ptr().cast::<c_char>(),
buf.len(),
)
};
if n < 0 {
let err = io::Error::last_os_error();
if err.raw_os_error() == Some(22) {
return Ok(None); }
return Err(normalize_errno(err));
}
Ok(Some(
String::from_utf8_lossy(&buf[..n as usize]).into_owned(),
))
}
fn raw_unlinkat(dirfd: RawFd, name: &CStr, flags: c_int) -> io::Result<()> {
let r = unsafe { unlinkat(dirfd, name.as_ptr(), flags) };
if r < 0 {
Err(normalize_errno(io::Error::last_os_error()))
} else {
Ok(())
}
}
fn raw_mkdirat(dirfd: RawFd, name: &CStr, mode: u32) -> io::Result<()> {
let r = unsafe { mkdirat(dirfd, name.as_ptr(), mode) };
if r < 0 {
Err(normalize_errno(io::Error::last_os_error()))
} else {
Ok(())
}
}
fn raw_symlinkat(target: &CStr, dirfd: RawFd, name: &CStr) -> io::Result<()> {
let r = unsafe { symlinkat(target.as_ptr(), dirfd, name.as_ptr()) };
if r < 0 {
Err(normalize_errno(io::Error::last_os_error()))
} else {
Ok(())
}
}
fn raw_renameat(from_dirfd: RawFd, from: &CStr, to_dirfd: RawFd, to: &CStr) -> io::Result<()> {
let r = unsafe { renameat(from_dirfd, from.as_ptr(), to_dirfd, to.as_ptr()) };
if r < 0 {
Err(normalize_errno(io::Error::last_os_error()))
} else {
Ok(())
}
}
#[cfg(target_os = "linux")]
fn open_symlink_itself(parent: RawFd, name: &CStr) -> io::Result<OwnedFd> {
raw_openat(parent, name, sys::O_PATH | sys::O_NOFOLLOW, 0)
}
#[cfg(target_os = "macos")]
fn open_symlink_itself(parent: RawFd, name: &CStr) -> io::Result<OwnedFd> {
raw_openat(parent, name, sys::O_SYMLINK, 0)
}
struct DirGuard(*mut c_void);
impl Drop for DirGuard {
fn drop(&mut self) {
unsafe {
closedir(self.0);
}
}
}
fn list_dir_fd(fd: OwnedFd) -> io::Result<Vec<DirEntry>> {
let raw = fd.into_raw_fd();
let dirp = unsafe { fdopendir(raw) };
if dirp.is_null() {
let err = io::Error::last_os_error();
drop(unsafe { OwnedFd::from_raw_fd(raw) });
return Err(err);
}
let guard = DirGuard(dirp);
let name_offset = std::mem::offset_of!(RawDirentHeader, d_name);
let mut names = Vec::new();
loop {
let entry = unsafe { readdir(dirp) };
if entry.is_null() {
break;
}
let name_ptr = unsafe { entry.cast::<u8>().add(name_offset).cast::<c_char>() };
let name = unsafe { CStr::from_ptr(name_ptr) }
.to_string_lossy()
.into_owned();
if name != "." && name != ".." {
names.push(name);
}
}
let mut out = Vec::with_capacity(names.len());
for name in names {
let cname = cstr(&name)?;
let (kind, inode) = match raw_openat(raw, &cname, sys::O_RDONLY | sys::O_NOFOLLOW, 0) {
Ok(entry_fd) => match fs::File::from(entry_fd).metadata() {
Ok(m) => (kind_of(&m), m.ino()),
Err(_) => (NodeKind::File, 0),
},
Err(e) if e.raw_os_error() == Some(sys::ELOOP) => {
match open_symlink_itself(raw, &cname).and_then(|f| fs::File::from(f).metadata()) {
Ok(m) => (kind_of(&m), m.ino()),
Err(_) => (NodeKind::Symlink, 0),
}
}
Err(_) => (NodeKind::File, 0),
};
out.push(DirEntry { name, kind, inode });
}
drop(guard); Ok(out)
}
#[derive(Debug)]
pub struct Passthrough {
root: PathBuf,
read_only: bool,
}
impl Passthrough {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
read_only: false,
}
}
#[must_use]
pub fn read_only(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
read_only: true,
}
}
fn deny_if_ro(&self) -> io::Result<()> {
if self.read_only {
Err(io::Error::from_raw_os_error(30)) } else {
Ok(())
}
}
fn root_fd(&self) -> io::Result<OwnedFd> {
let f = fs::OpenOptions::new()
.read(true)
.custom_flags(sys::O_DIRECTORY)
.open(&self.root)
.map_err(normalize_errno)?;
Ok(OwnedFd::from(f))
}
fn split_rel(rel: &str) -> io::Result<VecDeque<String>> {
let mut out = VecDeque::new();
for c in rel.split('/') {
match c {
"" | "." => {}
".." => return Err(io::Error::from_raw_os_error(13)), c => out.push_back(c.to_string()),
}
}
Ok(out)
}
fn split_target(target: &str) -> (bool, VecDeque<String>) {
let absolute = target.starts_with('/');
let mut out = VecDeque::new();
for c in target.split('/') {
if !c.is_empty() && c != "." {
out.push_back(c.to_string());
}
}
(absolute, out)
}
fn resolve(
&self,
rel: &str,
follow_final: bool,
allow_missing_final: bool,
) -> io::Result<(OwnedFd, String)> {
let mut queue = Self::split_rel(rel)?;
if queue.is_empty() {
return Err(io::Error::from_raw_os_error(21)); }
let mut stack: Vec<OwnedFd> = vec![self.root_fd()?];
let mut budget = MAX_SYMLINKS;
let mut seen = 0u32;
loop {
let Some(name) = queue.pop_front() else {
return Err(io::Error::from_raw_os_error(21)); };
seen += 1;
if seen > MAX_COMPONENTS {
return Err(eloop());
}
if name == ".." {
if stack.len() > 1 {
stack.pop();
}
continue;
}
let is_last = queue.is_empty();
let cname = cstr(&name)?;
let parent_raw = stack.last().expect("root frame always present").as_raw_fd();
if is_last && !follow_final {
let parent = stack.pop().expect("root frame always present");
return Ok((parent, name));
}
let open_flags = if is_last {
sys::O_RDONLY | sys::O_NOFOLLOW
} else {
sys::O_DIRECTORY | sys::O_RDONLY | sys::O_NOFOLLOW
};
match raw_openat(parent_raw, &cname, open_flags, 0) {
Ok(fd) => {
if is_last {
drop(fd);
let parent = stack.pop().expect("root frame always present");
return Ok((parent, name));
}
stack.push(fd);
}
Err(e)
if is_last
&& follow_final
&& allow_missing_final
&& e.raw_os_error() == Some(2) =>
{
let parent = stack.pop().expect("root frame always present");
return Ok((parent, name));
}
Err(e) if e.raw_os_error() == Some(sys::ELOOP) => {
let target = raw_readlinkat(parent_raw, &cname)?.ok_or_else(eloop)?;
budget = budget.checked_sub(1).ok_or_else(eloop)?;
let (absolute, mut target_comps) = Self::split_target(&target);
if absolute {
stack.truncate(1);
}
while let Some(c) = target_comps.pop_back() {
queue.push_front(c);
}
}
Err(e) => return Err(normalize_errno(e)),
}
}
}
}
fn kind_of(m: &fs::Metadata) -> NodeKind {
let t = m.file_type();
if t.is_dir() {
NodeKind::Dir
} else if t.is_symlink() {
NodeKind::Symlink
} else if t.is_file() {
NodeKind::File
} else {
match m.mode() & 0o170_000 {
0o010_000 => NodeKind::Fifo,
0o140_000 => NodeKind::Socket,
0o060_000 => NodeKind::BlockDevice,
_ => NodeKind::CharDevice,
}
}
}
fn attrs_of(m: &fs::Metadata) -> Attrs {
Attrs {
kind: kind_of(m),
size: m.len(),
mode: m.mode(),
uid: m.uid(),
gid: m.gid(),
mtime: m.mtime(),
inode: m.ino(),
nlink: m.nlink() as u32,
rdev: m.rdev(),
}
}
impl MountFs for Passthrough {
fn read_only(&self) -> bool {
self.read_only
}
fn stat(&mut self, rel: &str) -> Option<Attrs> {
if rel.is_empty() || rel == "." {
let fd = self.root_fd().ok()?;
let m = fs::File::from(fd).metadata().ok()?;
return Some(attrs_of(&m));
}
let (parent, name) = self.resolve(rel, false, false).ok()?;
let cname = cstr(&name).ok()?;
match raw_openat(
parent.as_raw_fd(),
&cname,
sys::O_RDONLY | sys::O_NOFOLLOW,
0,
) {
Ok(fd) => {
let m = fs::File::from(fd).metadata().ok()?;
Some(attrs_of(&m))
}
Err(e) if e.raw_os_error() == Some(sys::ELOOP) => {
let fd = open_symlink_itself(parent.as_raw_fd(), &cname).ok()?;
let m = fs::File::from(fd).metadata().ok()?;
Some(attrs_of(&m))
}
Err(_) => None,
}
}
fn read_at(&mut self, rel: &str, off: u64, buf: &mut [u8]) -> io::Result<usize> {
let (parent, name) = self.resolve(rel, true, false)?;
let cname = cstr(&name)?;
let fd = raw_openat(
parent.as_raw_fd(),
&cname,
sys::O_RDONLY | sys::O_NOFOLLOW,
0,
)
.map_err(normalize_errno)?;
let mut f = fs::File::from(fd);
f.seek(SeekFrom::Start(off))?;
f.read(buf)
}
fn readdir(&mut self, rel: &str) -> io::Result<Vec<DirEntry>> {
let fd = if rel.is_empty() || rel == "." {
self.root_fd()?
} else {
let (parent, name) = self.resolve(rel, true, false)?;
let cname = cstr(&name)?;
raw_openat(
parent.as_raw_fd(),
&cname,
sys::O_DIRECTORY | sys::O_RDONLY | sys::O_NOFOLLOW,
0,
)
.map_err(normalize_errno)?
};
list_dir_fd(fd)
}
fn write_at(&mut self, rel: &str, off: u64, buf: &[u8]) -> io::Result<usize> {
self.deny_if_ro()?;
let (parent, name) = self.resolve(rel, true, false)?;
let cname = cstr(&name)?;
let fd = raw_openat(
parent.as_raw_fd(),
&cname,
sys::O_WRONLY | sys::O_NOFOLLOW,
0,
)
.map_err(normalize_errno)?;
let mut f = fs::File::from(fd);
f.seek(SeekFrom::Start(off))?;
f.write(buf)
}
fn create(&mut self, rel: &str, mode: u32) -> io::Result<()> {
self.deny_if_ro()?;
let (parent, name) = self.resolve(rel, true, true)?;
let cname = cstr(&name)?;
let fd = raw_openat(
parent.as_raw_fd(),
&cname,
sys::O_WRONLY | sys::O_CREAT | sys::O_TRUNC | sys::O_NOFOLLOW,
mode & 0o777,
)
.map_err(normalize_errno)?;
fs::File::from(fd).set_permissions(fs::Permissions::from_mode(mode & 0o777))
}
fn mkdir(&mut self, rel: &str, mode: u32) -> io::Result<()> {
self.deny_if_ro()?;
let (parent, name) = self.resolve(rel, false, false)?;
let cname = cstr(&name)?;
raw_mkdirat(parent.as_raw_fd(), &cname, mode & 0o777)?;
let fd = raw_openat(
parent.as_raw_fd(),
&cname,
sys::O_DIRECTORY | sys::O_RDONLY | sys::O_NOFOLLOW,
0,
)
.map_err(normalize_errno)?;
fs::File::from(fd).set_permissions(fs::Permissions::from_mode(mode & 0o777))
}
fn unlink(&mut self, rel: &str) -> io::Result<()> {
self.deny_if_ro()?;
let (parent, name) = self.resolve(rel, false, false)?;
let cname = cstr(&name)?;
if let Ok(fd) = raw_openat(
parent.as_raw_fd(),
&cname,
sys::O_RDONLY | sys::O_NOFOLLOW,
0,
) && fs::File::from(fd).metadata().is_ok_and(|m| m.is_dir())
{
return Err(io::Error::from_raw_os_error(21)); }
raw_unlinkat(parent.as_raw_fd(), &cname, 0)
}
fn rmdir(&mut self, rel: &str) -> io::Result<()> {
self.deny_if_ro()?;
let (parent, name) = self.resolve(rel, false, false)?;
let cname = cstr(&name)?;
raw_unlinkat(parent.as_raw_fd(), &cname, sys::AT_REMOVEDIR)
}
fn truncate(&mut self, rel: &str, len: u64) -> io::Result<()> {
self.deny_if_ro()?;
let (parent, name) = self.resolve(rel, true, false)?;
let cname = cstr(&name)?;
let fd = raw_openat(
parent.as_raw_fd(),
&cname,
sys::O_WRONLY | sys::O_NOFOLLOW | sys::O_NONBLOCK,
0,
)
.map_err(normalize_errno)?;
let file = fs::File::from(fd);
let meta = file.metadata()?;
if meta.is_dir() {
return Err(io::Error::from_raw_os_error(21)); }
if !meta.file_type().is_file() {
return Err(io::Error::from_raw_os_error(22)); }
file.set_len(len)
}
fn symlink(&mut self, target: &str, linkpath: &str) -> io::Result<()> {
self.deny_if_ro()?;
let (parent, name) = self.resolve(linkpath, false, false)?;
let cname = cstr(&name)?;
let ctarget = cstr(target)?;
raw_symlinkat(&ctarget, parent.as_raw_fd(), &cname)
}
fn readlink(&mut self, rel: &str) -> io::Result<String> {
let (parent, name) = self.resolve(rel, false, false)?;
let cname = cstr(&name)?;
raw_readlinkat(parent.as_raw_fd(), &cname)?.ok_or_else(|| {
io::Error::from_raw_os_error(22) })
}
fn rename(&mut self, from: &str, to: &str) -> io::Result<()> {
self.deny_if_ro()?;
let (from_parent, from_name) = self.resolve(from, false, false)?;
let (to_parent, to_name) = self.resolve(to, false, false)?;
let cfrom = cstr(&from_name)?;
let cto = cstr(&to_name)?;
raw_renameat(from_parent.as_raw_fd(), &cfrom, to_parent.as_raw_fd(), &cto)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!("nixvm-pt-{}-{tag}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
Self(dir)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn write_read_and_list_host_dir() {
let tmp = TempDir::new("rw");
let mut pt = Passthrough::new(tmp.0.clone());
pt.create("f.txt", 0o644).unwrap();
assert_eq!(pt.write_at("f.txt", 0, b"host data").unwrap(), 9);
let mut buf = [0u8; 9];
assert_eq!(pt.read_at("f.txt", 0, &mut buf).unwrap(), 9);
assert_eq!(&buf, b"host data");
assert_eq!(fs::read(tmp.0.join("f.txt")).unwrap(), b"host data");
pt.mkdir("sub", 0o755).unwrap();
let names: Vec<_> = pt
.readdir("")
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert!(names.contains(&"f.txt".to_string()) && names.contains(&"sub".to_string()));
assert_eq!(pt.stat("f.txt").unwrap().size, 9);
}
#[test]
fn rejects_parent_escape() {
let tmp = TempDir::new("esc");
let mut pt = Passthrough::new(tmp.0.clone());
assert!(pt.stat("../etc/passwd").is_none());
assert!(pt.read_at("../../secret", 0, &mut [0u8; 1]).is_err());
}
#[test]
fn read_only_denies_writes() {
let tmp = TempDir::new("ro");
fs::write(tmp.0.join("x"), b"hi").unwrap();
let mut pt = Passthrough::read_only(tmp.0.clone());
assert!(pt.write_at("x", 0, b"no").is_err());
assert!(pt.create("new", 0o644).is_err());
let mut buf = [0u8; 2];
assert_eq!(pt.read_at("x", 0, &mut buf).unwrap(), 2);
}
#[test]
fn legit_file_resolves_and_reads() {
let tmp = TempDir::new("legit");
fs::write(tmp.0.join("real.txt"), b"hello world").unwrap();
let mut pt = Passthrough::new(tmp.0.clone());
let mut buf = [0u8; 11];
assert_eq!(pt.read_at("real.txt", 0, &mut buf).unwrap(), 11);
assert_eq!(&buf, b"hello world");
assert_eq!(pt.stat("real.txt").unwrap().size, 11);
}
#[test]
fn in_tree_relative_symlink_resolves() {
let tmp = TempDir::new("intree");
fs::write(tmp.0.join("target.txt"), b"symlinked data").unwrap();
std::os::unix::fs::symlink("target.txt", tmp.0.join("link.txt")).unwrap();
let mut pt = Passthrough::new(tmp.0.clone());
let mut buf = [0u8; 14];
assert_eq!(pt.read_at("link.txt", 0, &mut buf).unwrap(), 14);
assert_eq!(&buf, b"symlinked data");
assert_eq!(pt.stat("link.txt").unwrap().kind, NodeKind::Symlink);
assert_eq!(pt.readlink("link.txt").unwrap(), "target.txt");
}
#[test]
fn absolute_symlink_target_is_not_followed_out_of_root() {
let tmp = TempDir::new("abs-escape");
std::os::unix::fs::symlink("/etc/passwd", tmp.0.join("evil")).unwrap();
let mut pt = Passthrough::new(tmp.0.clone());
let mut buf = [0u8; 16];
let err = pt.read_at("evil", 0, &mut buf).unwrap_err();
assert_eq!(err.raw_os_error(), Some(2));
assert!(pt.read_at("evil", 0, &mut buf).is_err());
}
#[test]
fn symlink_ascending_above_root_does_not_escape() {
let tmp = TempDir::new("ascend-escape");
std::os::unix::fs::symlink("../../../../../../../../../../etc/passwd", tmp.0.join("up"))
.unwrap();
let mut pt = Passthrough::new(tmp.0.clone());
let mut buf = [0u8; 16];
let err = pt.read_at("up", 0, &mut buf).unwrap_err();
assert_eq!(err.raw_os_error(), Some(2));
std::os::unix::fs::symlink("../..", tmp.0.join("up2")).unwrap();
assert!(pt.read_at("up2", 0, &mut buf).is_err());
}
#[test]
fn dotdot_ascending_above_root_is_rejected() {
let tmp = TempDir::new("dotdot");
let mut pt = Passthrough::new(tmp.0.clone());
assert!(pt.stat("../etc/passwd").is_none());
assert!(pt.read_at("../../secret", 0, &mut [0u8; 1]).is_err());
assert!(pt.stat("sub/../../escape").is_none());
}
#[test]
fn unlink_rmdir_rename_and_symlink_roundtrip() {
let tmp = TempDir::new("ops");
let mut pt = Passthrough::new(tmp.0.clone());
pt.create("a.txt", 0o644).unwrap();
pt.write_at("a.txt", 0, b"x").unwrap();
pt.rename("a.txt", "b.txt").unwrap();
assert!(pt.stat("a.txt").is_none());
assert_eq!(pt.stat("b.txt").unwrap().size, 1);
pt.mkdir("d", 0o755).unwrap();
pt.rmdir("d").unwrap();
assert!(pt.stat("d").is_none());
pt.symlink("b.txt", "c.txt").unwrap();
assert_eq!(pt.readlink("c.txt").unwrap(), "b.txt");
pt.unlink("c.txt").unwrap();
assert!(pt.stat("c.txt").is_none());
assert!(pt.stat("b.txt").is_some());
}
#[test]
fn full_rw_roundtrip_create_write_read_stat_truncate_unlink() {
let tmp = TempDir::new("full-rw");
let mut pt = Passthrough::new(tmp.0.clone());
pt.create("f.txt", 0o640).unwrap();
assert_eq!(pt.stat("f.txt").unwrap().size, 0);
assert_eq!(pt.stat("f.txt").unwrap().mode & 0o777, 0o640);
assert_eq!(pt.write_at("f.txt", 0, b"hello world").unwrap(), 11);
let mut buf = [0u8; 11];
assert_eq!(pt.read_at("f.txt", 0, &mut buf).unwrap(), 11);
assert_eq!(&buf, b"hello world");
let attrs = pt.stat("f.txt").unwrap();
assert_eq!(attrs.size, 11);
assert_eq!(attrs.kind, NodeKind::File);
pt.truncate("f.txt", 5).unwrap();
assert_eq!(pt.stat("f.txt").unwrap().size, 5);
let mut buf5 = [0u8; 5];
assert_eq!(pt.read_at("f.txt", 0, &mut buf5).unwrap(), 5);
assert_eq!(&buf5, b"hello");
pt.unlink("f.txt").unwrap();
assert!(pt.stat("f.txt").is_none());
assert!(!tmp.0.join("f.txt").exists());
}
#[test]
fn truncate_shrinks_and_grows() {
let tmp = TempDir::new("trunc");
let mut pt = Passthrough::new(tmp.0.clone());
pt.create("g.txt", 0o644).unwrap();
pt.write_at("g.txt", 0, b"abcdefghij").unwrap();
assert_eq!(pt.stat("g.txt").unwrap().size, 10);
pt.truncate("g.txt", 4).unwrap();
assert_eq!(pt.stat("g.txt").unwrap().size, 4);
let mut buf = [0u8; 4];
assert_eq!(pt.read_at("g.txt", 0, &mut buf).unwrap(), 4);
assert_eq!(&buf, b"abcd");
pt.truncate("g.txt", 10).unwrap();
assert_eq!(pt.stat("g.txt").unwrap().size, 10);
let mut tail = [0xffu8; 6];
assert_eq!(pt.read_at("g.txt", 4, &mut tail).unwrap(), 6);
assert_eq!(&tail, &[0u8; 6]);
assert_eq!(fs::metadata(tmp.0.join("g.txt")).unwrap().len(), 10);
}
#[test]
fn write_at_offset_beyond_eof_extends_file() {
let tmp = TempDir::new("write-extend");
let mut pt = Passthrough::new(tmp.0.clone());
pt.create("h.txt", 0o644).unwrap();
pt.write_at("h.txt", 0, b"abc").unwrap();
assert_eq!(pt.write_at("h.txt", 8, b"xyz").unwrap(), 3);
assert_eq!(pt.stat("h.txt").unwrap().size, 11);
let mut buf = [0u8; 11];
assert_eq!(pt.read_at("h.txt", 0, &mut buf).unwrap(), 11);
assert_eq!(&buf[..3], b"abc");
assert_eq!(&buf[3..8], &[0u8; 5]);
assert_eq!(&buf[8..], b"xyz");
}
#[test]
fn truncate_on_directory_fails_eisdir() {
let tmp = TempDir::new("trunc-dir");
let mut pt = Passthrough::new(tmp.0.clone());
pt.mkdir("d", 0o755).unwrap();
let err = pt.truncate("d", 0).unwrap_err();
assert_eq!(err.raw_os_error(), Some(21)); }
#[test]
fn read_only_rejects_all_mutators_with_erofs() {
let tmp = TempDir::new("ro-full");
fs::write(tmp.0.join("x"), b"hi").unwrap();
fs::create_dir(tmp.0.join("d")).unwrap();
let mut pt = Passthrough::read_only(tmp.0.clone());
assert_eq!(
pt.create("new.txt", 0o644).unwrap_err().raw_os_error(),
Some(30) );
assert_eq!(
pt.write_at("x", 0, b"no").unwrap_err().raw_os_error(),
Some(30)
);
assert_eq!(pt.truncate("x", 0).unwrap_err().raw_os_error(), Some(30));
assert_eq!(pt.unlink("x").unwrap_err().raw_os_error(), Some(30));
assert_eq!(pt.rmdir("d").unwrap_err().raw_os_error(), Some(30));
assert_eq!(pt.mkdir("d2", 0o755).unwrap_err().raw_os_error(), Some(30));
assert_eq!(
pt.symlink("x", "link").unwrap_err().raw_os_error(),
Some(30)
);
assert_eq!(pt.rename("x", "y").unwrap_err().raw_os_error(), Some(30));
assert_eq!(fs::read(tmp.0.join("x")).unwrap(), b"hi");
assert!(tmp.0.join("d").is_dir());
let mut buf = [0u8; 2];
assert_eq!(pt.read_at("x", 0, &mut buf).unwrap(), 2);
assert_eq!(pt.stat("x").unwrap().size, 2);
}
#[test]
fn rmdir_nonempty_fails_enotempty() {
let tmp = TempDir::new("rmdir-nonempty");
let mut pt = Passthrough::new(tmp.0.clone());
pt.mkdir("d", 0o755).unwrap();
pt.create("d/child.txt", 0o644).unwrap();
let err = pt.rmdir("d").unwrap_err();
assert_eq!(err.raw_os_error(), Some(39));
assert_eq!(pt.stat("d").unwrap().kind, NodeKind::Dir);
assert!(pt.stat("d/child.txt").is_some());
pt.unlink("d/child.txt").unwrap();
pt.rmdir("d").unwrap();
assert!(pt.stat("d").is_none());
}
#[test]
fn unlink_on_directory_fails_eisdir() {
let tmp = TempDir::new("unlink-dir");
let mut pt = Passthrough::new(tmp.0.clone());
pt.mkdir("d", 0o755).unwrap();
let err = pt.unlink("d").unwrap_err();
assert_eq!(err.raw_os_error(), Some(21));
assert_eq!(pt.stat("d").unwrap().kind, NodeKind::Dir);
pt.symlink("d", "link_to_d").unwrap();
pt.unlink("link_to_d").unwrap();
assert!(pt.stat("link_to_d").is_none());
assert!(pt.stat("d").is_some());
}
#[test]
fn mkdir_existing_path_fails_eexist() {
let tmp = TempDir::new("mkdir-eexist");
let mut pt = Passthrough::new(tmp.0.clone());
pt.mkdir("d", 0o755).unwrap();
let err = pt.mkdir("d", 0o755).unwrap_err();
assert_eq!(err.raw_os_error(), Some(17)); }
#[test]
fn absolute_symlink_escape_is_not_followed_on_write_or_truncate() {
let tmp = TempDir::new("write-escape");
std::os::unix::fs::symlink("/etc/passwd", tmp.0.join("evil")).unwrap();
let mut pt = Passthrough::new(tmp.0.clone());
let err = pt.write_at("evil", 0, b"pwned").unwrap_err();
assert_eq!(err.raw_os_error(), Some(2)); let err = pt.truncate("evil", 0).unwrap_err();
assert_eq!(err.raw_os_error(), Some(2));
assert!(!fs::read_to_string("/etc/passwd").unwrap().is_empty());
}
#[test]
fn ascending_symlink_escape_is_not_followed_on_write_or_truncate() {
let tmp = TempDir::new("write-ascend-escape");
std::os::unix::fs::symlink("../../../../../../../../../../etc/passwd", tmp.0.join("up"))
.unwrap();
let mut pt = Passthrough::new(tmp.0.clone());
let err = pt.write_at("up", 0, b"pwned").unwrap_err();
assert_eq!(err.raw_os_error(), Some(2)); let err = pt.truncate("up", 0).unwrap_err();
assert_eq!(err.raw_os_error(), Some(2));
}
}