#![cfg(unix)]
use std::process::Command;
use fstool::block::{BlockDevice, FileBackend};
use fstool::fs::ntfs::attribute::{AttributeIter, AttributeKind, TYPE_VOLUME_NAME, decode_utf16le};
use fstool::fs::ntfs::format::FormatOpts;
use fstool::fs::ntfs::{Ntfs, mft};
use fstool::fs::{FileMeta, FileSource};
use tempfile::NamedTempFile;
fn which(tool: &str) -> Option<std::path::PathBuf> {
let out = Command::new("sh")
.arg("-c")
.arg(format!("command -v {tool}"))
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
let p = s.trim();
if p.is_empty() { None } else { Some(p.into()) }
}
fn build_image_with_tree(volume_bytes: u64) -> NamedTempFile {
let tmp = NamedTempFile::new().unwrap();
let mut dev = FileBackend::create(tmp.path(), volume_bytes).unwrap();
let opts = FormatOpts {
volume_label: "FSTOOL-EXT".to_string(),
..Default::default()
};
let mut ntfs = Ntfs::format(&mut dev, &opts).unwrap();
ntfs.create_file(
&mut dev,
"/hello.txt",
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(b"hello ntfs\n".to_vec())),
len: 11,
},
FileMeta::default(),
)
.unwrap();
ntfs.create_dir(&mut dev, "/sub", FileMeta::default())
.unwrap();
let nested: Vec<u8> = (0..8000).map(|i| (i & 0xFF) as u8).collect();
ntfs.create_file(
&mut dev,
"/sub/big.bin",
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(nested)),
len: 8000,
},
FileMeta::default(),
)
.unwrap();
ntfs.create_symlink(&mut dev, "/link", "hello.txt", FileMeta::default())
.unwrap();
ntfs.flush(&mut dev).unwrap();
dev.sync().unwrap();
drop(dev);
tmp
}
fn writer_image_is_mountable(path: &std::path::Path) -> bool {
let Ok(out) = Command::new("ntfsls").arg("--force").arg(path).output() else {
return false;
};
out.status.success()
}
#[test]
fn writer_passes_ntfsfix_no_action() {
let Some(_) = which("ntfsfix") else {
eprintln!("skipping: ntfsfix not installed");
return;
};
let img = build_image_with_tree(16 * 1024 * 1024);
let out = Command::new("ntfsfix")
.arg("--no-action")
.arg(img.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{stdout}\n{stderr}");
assert!(
!combined.contains("Failed to open $Secure"),
"ntfsfix reports $Secure missing:\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
combined.contains("Mounting volume... OK"),
"ntfsfix did not cleanly mount the writer image:\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
}
#[test]
fn writer_image_ntfs3g_mountable() {
let Some(_) = which("ntfsls") else {
eprintln!("skipping: ntfsls not installed");
return;
};
let img = build_image_with_tree(16 * 1024 * 1024);
let out = Command::new("ntfsls")
.arg("--force")
.arg(img.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"ntfsls (ntfs-3g userspace) failed to walk the writer image:\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
!stderr.contains("Failed to open $Secure"),
"ntfsls reports $Secure missing:\nstderr:\n{stderr}"
);
assert!(
stdout.contains("hello.txt"),
"ntfsls did not list /hello.txt:\n{stdout}"
);
}
#[test]
fn writer_files_visible_via_ntfsls() {
let Some(_) = which("ntfsls") else {
eprintln!("skipping: ntfsls not installed");
return;
};
let img = build_image_with_tree(16 * 1024 * 1024);
if !writer_image_is_mountable(img.path()) {
eprintln!(
"skipping: ntfs-3g cannot mount the writer's image \
(writer doesn't index system files in root $I30 yet)"
);
return;
}
let out = Command::new("ntfsls")
.arg("--force")
.arg(img.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"ntfsls failed:\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stdout.contains("hello.txt"),
"ntfsls missing /hello.txt:\n{stdout}"
);
assert!(stdout.contains("sub"), "ntfsls missing /sub:\n{stdout}");
assert!(stdout.contains("link"), "ntfsls missing /link:\n{stdout}");
}
#[test]
fn writer_files_extractable_via_ntfscat() {
let Some(_) = which("ntfscat") else {
eprintln!("skipping: ntfscat not installed");
return;
};
let Some(_) = which("ntfsls") else {
eprintln!("skipping: ntfsls not installed (needed for mountability probe)");
return;
};
let img = build_image_with_tree(16 * 1024 * 1024);
if !writer_image_is_mountable(img.path()) {
eprintln!(
"skipping: ntfs-3g cannot mount the writer's image \
(writer doesn't index system files in root $I30 yet)"
);
return;
}
let out = Command::new("ntfscat")
.arg("--force")
.arg(img.path())
.arg("/hello.txt")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "ntfscat failed:\nstderr:\n{stderr}");
assert_eq!(
out.stdout, b"hello ntfs\n",
"ntfscat returned wrong bytes for /hello.txt"
);
let out2 = Command::new("ntfscat")
.arg("--force")
.arg(img.path())
.arg("/sub/big.bin")
.output()
.unwrap();
assert!(
out2.status.success(),
"ntfscat /sub/big.bin failed:\n{}",
String::from_utf8_lossy(&out2.stderr)
);
let expected: Vec<u8> = (0..8000).map(|i| (i & 0xFF) as u8).collect();
assert_eq!(
out2.stdout, expected,
"ntfscat returned wrong bytes for /sub/big.bin"
);
}
fn read_volume_label(ntfs: &mut Ntfs, dev: &mut dyn BlockDevice) -> Option<String> {
let rec_size = ntfs.mft_record_size() as usize;
let mut buf = vec![0u8; rec_size];
ntfs.read_mft_record(dev, 3, &mut buf).ok()?;
let hdr = mft::RecordHeader::parse(&buf).ok()?;
for attr_res in AttributeIter::new(&buf, hdr.first_attribute_offset as usize) {
let attr = attr_res.ok()?;
if attr.type_code == TYPE_VOLUME_NAME
&& let AttributeKind::Resident { value, .. } = attr.kind
{
return Some(decode_utf16le(value));
}
}
None
}
#[test]
fn mkntfs_image_opens_and_label_matches() {
let Some(_) = which("mkntfs") else {
eprintln!("skipping: mkntfs not installed");
return;
};
let Some(_) = which("ntfsls") else {
eprintln!("skipping: ntfsls not installed");
return;
};
let tmp = NamedTempFile::new().unwrap();
{
let f = std::fs::OpenOptions::new()
.write(true)
.open(tmp.path())
.unwrap();
f.set_len(16 * 1024 * 1024).unwrap();
}
let label = "TEST-NTFS";
let out = Command::new("mkntfs")
.arg("-f") .arg("-F") .arg("-L")
.arg(label)
.arg(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"mkntfs failed:\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let mut dev = FileBackend::open(tmp.path()).unwrap();
let mut ntfs = Ntfs::open(&mut dev).unwrap();
let got_label = read_volume_label(&mut ntfs, &mut dev)
.expect("could not read $Volume:$VOLUME_NAME from mkntfs image");
assert_eq!(got_label, label, "volume label mismatch");
let root_entries = ntfs.list_path(&mut dev, "/").unwrap();
let mut fstool_names: Vec<String> = root_entries
.iter()
.map(|e| e.name.clone())
.filter(|n| !n.starts_with('$') && n != "." && n != "..")
.collect();
fstool_names.sort();
let ntfsls_out = Command::new("ntfsls")
.arg("--force")
.arg(tmp.path())
.output()
.unwrap();
assert!(
ntfsls_out.status.success(),
"ntfsls failed:\n{}",
String::from_utf8_lossy(&ntfsls_out.stderr)
);
let ntfsls_stdout = String::from_utf8_lossy(&ntfsls_out.stdout);
let mut ntfsls_names: Vec<String> = ntfsls_stdout
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty() && !s.starts_with('$') && s != "." && s != "..")
.collect();
ntfsls_names.sort();
assert_eq!(
fstool_names, ntfsls_names,
"fstool root listing disagrees with ntfsls\nfstool: {fstool_names:?}\nntfsls: {ntfsls_names:?}"
);
}
#[test]
fn reopen_then_add_file_roundtrips_and_validates() {
let img = build_image_with_tree(16 * 1024 * 1024);
let path = img.path().to_path_buf();
{
let mut dev = FileBackend::open(&path).unwrap();
let mut ntfs = Ntfs::open(&mut dev).unwrap();
let body = b"added after reopen\n";
ntfs.create_file(
&mut dev,
"/added.txt",
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(body.to_vec())),
len: body.len() as u64,
},
FileMeta::default(),
)
.unwrap();
ntfs.flush(&mut dev).unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(&path).unwrap();
let mut ntfs = Ntfs::open(&mut dev).unwrap();
let names: Vec<String> = ntfs
.list_path(&mut dev, "/")
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert!(
names.iter().any(|n| n == "added.txt"),
"reopened image missing the file added after reopen: {names:?}"
);
assert!(
names.iter().any(|n| n == "hello.txt"),
"reopen-mutate clobbered the pre-existing tree: {names:?}"
);
let mut got = Vec::new();
{
let mut r = ntfs.open_file_reader(&mut dev, "/added.txt").unwrap();
std::io::Read::read_to_end(&mut r, &mut got).unwrap();
}
assert_eq!(got, b"added after reopen\n");
let mut got2 = Vec::new();
{
let mut r2 = ntfs.open_file_reader(&mut dev, "/hello.txt").unwrap();
std::io::Read::read_to_end(&mut r2, &mut got2).unwrap();
}
assert_eq!(got2, b"hello ntfs\n");
}
if which("ntfsfix").is_some() {
let out = Command::new("ntfsfix")
.arg("--no-action")
.arg(&path)
.output()
.unwrap();
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("Mounting volume... OK"),
"ntfsfix did not cleanly mount the reopen-mutated image:\n{combined}"
);
} else {
eprintln!("skipping ntfsfix oracle: not installed");
}
if which("ntfsls").is_some() {
let out = Command::new("ntfsls")
.arg("--force")
.arg(&path)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"ntfsls failed on reopen-mutated image:\n{stdout}\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
stdout.contains("added.txt"),
"ntfsls did not list the reopen-added file:\n{stdout}"
);
} else {
eprintln!("skipping ntfsls oracle: not installed");
}
}
#[test]
fn writer_device_nodes_round_trip_via_ntfscat() {
if which("ntfsfix").is_none() {
eprintln!("skipping: ntfsfix not installed");
return;
}
if which("ntfscat").is_none() {
eprintln!("skipping: ntfscat not installed");
return;
}
let tmp = NamedTempFile::new().unwrap();
let mut dev = FileBackend::create(tmp.path(), 16 * 1024 * 1024).unwrap();
let opts = FormatOpts {
volume_label: "FSTOOL-DEV".to_string(),
..Default::default()
};
let mut ntfs = Ntfs::format(&mut dev, &opts).unwrap();
use fstool::fs::DeviceKind;
ntfs.create_device(
&mut dev,
"/null",
DeviceKind::Char,
1,
3,
FileMeta::default(),
)
.unwrap();
ntfs.create_device(
&mut dev,
"/loop0",
DeviceKind::Block,
7,
0,
FileMeta::default(),
)
.unwrap();
let err = ntfs
.create_device(
&mut dev,
"/pipe",
DeviceKind::Fifo,
0,
0,
FileMeta::default(),
)
.expect_err("FIFO must reject on NTFS");
assert!(
matches!(err, fstool::Error::Unsupported(_)),
"expected Unsupported, got {err:?}"
);
let err = ntfs
.create_device(
&mut dev,
"/sock",
DeviceKind::Socket,
0,
0,
FileMeta::default(),
)
.expect_err("socket must reject on NTFS");
assert!(matches!(err, fstool::Error::Unsupported(_)));
ntfs.flush(&mut dev).unwrap();
dev.sync().unwrap();
drop(dev);
let out = Command::new("ntfsfix")
.arg("--no-action")
.arg(tmp.path())
.output()
.unwrap();
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("Mounting volume... OK"),
"ntfsfix did not cleanly mount the device-node image:\n{combined}"
);
for (path, want_magic, major, minor) in [
("/null", b"IntxCHR\0", 1u32, 3u32),
("/loop0", b"IntxBLK\0", 7u32, 0u32),
] {
let out = Command::new("ntfscat")
.arg("--force")
.arg(tmp.path())
.arg(path)
.output()
.unwrap();
assert!(
out.status.success(),
"ntfscat failed on {path}:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
out.stdout.len(),
24,
"expected 24-byte INTX_FILE payload for {path}, got {} bytes",
out.stdout.len()
);
assert_eq!(
&out.stdout[..8],
want_magic,
"magic mismatch for {path}: got {:02x?}",
&out.stdout[..8]
);
let got_major = u64::from_le_bytes(out.stdout[8..16].try_into().unwrap());
let got_minor = u64::from_le_bytes(out.stdout[16..24].try_into().unwrap());
assert_eq!(got_major, major as u64, "major mismatch for {path}");
assert_eq!(got_minor, minor as u64, "minor mismatch for {path}");
}
}
#[test]
fn remove_files_and_empty_dir_round_trip() {
let img = build_image_with_tree(16 * 1024 * 1024);
let path = img.path().to_path_buf();
{
let mut dev = FileBackend::open(&path).unwrap();
let mut ntfs = Ntfs::open(&mut dev).unwrap();
ntfs.remove(&mut dev, "/link").unwrap();
ntfs.remove(&mut dev, "/sub/big.bin").unwrap();
ntfs.remove(&mut dev, "/sub").unwrap();
ntfs.remove(&mut dev, "/hello.txt").unwrap();
ntfs.flush(&mut dev).unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(&path).unwrap();
let mut ntfs = Ntfs::open(&mut dev).unwrap();
let names: Vec<String> = ntfs
.list_path(&mut dev, "/")
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
for gone in ["link", "sub", "hello.txt"] {
assert!(
!names.iter().any(|n| n == gone),
"expected {gone:?} to be gone, got {names:?}"
);
}
for gone in ["/link", "/sub", "/sub/big.bin", "/hello.txt"] {
assert!(
ntfs.lookup_path(&mut dev, gone).is_err(),
"lookup_path on removed {gone:?} unexpectedly succeeded"
);
}
}
if which("ntfsfix").is_some() {
let out = Command::new("ntfsfix")
.arg("--no-action")
.arg(&path)
.output()
.unwrap();
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("Mounting volume... OK"),
"ntfsfix did not cleanly mount the post-remove image:\n{combined}"
);
} else {
eprintln!("skipping ntfsfix oracle: not installed");
}
if which("ntfsls").is_some() {
let out = Command::new("ntfsls")
.arg("--force")
.arg(&path)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"ntfsls failed after removes:\n{stdout}\n{}",
String::from_utf8_lossy(&out.stderr)
);
for gone in ["link", "sub", "hello.txt"] {
for line in stdout.lines() {
assert!(
line.trim() != gone,
"ntfsls still lists removed name {gone:?}:\n{stdout}"
);
}
}
} else {
eprintln!("skipping ntfsls oracle: not installed");
}
}
#[test]
fn remove_from_promoted_index_passes_ntfsfix() {
let tmp = NamedTempFile::new().unwrap();
let mut dev = FileBackend::create(tmp.path(), 16 * 1024 * 1024).unwrap();
let opts = FormatOpts {
volume_label: "FSTOOL-REM-LG".to_string(),
..Default::default()
};
let mut ntfs = Ntfs::format(&mut dev, &opts).unwrap();
ntfs.create_dir(&mut dev, "/many", FileMeta::default())
.unwrap();
let mut planted: Vec<String> = Vec::new();
for i in 0..16 {
let name = format!("/many/entry-with-a-longish-name-{i:03}.txt");
ntfs.create_file(
&mut dev,
&name,
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(format!("body-{i}\n").into_bytes())),
len: format!("body-{i}\n").len() as u64,
},
FileMeta::default(),
)
.unwrap();
planted.push(name);
}
ntfs.flush(&mut dev).unwrap();
dev.sync().unwrap();
drop(dev);
let victim_idx = planted.len() / 2;
let victim = &planted[victim_idx];
{
let mut dev = FileBackend::open(tmp.path()).unwrap();
let mut ntfs = Ntfs::open(&mut dev).unwrap();
ntfs.remove(&mut dev, victim).unwrap();
ntfs.flush(&mut dev).unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(tmp.path()).unwrap();
let mut ntfs = Ntfs::open(&mut dev).unwrap();
assert!(
ntfs.lookup_path(&mut dev, victim).is_err(),
"victim still resolves after promoted-index remove"
);
let kids: Vec<String> = ntfs
.list_path(&mut dev, "/many")
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert_eq!(kids.len(), planted.len() - 1, "kids: {kids:?}");
for (i, name) in planted.iter().enumerate() {
let leaf = name.rsplit('/').next().unwrap();
if i == victim_idx {
assert!(!kids.iter().any(|k| k == leaf), "victim survived: {kids:?}");
} else {
assert!(kids.iter().any(|k| k == leaf), "sibling lost: {kids:?}");
}
}
}
if which("ntfsfix").is_some() {
let out = Command::new("ntfsfix")
.arg("--no-action")
.arg(tmp.path())
.output()
.unwrap();
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("Mounting volume... OK"),
"ntfsfix not clean after promoted-index remove:\n{combined}"
);
} else {
eprintln!("skipping ntfsfix oracle: not installed");
}
}
#[test]
fn remove_negative_cases_reject_cleanly() {
let img = build_image_with_tree(16 * 1024 * 1024);
let mut dev = FileBackend::open(img.path()).unwrap();
let mut ntfs = Ntfs::open(&mut dev).unwrap();
let err = ntfs
.remove(&mut dev, "/")
.expect_err("remove of root must reject");
assert!(matches!(err, fstool::Error::InvalidArgument(_)));
assert!(ntfs.remove(&mut dev, "/no-such-file").is_err());
let err = ntfs
.remove(&mut dev, "/sub")
.expect_err("remove of non-empty dir must reject");
assert!(
matches!(err, fstool::Error::InvalidArgument(_)),
"expected InvalidArgument for non-empty dir, got: {err:?}"
);
ntfs.flush(&mut dev).unwrap();
dev.sync().unwrap();
drop(dev);
if which("ntfsfix").is_some() {
let out = Command::new("ntfsfix")
.arg("--no-action")
.arg(img.path())
.output()
.unwrap();
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("Mounting volume... OK"),
"rejected removes corrupted the image:\n{combined}"
);
}
}
#[test]
fn large_directory_scales_and_mounts_clean() {
if which("ntfsfix").is_none() {
eprintln!("skipping: ntfsfix not installed");
return;
}
const N: usize = 4000;
let tmp = NamedTempFile::new().unwrap();
let mut dev = FileBackend::create(tmp.path(), 64 * 1024 * 1024).unwrap();
let opts = FormatOpts {
volume_label: "FSTOOL-BIG".to_string(),
..Default::default()
};
let mut ntfs = Ntfs::format(&mut dev, &opts).unwrap();
ntfs.create_dir(&mut dev, "/big", FileMeta::default())
.unwrap();
for i in 0..N {
let name = format!("/big/file{i:05}");
let body = b"x".to_vec();
ntfs.create_file(
&mut dev,
&name,
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(body)),
len: 1,
},
FileMeta::default(),
)
.unwrap();
}
ntfs.flush(&mut dev).unwrap();
dev.sync().unwrap();
drop(dev);
let out = Command::new("ntfsfix")
.arg("--no-action")
.arg(tmp.path())
.output()
.unwrap();
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("Mounting volume... OK"),
"ntfsfix could not mount the {N}-file volume:\n{combined}"
);
assert!(
!combined.contains("mapping pairs"),
"ntfsfix hit a mapping-pairs error (run-length regression):\n{combined}"
);
if which("ntfsls").is_some() {
let out = Command::new("ntfsls")
.arg("--force")
.arg("-p")
.arg("/big")
.arg(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"ntfsls failed on the large directory:\n{stdout}\n{}",
String::from_utf8_lossy(&out.stderr)
);
let listed = stdout.lines().filter(|l| l.contains("file")).count();
assert_eq!(listed, N, "ntfsls listed {listed} of {N} files in /big");
}
}