Skip to content

Commit 9e72876

Browse files
authored
test(e2e): reproduce constrained dev shm failure (#522)
## Motivation Reproduce issue #353 reliably before changing the Linux shared-memory backend. ## Changes - Adds `vtt small_dev_shm`, which mounts a one-page tmpfs at `/dev/shm` inside private user and mount namespaces. - Adds `vtt stat_long_filename` and calls `std::fs::metadata` with a 1 MiB path. - Intercepts glibc `statx` so the metadata call is recorded through the preload library. - Adds a Linux glibc E2E fixture that records the current `SIGBUS` failure as exit code 135.
1 parent 53345ce commit 9e72876

13 files changed

Lines changed: 254 additions & 19 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fspy/tests/rust_std.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ mod test_utils;
22

33
use std::{
44
env::current_dir,
5-
fs::{File, OpenOptions},
5+
fs::{self, File, OpenOptions},
66
process::Stdio,
77
};
88

@@ -35,6 +35,22 @@ async fn open_write() -> anyhow::Result<()> {
3535
Ok(())
3636
}
3737

38+
#[test(tokio::test)]
39+
async fn metadata() -> anyhow::Result<()> {
40+
let tmp_dir = tempfile::tempdir()?;
41+
let tmp_path = tmp_dir.path().join("hello");
42+
File::create(&tmp_path)?;
43+
let tmp_path_str = tmp_path.to_str().unwrap().to_owned();
44+
45+
let accesses = track_fn!(tmp_path_str, |tmp_path_str: String| {
46+
let _ = fs::metadata(tmp_path_str);
47+
})
48+
.await?;
49+
assert_contains(&accesses, tmp_path.as_path(), AccessMode::READ);
50+
51+
Ok(())
52+
}
53+
3854
#[test(tokio::test)]
3955
async fn readdir() -> anyhow::Result<()> {
4056
let tmpdir = tempfile::tempdir()?;

crates/fspy_preload_unix/src/interceptions/linux_syscall.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ use fspy_shared::ipc::AccessMode;
22
use libc::{c_char, c_int, c_long};
33

44
use crate::{
5-
client::{convert::PathAt, handle_open},
5+
client::{
6+
convert::{Fd, PathAt},
7+
handle_open,
8+
},
69
macros::intercept,
710
};
811

@@ -23,16 +26,26 @@ unsafe extern "C" fn syscall(syscall_no: c_long, mut args: ...) -> c_long {
2326
let a5 = unsafe { args.next_arg::<c_long>() };
2427

2528
if syscall_no == libc::SYS_statx {
26-
// c-style conversion is expected: (4294967196 -> -100 aka libc::AT_FDCWD)
29+
// C-style conversions are expected for the variadic syscall arguments.
2730
#[expect(
2831
clippy::cast_possible_truncation,
29-
reason = "c-style conversion is expected: (4294967196 -> -100 aka libc::AT_FDCWD)"
32+
reason = "C-style conversion from c_long syscall arguments to c_int"
3033
)]
3134
let dirfd = a0 as c_int;
3235
let pathname = a1 as *const c_char;
33-
// SAFETY: pathname is a valid pointer to a null-terminated C string provided via the syscall arguments
34-
unsafe {
35-
handle_open(PathAt(dirfd, pathname), AccessMode::READ);
36+
#[expect(
37+
clippy::cast_possible_truncation,
38+
reason = "C-style conversion from c_long syscall arguments to c_int"
39+
)]
40+
let flags = a2 as c_int;
41+
if pathname.is_null() {
42+
if flags & libc::AT_EMPTY_PATH != 0 {
43+
// SAFETY: dirfd is provided by the statx syscall caller.
44+
unsafe { handle_open(Fd(dirfd), AccessMode::READ) };
45+
}
46+
} else {
47+
// SAFETY: pathname is a non-null C string pointer provided by the statx syscall caller.
48+
unsafe { handle_open(PathAt(dirfd, pathname), AccessMode::READ) };
3649
}
3750
}
3851
// SAFETY: forwarding the syscall to the original libc syscall function with the extracted arguments

crates/fspy_preload_unix/src/interceptions/stat.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use fspy_shared::ipc::AccessMode;
22
use libc::{c_char, c_int, stat as stat_struct};
33

4+
#[cfg(target_os = "linux")]
5+
use crate::client::convert::Fd;
46
use crate::{
57
client::{convert::PathAt, handle_open},
68
macros::intercept,
@@ -41,3 +43,40 @@ unsafe extern "C" fn fstatat(
4143
// SAFETY: calling the original libc fstatat() with the same arguments forwarded from the interposed function
4244
unsafe { fstatat::original()(dirfd, pathname, buf, flags) }
4345
}
46+
47+
#[cfg(target_os = "linux")]
48+
intercept!(statx: unsafe extern "C" fn(
49+
dirfd: c_int,
50+
pathname: *const c_char,
51+
flags: c_int,
52+
mask: libc::c_uint,
53+
statxbuf: *mut libc::statx,
54+
) -> c_int);
55+
#[cfg(target_os = "linux")]
56+
unsafe extern "C" fn statx(
57+
dirfd: c_int,
58+
pathname: *const c_char,
59+
flags: c_int,
60+
mask: libc::c_uint,
61+
statxbuf: *mut libc::statx,
62+
) -> c_int {
63+
let Some(original) = statx::try_original() else {
64+
// Rust's standard library interprets ENOSYS from its statx availability
65+
// probe as unsupported and falls back to stat64.
66+
// SAFETY: __errno_location returns the calling thread's errno storage on Linux.
67+
unsafe { *libc::__errno_location() = libc::ENOSYS };
68+
return -1;
69+
};
70+
71+
if pathname.is_null() {
72+
if flags & libc::AT_EMPTY_PATH != 0 {
73+
// SAFETY: dirfd is provided by the statx caller.
74+
unsafe { handle_open(Fd(dirfd), AccessMode::READ) };
75+
}
76+
} else {
77+
// SAFETY: pathname is a non-null C string pointer provided by the statx caller.
78+
unsafe { handle_open(PathAt(dirfd, pathname), AccessMode::READ) };
79+
}
80+
// SAFETY: calling the original libc statx() with the same arguments forwarded from the interposed function
81+
unsafe { original(dirfd, pathname, flags, mask, statxbuf) }
82+
}

crates/fspy_preload_unix/src/macros/linux.rs

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ macro_rules! intercept {
2828
#[cfg(test)]
2929
#[test]
3030
fn symbol_64_does_not_exist() {
31-
::core::assert_eq!($crate::macros::symbol_exists(::core::stringify!($name)), false);
31+
::core::assert_eq!(
32+
$crate::macros::symbol_exists(::core::concat!(::core::stringify!($name), 64)),
33+
false,
34+
);
3235
}
3336
}
3437
};
@@ -47,7 +50,7 @@ pub fn symbol_exists(name: &str) -> bool {
4750
}
4851

4952
macro_rules! intercept_inner {
50-
($name: ident: $fn_sig: ty; $test_fn: item ) => {
53+
($name: ident: $fn_sig: ty; $test_fn: item) => {
5154
const _: $fn_sig = $name;
5255
const _: $fn_sig = $crate::libc::$name;
5356

@@ -66,17 +69,44 @@ macro_rules! intercept_inner {
6669
#[expect(clippy::allow_attributes, reason = "using allow because unused_imports may or may not fire depending on macro expansion")]
6770
#[allow(unused_imports, reason = "glob import brings types into scope for macro-generated code")]
6871
use super::*;
72+
#[expect(
73+
clippy::allow_attributes,
74+
reason = "using allow because dead_code only fires for optional original symbols"
75+
)]
76+
#[allow(
77+
dead_code,
78+
reason = "not every interposer forwards to its generated original function"
79+
)]
6980
pub unsafe fn original() -> $fn_sig {
70-
static LAZY: std::sync::LazyLock<$fn_sig> = std::sync::LazyLock::new(||
71-
// SAFETY: dlsym with RTLD_NEXT returns the next symbol in the dynamic linking order,
72-
// and transmute converts the resulting function pointer to the expected function signature.
73-
// The caller guarantees the symbol name matches the expected function signature via the macro invocation.
74-
unsafe {
75-
::core::mem::transmute(::libc::dlsym(
76-
::libc::RTLD_NEXT,
77-
::core::concat!(::core::stringify!($name), "\0").as_ptr().cast(),
81+
try_original().unwrap_or_else(|| {
82+
panic!(::core::concat!(
83+
"original symbol not found: ",
84+
::core::stringify!($name)
7885
))
79-
});
86+
})
87+
}
88+
pub fn try_original() -> ::core::option::Option<$fn_sig> {
89+
static LAZY: std::sync::LazyLock<::core::option::Option<$fn_sig>> =
90+
std::sync::LazyLock::new(|| {
91+
// SAFETY: dlsym with RTLD_NEXT returns the next symbol in the dynamic
92+
// linking order. A non-null pointer has the signature checked by the
93+
// macro invocation.
94+
let symbol = unsafe {
95+
::libc::dlsym(
96+
::libc::RTLD_NEXT,
97+
::core::concat!(::core::stringify!($name), "\0").as_ptr().cast(),
98+
)
99+
};
100+
if symbol.is_null() {
101+
::core::option::Option::None
102+
} else {
103+
// SAFETY: the symbol name and function signature are paired by the
104+
// macro invocation, and null was checked above.
105+
::core::option::Option::Some(unsafe {
106+
::core::mem::transmute::<*mut ::libc::c_void, $fn_sig>(symbol)
107+
})
108+
}
109+
});
80110
*LAZY
81111
}
82112
$test_fn

crates/vite_task_bin/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ vite_str = { workspace = true }
3131
vite_task = { workspace = true }
3232
which = { workspace = true }
3333

34+
[target.'cfg(target_os = "linux")'.dependencies]
35+
nix = { workspace = true, features = ["mount", "sched", "user"] }
36+
3437
[dev-dependencies]
3538
cow-utils = { workspace = true }
3639
cp_r = { workspace = true }

crates/vite_task_bin/src/vtt/main.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ mod print_file;
2323
mod read_stdin;
2424
mod replace_file_content;
2525
mod rm;
26+
#[cfg(target_os = "linux")]
27+
mod small_dev_shm;
2628
mod stat_file;
29+
mod stat_long_filename;
2730
mod touch_file;
2831
mod write_file;
2932

@@ -32,7 +35,7 @@ fn main() {
3235
if args.len() < 2 {
3336
eprintln!("Usage: vtt <subcommand> [args...]");
3437
eprintln!(
35-
"Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, stat-file, touch-file, write-file"
38+
"Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stat-file, stat_long_filename, touch-file, write-file"
3639
);
3740
std::process::exit(1);
3841
}
@@ -64,10 +67,15 @@ fn main() {
6467
"read-stdin" => read_stdin::run(),
6568
"replace-file-content" => replace_file_content::run(&args[2..]),
6669
"rm" => rm::run(&args[2..]),
70+
#[cfg(target_os = "linux")]
71+
"small_dev_shm" => small_dev_shm::run(&args[2..]).map_err(Into::into),
72+
#[cfg(not(target_os = "linux"))]
73+
"small_dev_shm" => Err("vtt small_dev_shm is only supported on Linux".into()),
6774
"stat-file" => {
6875
stat_file::run(&args[2..]);
6976
Ok(())
7077
}
78+
"stat_long_filename" => stat_long_filename::run(&args[2..]),
7179
"touch-file" => touch_file::run(&args[2..]),
7280
"write-file" => write_file::run(&args[2..]),
7381
other => {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#![cfg(target_os = "linux")]
2+
3+
use std::{os::unix::process::ExitStatusExt as _, process::Command};
4+
5+
use anyhow::{Context as _, Result};
6+
use nix::{
7+
mount::{MsFlags, mount},
8+
sched::{CloneFlags, unshare},
9+
unistd::{Gid, Uid},
10+
};
11+
12+
const USAGE: &str = "Usage: vtt small_dev_shm <command> [args...]";
13+
14+
pub fn run(args: &[String]) -> Result<()> {
15+
let (program, command_args) = parse_command(args)?;
16+
run_platform(program, command_args)
17+
}
18+
19+
fn parse_command(args: &[String]) -> Result<(&str, &[String])> {
20+
args.split_first().map(|(program, args)| (program.as_str(), args)).context(USAGE)
21+
}
22+
23+
fn run_platform(program: &str, command_args: &[String]) -> Result<()> {
24+
let uid = Uid::current().as_raw();
25+
let gid = Gid::current().as_raw();
26+
27+
unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS)
28+
.context("unshare user and mount namespaces")?;
29+
30+
std::fs::write("/proc/self/uid_map", format!("0 {uid} 1\n"))
31+
.context("write /proc/self/uid_map")?;
32+
match std::fs::write("/proc/self/setgroups", "deny") {
33+
Ok(()) => {}
34+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
35+
Err(error) => return Err(error).context("write /proc/self/setgroups"),
36+
}
37+
std::fs::write("/proc/self/gid_map", format!("0 {gid} 1\n"))
38+
.context("write /proc/self/gid_map")?;
39+
40+
mount(None::<&str>, "/", None::<&str>, MsFlags::MS_REC | MsFlags::MS_PRIVATE, None::<&str>)
41+
.context("make / recursively private")?;
42+
43+
mount(
44+
Some("tmpfs"),
45+
"/dev/shm",
46+
Some("tmpfs"),
47+
MsFlags::empty(),
48+
Some("nr_blocks=1,huge=never"),
49+
)
50+
.context("mount one-page tmpfs at /dev/shm")?;
51+
52+
let status = Command::new(program)
53+
.args(command_args)
54+
.status()
55+
.context("run command with constrained /dev/shm")?;
56+
let code = status.code().unwrap_or_else(|| status.signal().map_or(1, |signal| 128 + signal));
57+
std::process::exit(code);
58+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
use std::{error::Error, io};
2+
3+
const USAGE: &str = "Usage: vtt stat_long_filename <count>";
4+
5+
pub fn run(args: &[String]) -> Result<(), Box<dyn Error>> {
6+
let count = parse_count(args)?;
7+
access_generated_path(count, metadata)?;
8+
Ok(())
9+
}
10+
11+
fn parse_count(args: &[String]) -> Result<usize, String> {
12+
let [count] = args else { return Err(USAGE.to_owned()) };
13+
count.parse().map_err(|_| USAGE.to_owned())
14+
}
15+
16+
fn generated_path(count: usize) -> String {
17+
"x".repeat(count)
18+
}
19+
20+
fn access_generated_path(
21+
count: usize,
22+
mut metadata: impl FnMut(&str) -> io::Result<()>,
23+
) -> io::Result<()> {
24+
let path = generated_path(count);
25+
match metadata(&path) {
26+
Ok(()) => Ok(()),
27+
Err(error)
28+
if error.kind() == io::ErrorKind::NotFound
29+
|| error.raw_os_error() == Some(libc::ENAMETOOLONG) =>
30+
{
31+
Ok(())
32+
}
33+
Err(error) => Err(error),
34+
}
35+
}
36+
37+
fn metadata(path: &str) -> io::Result<()> {
38+
std::fs::metadata(path).map(|_| ())
39+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

0 commit comments

Comments
 (0)