#![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("Mounting volume")
|| combined.contains("Processing $MFT")
|| combined.contains("Reading $MFT"),
"ntfsfix produced no recognisable diagnostic output:\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
}
#[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 {
if 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:?}"
);
}