#![cfg(test)]
#![allow(non_snake_case)]
use std::ffi::OsStr;
use std::fs;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
use std::path::PathBuf;
use super::backend::{
FsBackend, EACCES, EBADF, EEXIST, EINVAL, EISDIR, ENOENT, ENOTDIR, EPERM,
};
use super::posix::PosixFs;
use super::protocol::{
SetattrIn, FATTR_GID, FATTR_MODE, FATTR_SIZE, FATTR_UID, FUSE_ROOT_ID, S_IFDIR, S_IFLNK,
S_IFMT, S_IFREG,
};
fn tmpdir(name: &str) -> PathBuf {
let pid = unsafe { libc::getpid() };
let p = std::env::temp_dir().join(format!("posixfs-compliance-{pid}-{name}"));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
fn make_setattr(valid: u32) -> SetattrIn {
SetattrIn {
valid,
padding: 0,
fh: 0,
size: 0,
lock_owner: 0,
atime: 0,
mtime: 0,
ctime: 0,
atimensec: 0,
mtimensec: 0,
ctimensec: 0,
mode: 0,
unused4: 0,
uid: 0,
gid: 0,
unused5: 0,
}
}
mod lookup_semantics {
use super::*;
#[test]
fn lookup_of_broken_symlink_returns_symlink_kind_not_target_kind() {
let dir = tmpdir("lookup-broken-kind");
std::os::unix::fs::symlink("not-here.txt", dir.join("dangling")).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let e = fs.lookup(FUSE_ROOT_ID, OsStr::new("dangling")).unwrap();
assert_eq!(
e.attr.mode & S_IFMT,
S_IFLNK,
"broken symlink must report S_IFLNK ({:#o}) in lookup attr.mode; got mode={:#o}. \
A stat()-via-libc that follows would error ENOENT — we must call lstat() instead.",
S_IFLNK,
e.attr.mode
);
}
#[test]
fn lookup_of_broken_symlink_attr_size_equals_target_path_length() {
let dir = tmpdir("lookup-broken-size");
let target = "some/relative/missing-target.txt";
std::os::unix::fs::symlink(target, dir.join("dangling")).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let e = fs.lookup(FUSE_ROOT_ID, OsStr::new("dangling")).unwrap();
assert_eq!(
e.attr.size,
target.len() as u64,
"symlink size MUST equal byte length of target-path (POSIX lstat); \
got {} expected {} (target={:?}). \
readlink(2) callers pre-size their buffer to lstat.size; a wrong \
value here truncates.",
e.attr.size,
target.len(),
target
);
}
#[test]
fn lookup_of_symlink_to_dir_returns_symlink_not_dir_attrs() {
let dir = tmpdir("lookup-sym-to-dir");
fs::create_dir_all(dir.join("real_dir")).unwrap();
std::os::unix::fs::symlink("real_dir", dir.join("alias")).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let e = fs.lookup(FUSE_ROOT_ID, OsStr::new("alias")).unwrap();
assert_eq!(
e.attr.mode & S_IFMT,
S_IFLNK,
"symlink-to-directory must report S_IFLNK ({:#o}), NOT S_IFDIR ({:#o}); \
got mode={:#o}. If we say S_IFDIR here, the guest will treat the dentry \
as a real dir and skip readlink/openat lookup-via-target which is wrong.",
S_IFLNK,
S_IFDIR,
e.attr.mode
);
}
#[test]
fn lookup_of_dotdot_rejected() {
let dir = tmpdir("lookup-dotdot");
let fs = PosixFs::new(&dir).unwrap();
let err = fs.lookup(FUSE_ROOT_ID, OsStr::new("..")).unwrap_err();
assert_eq!(
err, EINVAL,
"lookup('..') must return EINVAL (-22); we never allow path traversal \
through `..` at the FUSE protocol boundary even though the host fs \
would happily resolve it."
);
}
#[test]
#[ignore = "BUG: name_safe() in posix.rs does not yet reject embedded newlines; \
see crates/supermachine/src/fuse/posix.rs:587 `name_safe`. \
Defensive — POSIX itself doesn't forbid `\\n`, but many tools \
break on it. Currently host fs creates the file with a newline \
in its name. Either implement the filter or close this ignore."]
fn lookup_of_name_with_embedded_newline_rejected() {
let dir = tmpdir("lookup-newline");
let fs = PosixFs::new(&dir).unwrap();
let bad = OsStr::from_bytes(b"foo\nbar");
let err = fs.lookup(FUSE_ROOT_ID, bad).unwrap_err();
assert_eq!(
err, EINVAL,
"name with embedded newline should be rejected — defensive policy \
matches POSIX 'path component must be portable filename' (per \
SUSv4 3.282); got err={err}"
);
}
#[test]
fn lookup_of_name_longer_than_NAME_MAX_rejected() {
let dir = tmpdir("lookup-toolong");
let fs = PosixFs::new(&dir).unwrap();
let long: Vec<u8> = vec![b'a'; 300];
let err = fs
.lookup(FUSE_ROOT_ID, OsStr::from_bytes(&long))
.unwrap_err();
assert_eq!(
err, -36,
"lookup(<300-char-name>) must return Linux ENAMETOOLONG=-36, \
NOT macOS ENAMETOOLONG=-63 (which the guest's libc decodes as \
EREMOTE on Linux). errno translator at posix.rs `host_to_linux_errno` \
must map 63→36. got err={err}"
);
}
}
mod stat_semantics {
use super::*;
#[test]
fn getattr_of_regular_file_returns_S_IFREG_with_correct_size() {
let dir = tmpdir("stat-reg");
let payload = b"hello world";
fs::write(dir.join("f"), payload).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let e = fs.lookup(FUSE_ROOT_ID, OsStr::new("f")).unwrap();
let attr = fs.getattr(e.nodeid, None).unwrap();
assert_eq!(
attr.mode & S_IFMT,
S_IFREG,
"regular file must report S_IFREG ({:#o}); got mode={:#o}",
S_IFREG,
attr.mode
);
assert_eq!(
attr.size,
payload.len() as u64,
"size for newly-written {} byte file must be {}; got {}",
payload.len(),
payload.len(),
attr.size
);
}
#[test]
fn getattr_of_directory_returns_S_IFDIR_with_nlink_ge_2() {
let dir = tmpdir("stat-dir");
fs::create_dir_all(dir.join("sub1")).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let attr = fs.getattr(FUSE_ROOT_ID, None).unwrap();
assert_eq!(
attr.mode & S_IFMT,
S_IFDIR,
"root must report S_IFDIR ({:#o}); got mode={:#o}",
S_IFDIR,
attr.mode
);
assert!(
attr.nlink >= 2,
"directory nlink must be >= 2 (counting `.` + parent's `..` slot); \
got nlink={}. Linux readdir + many shells assume this; nlink=1 \
is the 'broken' signal that bypasses optimizations.",
attr.nlink
);
}
#[test]
fn getattr_of_symlink_returns_S_IFLNK_with_size_eq_target_path_length() {
let dir = tmpdir("stat-sym");
fs::write(dir.join("real.bin"), vec![0u8; 4096]).unwrap();
std::os::unix::fs::symlink("real.bin", dir.join("link")).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let e = fs.lookup(FUSE_ROOT_ID, OsStr::new("link")).unwrap();
let attr = fs.getattr(e.nodeid, None).unwrap();
assert_eq!(
attr.mode & S_IFMT,
S_IFLNK,
"getattr on symlink must report S_IFLNK; got mode={:#o}. \
If this fires, posix.rs::getattr is calling std::fs::metadata \
(= stat, follows) instead of std::fs::symlink_metadata (= lstat). \
This was 0.7.4's bug.",
attr.mode
);
assert_eq!(
attr.size,
"real.bin".len() as u64,
"symlink size MUST equal strlen(target). got {}, expected {}. \
4096 would mean we returned the TARGET's size — readlink(2) \
callers pre-size by lstat.size and would truncate.",
attr.size,
"real.bin".len()
);
}
#[test]
fn getattr_of_recently_truncated_file_size_is_zero() {
let dir = tmpdir("stat-trunc");
fs::write(dir.join("f"), vec![0xAAu8; 100]).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let e = fs.lookup(FUSE_ROOT_ID, OsStr::new("f")).unwrap();
let mut req = make_setattr(FATTR_SIZE);
req.size = 0;
let post = fs.setattr(e.nodeid, None, req).unwrap();
assert_eq!(
post.size, 0,
"setattr returned size {}; expected 0 (we just truncated)",
post.size
);
let attr = fs.getattr(e.nodeid, None).unwrap();
assert_eq!(
attr.size, 0,
"getattr after truncate(0) must report size=0; got {}. \
A non-zero here = stale cache OR truncate didn't reach disk.",
attr.size
);
}
}
mod write_semantics {
use super::*;
#[test]
#[ignore = "BUG: PosixFs::open() in posix.rs masks O_TRUNC out (see open() \
at posix.rs:715 — `flags as i32 & libc::O_ACCMODE`). The guest \
kernel issues a separate FUSE_SETATTR with FATTR_SIZE=0 instead, \
so end-to-end the file IS truncated. But the unit-level contract \
'OPEN with O_TRUNC zeros the file' is not honored at this layer. \
Decide: either fold the host's open(O_TRUNC) into the backend \
OR document the contract as 'guest kernel must send SETATTR first'."]
fn open_O_TRUNC_zeros_existing_file() {
let dir = tmpdir("write-otrunc");
fs::write(dir.join("f"), vec![0xAAu8; 100]).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let e = fs.lookup(FUSE_ROOT_ID, OsStr::new("f")).unwrap();
let fh = fs
.open(
e.nodeid,
(libc::O_WRONLY | libc::O_TRUNC) as u32,
)
.unwrap();
let attr = fs.getattr(e.nodeid, None).unwrap();
assert_eq!(
attr.size, 0,
"open(O_WRONLY|O_TRUNC) must zero an existing file; got size={}. \
POSIX open(2): 'If O_TRUNC is set and the file already exists, \
the file shall be truncated to zero length'. If the backend masks \
this out, the guest's libc-level open(O_TRUNC) breaks subtly: \
read-back returns stale bytes instead of EOF.",
attr.size
);
fs.release(e.nodeid, fh).unwrap();
}
#[test]
fn open_O_CREAT_O_EXCL_existing_returns_EEXIST() {
let dir = tmpdir("write-excl");
let fs = PosixFs::new(&dir).unwrap();
let (_e1, fh1) = fs
.create(
FUSE_ROOT_ID,
OsStr::new("lock"),
0o644,
libc::O_RDWR as u32,
)
.unwrap();
let err = fs
.create(
FUSE_ROOT_ID,
OsStr::new("lock"),
0o644,
libc::O_RDWR as u32,
)
.unwrap_err();
assert_eq!(
err, EEXIST,
"second create(O_EXCL) on existing name must return EEXIST (-17); \
got err={err}. POSIX open(2) O_CREAT|O_EXCL contract — any other \
value breaks lock-file algorithms (test-and-set on a file).",
);
let _ = fs.release(0, fh1);
}
#[test]
fn write_then_read_returns_same_bytes() {
let dir = tmpdir("write-rt");
let fs = PosixFs::new(&dir).unwrap();
let (e, fh) = fs
.create(
FUSE_ROOT_ID,
OsStr::new("rt"),
0o644,
libc::O_RDWR as u32,
)
.unwrap();
let n = fs.write(e.nodeid, fh, 0, b"round-trip").unwrap();
assert_eq!(n, 10);
let buf = fs.read(e.nodeid, fh, 0, 64).unwrap();
assert_eq!(
buf, b"round-trip",
"read after write returned {:?}; expected {:?}",
buf, b"round-trip"
);
fs.release(e.nodeid, fh).unwrap();
}
#[test]
fn write_at_offset_holes_read_as_zero() {
let dir = tmpdir("write-hole");
let fs = PosixFs::new(&dir).unwrap();
let (e, fh) = fs
.create(
FUSE_ROOT_ID,
OsStr::new("sparse"),
0o644,
libc::O_RDWR as u32,
)
.unwrap();
let off: u64 = 1 << 20;
fs.write(e.nodeid, fh, off, &[0xFF]).unwrap();
let early = fs.read(e.nodeid, fh, 0, 100).unwrap();
assert_eq!(
early.len(),
100,
"read across hole returned {} bytes; expected 100. \
Sparse files: read in a hole returns zeros without short-read.",
early.len()
);
assert!(
early.iter().all(|&b| b == 0),
"hole region must read as zero bytes; got first non-zero at \
offset {} (value {:#04x}). POSIX sparse semantics.",
early.iter().position(|&b| b != 0).unwrap_or(usize::MAX),
early.iter().find(|&&b| b != 0).copied().unwrap_or(0),
);
fs.release(e.nodeid, fh).unwrap();
}
#[test]
fn write_after_close_returns_EBADF() {
let dir = tmpdir("write-eof-fh");
let fs = PosixFs::new(&dir).unwrap();
let (e, fh) = fs
.create(
FUSE_ROOT_ID,
OsStr::new("f"),
0o644,
libc::O_RDWR as u32,
)
.unwrap();
fs.release(e.nodeid, fh).unwrap();
let err = fs.write(e.nodeid, fh, 0, b"x").unwrap_err();
assert_eq!(
err, EBADF,
"write to released fh must return EBADF (-9); got err={err}. \
POSIX close(2) invalidates the fd; subsequent write(2) gives EBADF."
);
}
}
mod rmdir_unlink_semantics {
use super::*;
#[test]
fn rmdir_nonempty_returns_ENOTEMPTY_eq_39() {
let dir = tmpdir("rmdir-ne");
fs::create_dir_all(dir.join("sub")).unwrap();
fs::write(dir.join("sub/inside"), b"x").unwrap();
let fs = PosixFs::new(&dir).unwrap();
let err = fs.rmdir(FUSE_ROOT_ID, OsStr::new("sub")).unwrap_err();
assert_eq!(
err, -39,
"rmdir(non-empty) must return Linux ENOTEMPTY=-39 over the wire, \
NOT macOS-host -66 (which the guest decodes as EREMOTE). \
The 66→39 translation lives in posix.rs `host_to_linux_errno`; \
if this test fails, that map is broken. got err={err}"
);
}
#[test]
fn rmdir_of_regular_file_returns_ENOTDIR_eq_20() {
let dir = tmpdir("rmdir-notdir");
fs::write(dir.join("notadir"), b"x").unwrap();
let fs = PosixFs::new(&dir).unwrap();
let err = fs.rmdir(FUSE_ROOT_ID, OsStr::new("notadir")).unwrap_err();
assert_eq!(
err, ENOTDIR,
"rmdir(regular_file) must return Linux ENOTDIR=-20; got err={err}. \
POSIX: 'If the path argument refers to a path whose final component \
is neither a symbolic link nor a directory, rmdir() shall fail.'"
);
}
#[test]
fn unlink_of_directory_returns_EISDIR_eq_21() {
let dir = tmpdir("unlink-dir");
fs::create_dir_all(dir.join("sub")).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let err = fs.unlink(FUSE_ROOT_ID, OsStr::new("sub")).unwrap_err();
assert!(
err == EISDIR || err == EPERM,
"unlink(directory) must return EISDIR (-21, Linux) or EPERM (-1, macOS); \
got err={err}. POSIX leaves both as valid for this case (unlink on dir \
'shall fail and may return EPERM or EISDIR'). The errno translator at \
posix.rs preserves whichever the host fs returns."
);
}
#[test]
fn rmdir_of_nonexistent_returns_ENOENT_eq_2() {
let dir = tmpdir("rmdir-nope");
let fs = PosixFs::new(&dir).unwrap();
let err = fs.rmdir(FUSE_ROOT_ID, OsStr::new("ghost")).unwrap_err();
assert_eq!(
err, ENOENT,
"rmdir(nonexistent) must return ENOENT=-2; got err={err}. \
POSIX-identical on both macOS and Linux (errno 2)."
);
}
}
mod rename_semantics {
use super::*;
#[test]
fn rename_overwrites_existing_destination() {
let dir = tmpdir("rename-overwrite");
fs::write(dir.join("src"), b"new contents").unwrap();
fs::write(dir.join("dst"), b"old contents").unwrap();
let fs = PosixFs::new(&dir).unwrap();
fs.rename(
FUSE_ROOT_ID,
OsStr::new("src"),
FUSE_ROOT_ID,
OsStr::new("dst"),
0,
)
.unwrap();
assert!(
!dir.join("src").exists(),
"source must be gone after rename"
);
assert_eq!(
fs::read(dir.join("dst")).unwrap(),
b"new contents",
"destination must have the source's contents (atomic replace)"
);
}
#[test]
fn rename_directory_into_itself_returns_EINVAL() {
let dir = tmpdir("rename-self");
fs::create_dir_all(dir.join("a")).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let a = fs.lookup(FUSE_ROOT_ID, OsStr::new("a")).unwrap();
let err = fs
.rename(
FUSE_ROOT_ID,
OsStr::new("a"),
a.nodeid,
OsStr::new("b"),
0,
)
.unwrap_err();
assert_eq!(
err, EINVAL,
"rename(dir, dir/sub) must return EINVAL=-22; got err={err}. \
POSIX rename(2): 'A loop in the renaming hierarchy is forbidden.' \
macOS errno=22, Linux errno=22 (POSIX-identical)."
);
}
#[test]
fn rename_across_dirs_works() {
let dir = tmpdir("rename-xdir");
fs::create_dir_all(dir.join("a")).unwrap();
fs::create_dir_all(dir.join("b")).unwrap();
fs::write(dir.join("a/f"), b"cross").unwrap();
let fs = PosixFs::new(&dir).unwrap();
let a = fs.lookup(FUSE_ROOT_ID, OsStr::new("a")).unwrap();
let b = fs.lookup(FUSE_ROOT_ID, OsStr::new("b")).unwrap();
fs.rename(a.nodeid, OsStr::new("f"), b.nodeid, OsStr::new("f"), 0)
.unwrap();
assert!(!dir.join("a/f").exists());
assert_eq!(fs::read(dir.join("b/f")).unwrap(), b"cross");
}
#[test]
fn rename_preserves_nodeid_for_renamed_inode() {
let dir = tmpdir("rename-ino");
fs::write(dir.join("orig"), b"x").unwrap();
let fs = PosixFs::new(&dir).unwrap();
let before = fs.lookup(FUSE_ROOT_ID, OsStr::new("orig")).unwrap();
let nodeid_before = before.nodeid;
fs.rename(
FUSE_ROOT_ID,
OsStr::new("orig"),
FUSE_ROOT_ID,
OsStr::new("renamed"),
0,
)
.unwrap();
let after = fs.lookup(FUSE_ROOT_ID, OsStr::new("renamed")).unwrap();
assert_eq!(
after.nodeid, nodeid_before,
"nodeid must survive rename — was {}, now {}. Guest dentry cache \
keys on nodeid; a fresh allocation would silently invalidate every \
pre-rename fh the guest is holding.",
nodeid_before, after.nodeid
);
}
#[test]
fn rename_of_open_file_keeps_fh_valid() {
let dir = tmpdir("rename-fh");
fs::write(dir.join("src"), b"keep me reading").unwrap();
let fs = PosixFs::new(&dir).unwrap();
let e = fs.lookup(FUSE_ROOT_ID, OsStr::new("src")).unwrap();
let fh = fs.open(e.nodeid, libc::O_RDONLY as u32).unwrap();
fs.rename(
FUSE_ROOT_ID,
OsStr::new("src"),
FUSE_ROOT_ID,
OsStr::new("dst"),
0,
)
.unwrap();
let buf = fs.read(e.nodeid, fh, 0, 64).unwrap();
assert_eq!(
buf, b"keep me reading",
"fh held across rename must still read the original content; \
got {:?}. POSIX rename(2): an open fd refers to the open-file \
description, not the name — the data follows the inode.",
buf
);
fs.release(e.nodeid, fh).unwrap();
}
}
mod symlink_semantics {
use super::*;
#[test]
fn symlink_create_then_readlink_round_trip() {
let dir = tmpdir("sym-rt");
let fs = PosixFs::new(&dir).unwrap();
let e = fs
.symlink(FUSE_ROOT_ID, OsStr::new("link"), OsStr::new("payload"))
.unwrap();
let target = fs.readlink(e.nodeid).unwrap();
assert_eq!(
target, b"payload",
"readlink must return the exact bytes we passed to symlink; \
got {:?} expected b\"payload\"",
target
);
}
#[test]
fn symlink_target_can_contain_dotdot_and_absolute() {
let dir = tmpdir("sym-opaque-target");
let fs = PosixFs::new(&dir).unwrap();
let weird = "../../../absolutely/nowhere";
let e = fs
.symlink(FUSE_ROOT_ID, OsStr::new("weird"), OsStr::new(weird))
.unwrap();
let got = fs.readlink(e.nodeid).unwrap();
assert_eq!(
got,
weird.as_bytes(),
"POSIX: symlink target bytes are opaque — `..` and absolute paths \
must round-trip verbatim. got {:?} expected {:?}",
got,
weird.as_bytes()
);
}
#[test]
fn symlink_target_longer_than_PATH_MAX_rejected_with_ENAMETOOLONG() {
let dir = tmpdir("sym-toolong");
let fs = PosixFs::new(&dir).unwrap();
let huge: Vec<u8> = vec![b'a'; 5000];
let target_os = OsStr::from_bytes(&huge);
let err = fs
.symlink(FUSE_ROOT_ID, OsStr::new("toolong"), target_os)
.unwrap_err();
assert_eq!(
err, -36,
"symlink with 5000-byte target must return Linux ENAMETOOLONG=-36, \
NOT macOS-host -63 (which decodes as EREMOTE on Linux). errno \
translator at posix.rs `host_to_linux_errno` owns the 63→36 map. \
got err={err}"
);
}
#[test]
fn readlink_on_regular_file_returns_EINVAL_eq_22() {
let dir = tmpdir("readlink-reg");
fs::write(dir.join("notalink"), b"hi").unwrap();
let fs = PosixFs::new(&dir).unwrap();
let e = fs.lookup(FUSE_ROOT_ID, OsStr::new("notalink")).unwrap();
let err = fs.readlink(e.nodeid).unwrap_err();
assert_eq!(
err, EINVAL,
"readlink(regular_file) must return EINVAL=-22; got err={err}. \
POSIX readlink(2): 'The named file is not a symbolic link.'"
);
}
#[test]
fn lookup_through_internal_symlink_resolves() {
let dir = tmpdir("sym-internal");
fs::create_dir_all(dir.join("real")).unwrap();
fs::write(dir.join("real/data.txt"), b"internal").unwrap();
std::os::unix::fs::symlink("real/data.txt", dir.join("link")).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let e = fs.lookup(FUSE_ROOT_ID, OsStr::new("link")).unwrap();
assert_eq!(
e.attr.mode & S_IFMT,
S_IFLNK,
"internal-target symlink still reports S_IFLNK at lookup; \
the guest decides whether to deref via its own kernel."
);
let target = fs.readlink(e.nodeid).unwrap();
assert_eq!(
target, b"real/data.txt",
"readlink on internal symlink returns the stored target bytes."
);
}
}
mod errno_translation {
use super::*;
#[test]
fn host_to_linux_errno_ENOTEMPTY_maps_correctly() {
let dir = tmpdir("err-enotempty");
fs::create_dir_all(dir.join("sub")).unwrap();
fs::write(dir.join("sub/inside"), b"x").unwrap();
let fs = PosixFs::new(&dir).unwrap();
let err = fs.rmdir(FUSE_ROOT_ID, OsStr::new("sub")).unwrap_err();
assert_eq!(
err, -39,
"ENOTEMPTY contract: macOS host errno 66 MUST translate to Linux 39 \
on the FUSE wire. The guest's libc decodes -66 as EREMOTE (the bug). \
got err={err}"
);
}
#[test]
fn host_to_linux_errno_ENAMETOOLONG_maps_correctly() {
let dir = tmpdir("err-enametoolong");
let fs = PosixFs::new(&dir).unwrap();
let huge: Vec<u8> = vec![b'a'; 5000];
let err = fs
.symlink(
FUSE_ROOT_ID,
OsStr::new("over"),
OsStr::from_bytes(&huge),
)
.unwrap_err();
assert_eq!(
err, -36,
"ENAMETOOLONG contract: macOS host errno 63 MUST translate to Linux 36; \
the guest decodes -63 as EREMOTE. got err={err}"
);
}
#[test]
fn host_to_linux_errno_EEXIST_passthrough() {
let dir = tmpdir("err-eexist");
fs::write(dir.join("present"), b"x").unwrap();
let fs = PosixFs::new(&dir).unwrap();
let err = fs
.create(
FUSE_ROOT_ID,
OsStr::new("present"),
0o644,
libc::O_RDWR as u32,
)
.unwrap_err();
assert_eq!(
err, EEXIST,
"EEXIST is errno=17 on both macOS and Linux — passthrough. \
got err={err}. If this fails, the translator broke a row that \
shouldn't have moved."
);
}
#[test]
fn host_to_linux_errno_ENOTDIR_passthrough() {
let dir = tmpdir("err-enotdir");
fs::write(dir.join("f"), b"x").unwrap();
let fs = PosixFs::new(&dir).unwrap();
let err = fs.rmdir(FUSE_ROOT_ID, OsStr::new("f")).unwrap_err();
assert_eq!(
err, ENOTDIR,
"ENOTDIR is errno=20 on both macOS and Linux — passthrough. \
got err={err}"
);
}
#[test]
fn host_to_linux_errno_ENOENT_passthrough() {
let dir = tmpdir("err-enoent");
let fs = PosixFs::new(&dir).unwrap();
let err = fs.rmdir(FUSE_ROOT_ID, OsStr::new("ghost")).unwrap_err();
assert_eq!(
err, ENOENT,
"ENOENT is errno=2 on both sides — passthrough. got err={err}"
);
}
#[test]
fn host_to_linux_errno_EINVAL_passthrough() {
let dir = tmpdir("err-einval");
fs::create_dir_all(dir.join("a")).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let a = fs.lookup(FUSE_ROOT_ID, OsStr::new("a")).unwrap();
let err = fs
.rename(
FUSE_ROOT_ID,
OsStr::new("a"),
a.nodeid,
OsStr::new("b"),
0,
)
.unwrap_err();
assert_eq!(
err, EINVAL,
"EINVAL=22 is shared; rename(dir, dir/sub) is the canonical \
POSIX EINVAL trigger. got err={err}"
);
}
#[test]
fn host_to_linux_errno_EBADF_passthrough() {
let dir = tmpdir("err-ebadf");
let fs = PosixFs::new(&dir).unwrap();
let err = fs.read(FUSE_ROOT_ID, 0xdead_beef, 0, 8).unwrap_err();
assert_eq!(
err, EBADF,
"EBADF=9 on both sides; backend allocates fh, unknown fh → EBADF. \
got err={err}"
);
}
#[test]
fn host_to_linux_errno_EACCES_passthrough() {
let dir = tmpdir("err-eacces");
let outside = tmpdir("err-eacces-out");
fs::write(outside.join("x"), b"secret").unwrap();
std::os::unix::fs::symlink(&outside, dir.join("escape")).unwrap();
let fs = PosixFs::new(&dir).unwrap();
let err = fs.lookup(FUSE_ROOT_ID, OsStr::new("escape")).unwrap_err();
assert_eq!(
err, EACCES,
"EACCES=13 on both sides; external symlink under Opaque policy \
surfaces EACCES at LOOKUP. got err={err}"
);
}
}
mod permission_semantics {
use super::*;
#[test]
fn chmod_then_getattr_reflects_new_mode() {
let dir = tmpdir("perm-chmod");
let path = dir.join("f");
fs::write(&path, b"x").unwrap();
std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o644))
.unwrap();
let fs = PosixFs::new(&dir).unwrap();
let e = fs.lookup(FUSE_ROOT_ID, OsStr::new("f")).unwrap();
let mut req = make_setattr(FATTR_MODE);
req.mode = 0o755;
let post = fs.setattr(e.nodeid, None, req).unwrap();
assert_eq!(
post.mode & 0o7777,
0o755,
"setattr(MODE=0o755) must return new mode in attr.mode (low 12 bits); \
got mode={:#o}",
post.mode & 0o7777
);
let observed = fs.getattr(e.nodeid, None).unwrap();
assert_eq!(
observed.mode & 0o7777,
0o755,
"subsequent getattr must observe the new mode; got mode={:#o}",
observed.mode & 0o7777
);
}
#[test]
#[ignore = "requires non-root runtime + setuid bit drop semantics; \
lift to an integration test that boots a guest as non-root \
and verifies S_ISUID drops on chmod via os.chmod"]
fn chmod_clears_setuid_when_dropping_perms_on_owner() {
}
#[test]
fn chown_changes_uid_gid_or_returns_eperm_as_nonroot() {
let dir = tmpdir("perm-chown");
let path = dir.join("f");
fs::write(&path, b"x").unwrap();
let fs = PosixFs::new(&dir).unwrap();
let e = fs.lookup(FUSE_ROOT_ID, OsStr::new("f")).unwrap();
let me_uid = unsafe { libc::getuid() } as u32;
let me_gid = unsafe { libc::getgid() } as u32;
let mut req = make_setattr(FATTR_UID | FATTR_GID);
req.uid = me_uid;
req.gid = me_gid;
let post = fs.setattr(e.nodeid, None, req).unwrap();
assert_eq!(
post.uid, me_uid,
"setattr(UID=me) must reflect in attr.uid; got {} expected {}",
post.uid, me_uid
);
assert_eq!(
post.gid, me_gid,
"setattr(GID=me) must reflect in attr.gid; got {} expected {}",
post.gid, me_gid
);
let md = fs::metadata(&path).unwrap();
assert_eq!(md.uid(), me_uid);
assert_eq!(md.gid(), me_gid);
}
}