Skip to content

Commit d81a960

Browse files
committed
refactor(test): replace unshare with fuse2fs for cross-device test
Replace the user-namespace-based (`unshare --user --mount`) cross-device test with a FUSE-based approach using `fuse2fs`. User namespaces are often disabled in CI containers, making the unshare test unreliable. The new test creates a small ext2 filesystem image, mounts it via `fuse2fs` (which only needs `/dev/fuse` and `fusermount`), and verifies that `pdu -x` correctly excludes entries on the mounted filesystem. Diagnostic messages clearly report which FUSE component is missing. The `--cfg pdu_test_skip_cross_device` skip flag is preserved. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6
1 parent 51af07b commit d81a960

1 file changed

Lines changed: 163 additions & 124 deletions

File tree

tests/one_file_system.rs

Lines changed: 163 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
//! [`same_device_on_sample_workspace`] verifies that enabling `--one-file-system` on a
66
//! single-device workspace produces the same tree as without it.
77
//!
8-
//! ## Integration test via `unshare`
8+
//! ## Integration test via FUSE
99
//!
10-
//! [`cross_device_excludes_mount`] uses `unshare --user --mount --map-root-user` to create
11-
//! a tmpfs mount inside a user namespace (no root required) and checks that `-x` correctly
12-
//! excludes entries on the mounted filesystem.
10+
//! [`cross_device_excludes_mount`] uses `fuse2fs` to mount an ext2 filesystem image via FUSE
11+
//! (no root or user namespaces required) and checks that `-x` correctly excludes entries on
12+
//! the mounted filesystem.
1313
//!
14-
//! The `unshare` test panics when user namespaces are unavailable.
14+
//! The FUSE test panics when `fuse2fs`, `/dev/fuse`, or `fusermount` are unavailable.
1515
//! It can be excluded via `RUSTFLAGS='--cfg pdu_test_skip_cross_device'`.
1616
1717
#![cfg(unix)]
@@ -63,158 +63,197 @@ fn same_device_on_sample_workspace() {
6363
);
6464
}
6565

66-
/// Checks that `unshare --user --mount --map-root-user` is available and allows
67-
/// mounting a tmpfs inside the created namespace.
66+
/// Checks that `fuse2fs` and FUSE infrastructure are available.
67+
///
68+
/// Verifies:
69+
/// 1. `fuse2fs` binary exists
70+
/// 2. `/dev/fuse` is accessible
71+
/// 3. `fusermount` (or `fusermount3`) binary exists
6872
///
6973
/// Returns `Ok(())` on success, or `Err` with a diagnostic message on failure.
7074
#[cfg(target_os = "linux")]
7175
#[cfg(not(pdu_test_skip_cross_device))]
72-
fn unshare_available() -> Result<(), String> {
73-
use command_extra::CommandExtra;
74-
use std::process::Command;
75-
let output = Command::new("unshare")
76-
.with_args([
77-
"--user",
78-
"--mount",
79-
"--map-root-user",
80-
"sh",
81-
"-c",
82-
"mountpoint=$(mktemp -d) && mount -t tmpfs tmpfs \"$mountpoint\" && umount \"$mountpoint\"",
83-
])
76+
fn fuse_available() -> Result<(), String> {
77+
use std::{path::Path, process::Command};
78+
79+
// Check that fuse2fs is installed
80+
Command::new("fuse2fs")
81+
.arg("--help")
8482
.output()
85-
.map_err(|error| format!("failed to execute unshare: {error}"))?;
86-
if output.status.success() {
87-
Ok(())
88-
} else {
89-
let stderr = String::from_utf8_lossy(&output.stderr);
90-
Err(format!(
91-
"unshare probe exited with {}: {}",
92-
output.status,
93-
stderr.trim(),
94-
))
83+
.map_err(|error| format!("`fuse2fs` not found: {error}. Install e2fsprogs or fuse2fs."))?;
84+
85+
// Check that /dev/fuse is accessible
86+
if !Path::new("/dev/fuse").exists() {
87+
return Err(
88+
"/dev/fuse does not exist. The FUSE kernel module may not be loaded. \
89+
Try `modprobe fuse`."
90+
.to_string(),
91+
);
9592
}
93+
94+
// Check that fusermount is available (needed for unmounting)
95+
let has_fusermount = Command::new("fusermount").arg("-V").output().is_ok();
96+
let has_fusermount3 = Command::new("fusermount3").arg("-V").output().is_ok();
97+
if !has_fusermount && !has_fusermount3 {
98+
return Err(
99+
"Neither `fusermount` nor `fusermount3` found. Install fuse or fuse3.".to_string(),
100+
);
101+
}
102+
103+
Ok(())
96104
}
97105

98106
/// When a subdirectory is a mount point for a different filesystem, `-x` should exclude it.
99107
///
100-
/// Uses `unshare --user --mount --map-root-user` to avoid requiring root privileges.
101-
/// Skipped when user namespaces are unavailable.
108+
/// Uses `fuse2fs` to mount an ext2 filesystem image via FUSE — no root privileges or
109+
/// user namespaces required.
110+
/// Skipped when FUSE infrastructure is unavailable.
102111
#[test]
103112
#[cfg(target_os = "linux")]
104113
#[cfg(not(pdu_test_skip_cross_device))]
105114
fn cross_device_excludes_mount() {
106115
use command_extra::CommandExtra;
107116
use std::{
108-
fmt::Write,
117+
fs,
109118
process::{Command, Stdio},
119+
thread,
120+
time::Duration,
110121
};
111122

112-
if let Err(reason) = unshare_available() {
123+
if let Err(reason) = fuse_available() {
113124
panic!(
114-
"error: This test requires `unshare --user --mount --map-root-user` but the probe failed.\n\
125+
"error: This test requires FUSE (`fuse2fs`, `/dev/fuse`, `fusermount`) but the probe failed.\n\
115126
reason: {reason}\n\
116-
hint: Either enable user namespaces or set `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.",
127+
hint: Install e2fsprogs and fuse, or set `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.",
117128
);
118129
}
119130

120131
let pdu = env!("CARGO_BIN_EXE_pdu");
132+
let temp = Temp::new_dir().expect("create temp dir for cross-device test");
133+
let workspace = temp.join("workspace");
134+
let mount_point = workspace.join("mounted");
135+
let image_path = temp.join("ext2.img");
136+
137+
fs::create_dir_all(&mount_point).expect("create workspace and mount point");
138+
139+
// Write a file on the root filesystem
121140
let outside_content = "A".repeat(1000);
122-
let inside_content = "B".repeat(2000);
141+
fs::write(workspace.join("outside.txt"), &outside_content).expect("write outside.txt");
123142

124-
// Build a shell script that creates a tmpfs mount inside a user namespace,
125-
// writes files on both filesystems, and runs pdu with and without -x.
126-
let mut script = String::new();
127-
writeln!(script, "TMPDIR=$(mktemp -d)").unwrap();
128-
writeln!(script, "mkdir -p \"$TMPDIR/mounted\"").unwrap();
129-
writeln!(script, "mount -t tmpfs tmpfs \"$TMPDIR/mounted\"").unwrap();
130-
writeln!(
131-
script,
132-
"printf '%s' '{outside_content}' > \"$TMPDIR/outside.txt\""
133-
)
134-
.unwrap();
135-
writeln!(
136-
script,
137-
"printf '%s' '{inside_content}' > \"$TMPDIR/mounted/inside.txt\""
138-
)
139-
.unwrap();
140-
// Write each pdu invocation's output to a separate file so we don't need
141-
// to parse markers from a combined stdout.
142-
writeln!(script, "WITHOUT_X=$(mktemp)").unwrap();
143-
writeln!(script, "WITH_X=$(mktemp)").unwrap();
144-
writeln!(
145-
script,
146-
"\"{pdu}\" --bytes-format=plain \"$TMPDIR\" >\"$WITHOUT_X\" 2>&1"
147-
)
148-
.unwrap();
149-
writeln!(
150-
script,
151-
"\"{pdu}\" --bytes-format=plain -x \"$TMPDIR\" >\"$WITH_X\" 2>&1"
152-
)
153-
.unwrap();
154-
writeln!(script, "umount \"$TMPDIR/mounted\"").unwrap();
155-
writeln!(script, "rm -rf \"$TMPDIR\"").unwrap();
156-
writeln!(script, "printf 'WITHOUT_X\\0'").unwrap();
157-
writeln!(script, "cat \"$WITHOUT_X\"").unwrap();
158-
writeln!(script, "printf '\\0WITH_X\\0'").unwrap();
159-
writeln!(script, "cat \"$WITH_X\"").unwrap();
160-
writeln!(script, "printf '\\0'").unwrap();
161-
writeln!(script, "rm -f \"$WITHOUT_X\" \"$WITH_X\"").unwrap();
162-
163-
let output = Command::new("unshare")
164-
.with_args([
165-
"--user",
166-
"--mount",
167-
"--map-root-user",
168-
"bash",
169-
"-c",
170-
&script,
171-
])
143+
// Create a small ext2 filesystem image (4 MiB)
144+
let mkfs_output = Command::new("mkfs.ext2")
145+
.with_args(["-F", "-q"])
146+
.with_arg(&image_path)
147+
.with_arg("4096") // 4096 × 1K blocks = 4 MiB
172148
.with_stdout(Stdio::piped())
173149
.with_stderr(Stdio::piped())
174150
.output()
175-
.expect("run unshare");
176-
177-
let stderr = String::from_utf8_lossy(&output.stderr);
178-
if !stderr.is_empty() {
179-
eprintln!("STDERR:\n{stderr}");
180-
}
181-
assert!(output.status.success(), "unshare command failed");
182-
183-
let stdout = String::from_utf8_lossy(&output.stdout);
184-
eprintln!("STDOUT:\n{stdout}");
185-
186-
let find_section = |label: &str| -> &str {
187-
let label_start = stdout
188-
.find(label)
189-
.unwrap_or_else(|| panic!("missing {label} section in output:\n{stdout}"));
190-
let content_start = label_start + label.len() + 1; // skip label + NUL
191-
let content_end = stdout[content_start..]
192-
.find('\0')
193-
.map(|pos| content_start + pos)
194-
.unwrap_or(stdout.len());
195-
stdout[content_start..content_end].trim()
196-
};
197-
198-
let without_x = find_section("WITHOUT_X");
199-
let with_x = find_section("WITH_X");
200-
201-
// Without -x: should contain both "inside.txt" and "outside.txt"
202-
assert!(
203-
without_x.contains("inside.txt"),
204-
"without -x should show inside.txt:\n{without_x}",
205-
);
151+
.expect("run mkfs.ext2");
206152
assert!(
207-
without_x.contains("outside.txt"),
208-
"without -x should show outside.txt:\n{without_x}",
153+
mkfs_output.status.success(),
154+
"mkfs.ext2 failed: {}",
155+
String::from_utf8_lossy(&mkfs_output.stderr),
209156
);
210157

211-
// With -x: should contain "outside.txt" but NOT "inside.txt"
212-
assert!(
213-
with_x.contains("outside.txt"),
214-
"with -x should show outside.txt:\n{with_x}",
215-
);
158+
// Mount the image via fuse2fs
159+
let mount_output = Command::new("fuse2fs")
160+
.with_arg(&image_path)
161+
.with_arg(&mount_point)
162+
.with_args(["-o", "rw"])
163+
.with_stdout(Stdio::piped())
164+
.with_stderr(Stdio::piped())
165+
.output()
166+
.expect("run fuse2fs");
216167
assert!(
217-
!with_x.contains("inside.txt"),
218-
"with -x should exclude inside.txt (on different filesystem):\n{with_x}",
168+
mount_output.status.success(),
169+
"fuse2fs mount failed: {}",
170+
String::from_utf8_lossy(&mount_output.stderr),
219171
);
172+
173+
// Small delay to let FUSE settle
174+
thread::sleep(Duration::from_millis(100));
175+
176+
// Write a file on the mounted (different) filesystem
177+
let inside_content = "B".repeat(2000);
178+
let write_result = fs::write(mount_point.join("inside.txt"), &inside_content);
179+
180+
// Ensure we unmount even if assertions fail
181+
let test_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
182+
write_result.expect("write inside.txt on mounted filesystem");
183+
184+
// Run pdu WITHOUT -x — should see both files
185+
let without_x = Command::new(pdu)
186+
.with_args(["--bytes-format=plain"])
187+
.with_arg(&workspace)
188+
.with_stdout(Stdio::piped())
189+
.with_stderr(Stdio::piped())
190+
.output()
191+
.expect("run pdu without -x");
192+
let without_x_stdout = String::from_utf8_lossy(&without_x.stdout);
193+
let without_x_stderr = String::from_utf8_lossy(&without_x.stderr);
194+
if !without_x_stderr.is_empty() {
195+
eprintln!("pdu (no -x) STDERR:\n{without_x_stderr}");
196+
}
197+
eprintln!("pdu (no -x) STDOUT:\n{without_x_stdout}");
198+
assert!(
199+
without_x.status.success(),
200+
"pdu without -x failed: {without_x_stderr}",
201+
);
202+
assert!(
203+
without_x_stdout.contains("inside.txt"),
204+
"without -x should show inside.txt:\n{without_x_stdout}",
205+
);
206+
assert!(
207+
without_x_stdout.contains("outside.txt"),
208+
"without -x should show outside.txt:\n{without_x_stdout}",
209+
);
210+
211+
// Run pdu WITH -x — should only see outside.txt
212+
let with_x = Command::new(pdu)
213+
.with_args(["--bytes-format=plain", "-x"])
214+
.with_arg(&workspace)
215+
.with_stdout(Stdio::piped())
216+
.with_stderr(Stdio::piped())
217+
.output()
218+
.expect("run pdu with -x");
219+
let with_x_stdout = String::from_utf8_lossy(&with_x.stdout);
220+
let with_x_stderr = String::from_utf8_lossy(&with_x.stderr);
221+
if !with_x_stderr.is_empty() {
222+
eprintln!("pdu (-x) STDERR:\n{with_x_stderr}");
223+
}
224+
eprintln!("pdu (-x) STDOUT:\n{with_x_stdout}");
225+
assert!(
226+
with_x.status.success(),
227+
"pdu with -x failed: {with_x_stderr}",
228+
);
229+
assert!(
230+
with_x_stdout.contains("outside.txt"),
231+
"with -x should show outside.txt:\n{with_x_stdout}",
232+
);
233+
assert!(
234+
!with_x_stdout.contains("inside.txt"),
235+
"with -x should exclude inside.txt (on different filesystem):\n{with_x_stdout}",
236+
);
237+
}));
238+
239+
// Always unmount — try fusermount first, fall back to fusermount3
240+
let unmount_status = Command::new("fusermount")
241+
.with_arg("-u")
242+
.with_arg(&mount_point)
243+
.status()
244+
.or_else(|_| {
245+
Command::new("fusermount3")
246+
.with_arg("-u")
247+
.with_arg(&mount_point)
248+
.status()
249+
});
250+
match unmount_status {
251+
Ok(status) if status.success() => {}
252+
Ok(status) => eprintln!("warning: fusermount exited with {status}"),
253+
Err(error) => eprintln!("warning: failed to run fusermount: {error}"),
254+
}
255+
256+
if let Err(payload) = test_result {
257+
std::panic::resume_unwind(payload);
258+
}
220259
}

0 commit comments

Comments
 (0)