pub mod iso9660;
pub mod tree;
pub mod udf;
pub mod image_io;
#[cfg(feature = "simd")]
pub mod simd;
pub mod formats;
pub use tree::TreeNode;
use std::fs::create_dir_all;
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
pub type Error = Box<dyn std::error::Error + Send + Sync + 'static>;
pub type Result<T> = std::result::Result<T, Error>;
pub fn detect_and_parse_filesystem<R: Read + Seek>(
file: &mut R,
filename: &str,
) -> Result<TreeNode> {
detect_and_parse_filesystem_verbose(file, filename, false)
}
pub fn detect_and_parse_filesystem_verbose<R: Read + Seek>(
file: &mut R,
filename: &str,
verbose: bool,
) -> Result<TreeNode> {
let mut errors = Vec::new();
if verbose {
let file_size = file.seek(SeekFrom::End(0))?;
file.seek(SeekFrom::Start(0))?;
eprintln!(
"File size: {} bytes ({:.2} GB)",
file_size,
file_size as f64 / (1024.0 * 1024.0 * 1024.0)
);
eprintln!("Scanning key sectors for filesystem signatures...");
for (sector, desc) in [
(16, "ISO 9660 PVD / UDF VRS"),
(17, "UDF VRS"),
(256, "UDF AVDP"),
]
.iter()
{
file.seek(SeekFrom::Start(*sector as u64 * 2048))?;
let mut buf = [0u8; 32];
if file.read_exact(&mut buf).is_ok() {
let printable: String = buf
.iter()
.map(|&b| {
if (0x20..0x7f).contains(&b) {
b as char
} else {
'.'
}
})
.collect();
eprintln!(" Sector {:>3} ({}): {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} |{}|",
sector, desc, buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7], &printable[..8]);
}
}
file.seek(SeekFrom::Start(0))?;
}
if verbose {
eprintln!("\nAttempting ISO 9660 parsing...");
}
match iso9660::parse_iso9660_verbose(file, verbose) {
Ok(root) => return Ok(root),
Err(e) => {
if verbose {
eprintln!(" ISO 9660 parsing failed: {}", e);
}
errors.push(format!("ISO 9660: {}", e));
}
}
file.seek(SeekFrom::Start(0))?;
if verbose {
eprintln!("\nAttempting UDF parsing...");
}
match udf::parse_udf_verbose(file, verbose) {
Ok(root) => return Ok(root),
Err(e) => {
if verbose {
eprintln!(" UDF parsing failed: {}", e);
}
errors.push(format!("UDF: {}", e));
}
}
let mut msg = format!("Unable to detect supported filesystem in {}", filename);
if !errors.is_empty() {
msg.push_str("\nDetails:\n - ");
msg.push_str(&errors.join("\n - "));
}
Err(msg.into())
}
pub fn cat_node<R: Read + Seek, W: Write>(
file: &mut R,
node: &TreeNode,
writer: &mut W,
) -> Result<()> {
if node.is_directory {
return Err(format!("'{}' is a directory, not a file", node.name).into());
}
let (location, length) = match (node.file_location, node.file_length) {
(Some(l), Some(n)) => (l, n),
_ => return Err("File location information not available".into()),
};
file.seek(SeekFrom::Start(location))?;
let mut remaining: u64 = length;
let buf_cap = remaining.min(EXTRACT_CHUNK_SIZE as u64) as usize;
let mut buffer = vec![0u8; buf_cap];
while remaining > 0 {
let to_read = remaining.min(EXTRACT_CHUNK_SIZE as u64) as usize;
let buf = &mut buffer[..to_read];
file.read_exact(buf)?;
match writer.write_all(buf) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => return Ok(()),
Err(e) => return Err(e.into()),
}
remaining -= to_read as u64;
}
Ok(())
}
pub fn extract_node<R: Read + Seek>(
file: &mut R,
node: &TreeNode,
output_path: &str,
) -> Result<()> {
create_dir_all(output_path)
.map_err(|e| format!("cannot create output directory '{}': {}", output_path, e))?;
let root = std::fs::canonicalize(output_path).map_err(|e| {
format!(
"cannot canonicalize output directory '{}': {}",
output_path, e
)
})?;
if node.is_directory && node.name == "/" {
for child in &node.children {
extract_into(file, child, &root, &root)?;
}
Ok(())
} else {
extract_into(file, node, &root, &root)
}
}
const EXTRACT_CHUNK_SIZE: usize = 8 * 1024 * 1024;
fn validate_entry_name(name: &str) -> Result<()> {
if name.is_empty() || name == "." || name == ".." {
return Err(format!("refusing to extract entry with unsafe name {:?}", name).into());
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
return Err(format!(
"refusing to extract entry whose name contains a path separator or NUL byte: {:?}",
name
)
.into());
}
Ok(())
}
fn safe_join(root: &Path, here: &Path, name: &str) -> Result<PathBuf> {
validate_entry_name(name)?;
let target = here.join(name);
if !target.starts_with(root) {
return Err(format!(
"path escape: entry '{}' would write outside output directory {}",
name,
root.display()
)
.into());
}
Ok(target)
}
fn extract_into<R: Read + Seek>(
file: &mut R,
node: &TreeNode,
root: &Path,
here: &Path,
) -> Result<()> {
let target = safe_join(root, here, &node.name)?;
if node.is_directory {
create_dir_all(&target)?;
eprintln!("Created directory: {}", target.display());
for child in &node.children {
extract_into(file, child, root, &target)?;
}
} else {
extract_file_at(file, node, &target)?;
}
Ok(())
}
fn extract_file_at<R: Read + Seek>(file: &mut R, node: &TreeNode, target: &Path) -> Result<()> {
let (location, length) = match (node.file_location, node.file_length) {
(Some(l), Some(n)) => (l, n),
_ => return Err("File location information not available for extraction".into()),
};
file.seek(SeekFrom::Start(location))?;
if let Some(parent) = target.parent() {
create_dir_all(parent)?;
}
let mut output_file = std::fs::File::create(target)
.map_err(|e| format!("cannot create '{}': {}", target.display(), e))?;
let mut remaining: u64 = length;
let buf_cap = remaining.min(EXTRACT_CHUNK_SIZE as u64) as usize;
let mut buffer = vec![0u8; buf_cap];
while remaining > 0 {
let to_read = remaining.min(EXTRACT_CHUNK_SIZE as u64) as usize;
let buf = &mut buffer[..to_read];
file.read_exact(buf)?;
output_file.write_all(buf)?;
remaining -= to_read as u64;
if length > 100 * 1024 * 1024 {
let done = length - remaining;
eprint!(
"\r Extracting {}: {:.1}%",
node.name,
done as f64 / length as f64 * 100.0
);
}
}
if length > 100 * 1024 * 1024 {
eprintln!();
}
eprintln!("Extracted: {}", target.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::path::Path;
fn test_file_path(filename: &str) -> String {
format!("test_data/{}", filename)
}
fn require_test_file(name: &str) -> Option<File> {
let path = test_file_path(name);
if !Path::new(&path).exists() {
eprintln!(
"Skipping test: {} not found (run `make test-data` to generate)",
path
);
return None;
}
Some(File::open(&path).unwrap_or_else(|_| panic!("Failed to open test file: {}", path)))
}
fn parse_linux_iso() -> Option<(File, TreeNode)> {
let mut file = require_test_file("test_linux.iso")?;
let root = detect_and_parse_filesystem(&mut file, "test_linux.iso")
.expect("Failed to parse test_linux.iso");
Some((file, root))
}
fn parse_macos_iso() -> Option<(File, TreeNode)> {
let mut file = require_test_file("test_macos.iso")?;
let root = detect_and_parse_filesystem(&mut file, "test_macos.iso")
.expect("Failed to parse test_macos.iso");
Some((file, root))
}
#[test]
fn test_iso9660_parsing() {
for test_file in &["test_linux.iso", "test_macos.iso"] {
if let Some(mut file) = require_test_file(test_file) {
let root = iso9660::parse_iso9660(&mut file)
.unwrap_or_else(|e| panic!("ISO 9660 parsing failed for {}: {}", test_file, e));
assert_eq!(root.name, "/");
assert!(root.is_directory);
assert!(
!root.children.is_empty(),
"{} should have children",
test_file
);
}
}
}
#[test]
fn test_filesystem_detection() {
for test_file in &["test_linux.iso", "test_macos.iso"] {
if let Some(mut file) = require_test_file(test_file) {
let root = detect_and_parse_filesystem(&mut file, test_file).unwrap_or_else(|e| {
panic!("Filesystem detection failed for {}: {}", test_file, e)
});
assert_eq!(root.name, "/");
assert!(root.is_directory);
}
}
}
#[test]
fn test_invalid_file_handling() {
assert!(File::open(test_file_path("nonexistent.iso")).is_err());
}
#[test]
fn test_garbage_data_rejected() {
let dir = std::env::temp_dir().join("isomage_test");
std::fs::create_dir_all(&dir).unwrap();
let garbage_path = dir.join("garbage.iso");
std::fs::write(&garbage_path, b"this is not an ISO file at all").unwrap();
let mut file = File::open(&garbage_path).unwrap();
let result = detect_and_parse_filesystem(&mut file, "garbage.iso");
assert!(result.is_err(), "Garbage data should fail to parse");
let err = result.unwrap_err().to_string();
assert!(
err.contains("Unable to detect"),
"Error should mention detection failure, got: {}",
err
);
std::fs::remove_file(&garbage_path).ok();
}
#[test]
fn test_linux_iso_expected_directories() {
if let Some((_file, root)) = parse_linux_iso() {
for dir_name in &["boot", "etc", "home", "usr", "var"] {
let node = root
.find_node(dir_name)
.unwrap_or_else(|| panic!("Expected directory '{}' not found", dir_name));
assert!(node.is_directory, "'{}' should be a directory", dir_name);
}
}
}
#[test]
fn test_linux_iso_expected_files() {
if let Some((_file, root)) = parse_linux_iso() {
let expected_files = [
"boot/grub.cfg",
"etc/hostname",
"etc/hosts",
"home/user/.bashrc",
"usr/bin/hello",
"var/log/system.log",
];
for path in &expected_files {
let node = root
.find_node(path)
.unwrap_or_else(|| panic!("Expected file '{}' not found", path));
assert!(!node.is_directory, "'{}' should be a file", path);
assert!(node.size > 0, "'{}' should have non-zero size", path);
assert!(
node.file_location.is_some(),
"'{}' should have a file location",
path
);
assert!(
node.file_length.is_some(),
"'{}' should have a file length",
path
);
}
}
}
#[test]
fn test_linux_iso_nested_structure() {
if let Some((_file, root)) = parse_linux_iso() {
let home = root.find_node("home").expect("home not found");
assert!(home.is_directory);
let user = home.find_node("user").expect("user not found in home");
assert!(user.is_directory);
let bashrc = user
.find_node(".bashrc")
.expect(".bashrc not found in user");
assert!(!bashrc.is_directory);
}
}
#[test]
fn test_macos_iso_expected_structure() {
if let Some((_file, root)) = parse_macos_iso() {
for dir_name in &["Applications", "System", "Users", "private"] {
let node = root.find_node(dir_name).unwrap_or_else(|| {
panic!("Expected directory '{}' not found in macOS ISO", dir_name)
});
assert!(node.is_directory);
}
let expected_files = [
"Applications/readme.txt",
"System/Library/info.txt",
"Users/user/welcome.txt",
"private/var/log/system.log",
];
for path in &expected_files {
let node = root
.find_node(path)
.unwrap_or_else(|| panic!("Expected file '{}' not found in macOS ISO", path));
assert!(!node.is_directory);
assert!(node.size > 0);
}
}
}
#[test]
fn test_tree_structure_validation() {
for (name, parser) in [
("linux", parse_linux_iso as fn() -> Option<(File, TreeNode)>),
("macos", parse_macos_iso),
] {
if let Some((_file, root)) = parser() {
validate_tree_structure(&root, 0, name);
}
}
}
fn validate_tree_structure(node: &TreeNode, depth: usize, iso_name: &str) {
assert!(
!node.name.is_empty(),
"Node name should not be empty in {}",
iso_name
);
assert!(depth <= 10, "Tree depth exceeded limit in {}", iso_name);
if !node.is_directory {
assert!(
node.children.is_empty(),
"File '{}' should not have children in {}",
node.name,
iso_name
);
}
for child in &node.children {
validate_tree_structure(child, depth + 1, iso_name);
}
}
#[test]
fn test_tree_node_creation() {
let dir_node = TreeNode::new_directory("test_dir".to_string());
assert!(dir_node.is_directory);
assert_eq!(dir_node.name, "test_dir");
assert_eq!(dir_node.size, 0);
assert!(dir_node.children.is_empty());
assert!(dir_node.file_location.is_none());
let file_node = TreeNode::new_file("test_file.txt".to_string(), 1024);
assert!(!file_node.is_directory);
assert_eq!(file_node.name, "test_file.txt");
assert_eq!(file_node.size, 1024);
assert!(file_node.file_location.is_none());
let located = TreeNode::new_file_with_location("f.bin".to_string(), 512, 4096, 512);
assert_eq!(located.file_location, Some(4096));
assert_eq!(located.file_length, Some(512));
}
#[test]
fn test_directory_size_calculation() {
let mut root = TreeNode::new_directory("root".to_string());
root.add_child(TreeNode::new_file("file1.txt".to_string(), 100));
root.add_child(TreeNode::new_file("file2.txt".to_string(), 200));
let mut subdir = TreeNode::new_directory("subdir".to_string());
subdir.add_child(TreeNode::new_file("file3.txt".to_string(), 300));
root.add_child(subdir);
root.calculate_directory_size();
assert_eq!(root.size, 600);
let sub = root.find_node("subdir").unwrap();
assert_eq!(sub.size, 300);
}
#[test]
fn test_find_node_with_leading_slash() {
if let Some((_file, root)) = parse_linux_iso() {
assert!(root.find_node("/etc/hostname").is_some());
assert!(root.find_node("etc/hostname").is_some());
assert!(root.find_node("///etc/hostname").is_some());
}
}
#[test]
fn test_find_node_root_paths() {
if let Some((_file, root)) = parse_linux_iso() {
let by_empty = root.find_node("").unwrap();
assert_eq!(by_empty.name, "/");
let by_slash = root.find_node("/").unwrap();
assert_eq!(by_slash.name, "/");
}
}
#[test]
fn test_find_node_nonexistent() {
if let Some((_file, root)) = parse_linux_iso() {
assert!(root.find_node("nonexistent").is_none());
assert!(root.find_node("etc/nonexistent").is_none());
assert!(root.find_node("a/b/c/d/e/f").is_none());
}
}
#[test]
fn test_cat_file_to_buffer() {
if let Some((mut file, root)) = parse_linux_iso() {
let node = root
.find_node("etc/hostname")
.expect("etc/hostname not found");
let mut output = Vec::new();
cat_node(&mut file, node, &mut output).expect("cat_node failed");
let content = String::from_utf8(output).expect("Not valid UTF-8");
assert!(
content.contains("test-linux-system"),
"Expected hostname content, got: {:?}",
content
);
}
}
#[test]
fn test_cat_preserves_exact_bytes() {
if let Some((mut file, root)) = parse_linux_iso() {
let node = root
.find_node("etc/hostname")
.expect("etc/hostname not found");
let mut output = Vec::new();
cat_node(&mut file, node, &mut output).expect("cat_node failed");
assert_eq!(
output.len() as u64,
node.size,
"cat output length {} doesn't match node size {}",
output.len(),
node.size
);
}
}
#[test]
fn test_cat_rejects_directory() {
if let Some((mut file, root)) = parse_linux_iso() {
let node = root.find_node("etc").expect("etc/ not found");
let mut output = Vec::new();
let result = cat_node(&mut file, node, &mut output);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("directory"));
assert!(
output.is_empty(),
"No bytes should be written for a directory"
);
}
}
#[test]
fn test_cat_node_without_location() {
let node = TreeNode::new_file("orphan.txt".to_string(), 100);
let dir = std::env::temp_dir().join("isomage_test");
std::fs::create_dir_all(&dir).unwrap();
let dummy_path = dir.join("dummy.bin");
std::fs::write(&dummy_path, b"x").unwrap();
let mut file = File::open(&dummy_path).unwrap();
let mut output = Vec::new();
let result = cat_node(&mut file, &node, &mut output);
assert!(result.is_err(), "cat on file without location should error");
assert!(result.unwrap_err().to_string().contains("not available"));
std::fs::remove_file(&dummy_path).ok();
}
#[test]
fn test_cat_every_file_in_linux_iso() {
if let Some((mut file, root)) = parse_linux_iso() {
let files = [
("boot/grub.cfg", "GRUB"),
("etc/hostname", "test-linux-system"),
("etc/hosts", "127.0.0.1"),
("home/user/.bashrc", "Bash"),
("usr/bin/hello", "Hello World"),
("var/log/system.log", "System started"),
];
for (path, expected) in &files {
let node = root
.find_node(path)
.unwrap_or_else(|| panic!("{} not found", path));
let mut output = Vec::new();
cat_node(&mut file, node, &mut output)
.unwrap_or_else(|e| panic!("cat failed for {}: {}", path, e));
let content = String::from_utf8(output).expect("Not valid UTF-8");
assert!(
content.contains(expected),
"Expected '{}' in {}, got: {:?}",
expected,
path,
content
);
}
}
}
#[test]
fn test_cat_every_file_in_macos_iso() {
if let Some((mut file, root)) = parse_macos_iso() {
let files = [
("Applications/readme.txt", "Application Data"),
("System/Library/info.txt", "System Library"),
("Users/user/welcome.txt", "Welcome to macOS"),
("private/var/log/system.log", "macOS system log"),
];
for (path, expected) in &files {
let node = root
.find_node(path)
.unwrap_or_else(|| panic!("{} not found in macOS ISO", path));
let mut output = Vec::new();
cat_node(&mut file, node, &mut output)
.unwrap_or_else(|e| panic!("cat failed for {}: {}", path, e));
let content = String::from_utf8(output).expect("Not valid UTF-8");
assert!(
content.contains(expected),
"Expected '{}' in {}, got: {:?}",
expected,
path,
content
);
}
}
}
#[test]
fn test_extract_single_file() {
if let Some((mut file, root)) = parse_linux_iso() {
let dir = std::env::temp_dir().join("isomage_test_extract_single");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let node = root
.find_node("etc/hostname")
.expect("etc/hostname not found");
extract_node(&mut file, node, dir.to_str().unwrap()).expect("extract failed");
let extracted = std::fs::read_to_string(dir.join("hostname")).unwrap();
assert!(extracted.contains("test-linux-system"));
std::fs::remove_dir_all(&dir).ok();
}
}
#[test]
fn test_extract_directory() {
if let Some((mut file, root)) = parse_linux_iso() {
let dir = std::env::temp_dir().join("isomage_test_extract_dir");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let node = root.find_node("etc").expect("etc not found");
extract_node(&mut file, node, dir.to_str().unwrap()).expect("extract failed");
assert!(dir.join("etc/hostname").exists(), "hostname should exist");
assert!(dir.join("etc/hosts").exists(), "hosts should exist");
let hostname = std::fs::read_to_string(dir.join("etc/hostname")).unwrap();
assert!(hostname.contains("test-linux-system"));
std::fs::remove_dir_all(&dir).ok();
}
}
#[test]
fn test_extract_root() {
if let Some((mut file, root)) = parse_linux_iso() {
let dir = std::env::temp_dir().join("isomage_test_extract_root");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
extract_node(&mut file, &root, dir.to_str().unwrap()).expect("extract root failed");
for name in &["boot", "etc", "home", "usr", "var"] {
assert!(dir.join(name).is_dir(), "{} directory should exist", name);
}
assert!(
dir.join("home/user/.bashrc").exists(),
".bashrc should exist"
);
std::fs::remove_dir_all(&dir).ok();
}
}
#[test]
fn test_extract_matches_cat() {
if let Some((mut file, root)) = parse_linux_iso() {
let dir = std::env::temp_dir().join("isomage_test_extract_vs_cat");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let node = root.find_node("etc/hosts").expect("etc/hosts not found");
let mut cat_output = Vec::new();
cat_node(&mut file, node, &mut cat_output).expect("cat failed");
extract_node(&mut file, node, dir.to_str().unwrap()).expect("extract failed");
let extracted = std::fs::read(dir.join("hosts")).unwrap();
assert_eq!(
cat_output, extracted,
"cat and extract should produce identical bytes"
);
std::fs::remove_dir_all(&dir).ok();
}
}
fn dummy_iso() -> (File, std::path::PathBuf) {
let dir = std::env::temp_dir().join("isomage_test_security");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("dummy.bin");
std::fs::write(&p, b"hostile payload bytes").unwrap();
let f = File::open(&p).unwrap();
(f, p)
}
#[test]
fn test_extract_rejects_dotdot_in_name() {
let (mut file, _payload) = dummy_iso();
let mut root = TreeNode::new_directory("/".to_string());
root.add_child(TreeNode::new_file_with_location(
"../escapee.txt".to_string(),
21,
0,
21,
));
let out = std::env::temp_dir().join("isomage_test_extract_dotdot_out");
let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap();
let result = extract_node(&mut file, &root, out.to_str().unwrap());
assert!(result.is_err(), "extract must refuse '../escapee.txt'");
let err = result.unwrap_err().to_string();
assert!(
err.contains("unsafe") || err.contains("path"),
"error should mention unsafe/path traversal, got: {}",
err
);
assert!(
!out.parent().unwrap().join("escapee.txt").exists(),
"no file should have been written outside the output directory"
);
std::fs::remove_dir_all(&out).ok();
}
#[test]
fn test_extract_rejects_slash_in_name() {
let (mut file, _payload) = dummy_iso();
let mut root = TreeNode::new_directory("/".to_string());
root.add_child(TreeNode::new_file_with_location(
"subdir/file.txt".to_string(),
21,
0,
21,
));
let out = std::env::temp_dir().join("isomage_test_extract_slash_out");
let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap();
let result = extract_node(&mut file, &root, out.to_str().unwrap());
assert!(result.is_err(), "extract must refuse a name containing '/'");
std::fs::remove_dir_all(&out).ok();
}
#[test]
fn test_extract_rejects_absolute_name() {
let (mut file, _payload) = dummy_iso();
let mut root = TreeNode::new_directory("/".to_string());
root.add_child(TreeNode::new_file_with_location(
"/etc/passwd".to_string(),
21,
0,
21,
));
let out = std::env::temp_dir().join("isomage_test_extract_abs_out");
let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap();
let result = extract_node(&mut file, &root, out.to_str().unwrap());
assert!(
result.is_err(),
"extract must refuse an absolute-looking name"
);
std::fs::remove_dir_all(&out).ok();
}
#[test]
fn test_extract_rejects_nul_byte() {
let (mut file, _payload) = dummy_iso();
let mut root = TreeNode::new_directory("/".to_string());
root.add_child(TreeNode::new_file_with_location(
"good\0name.txt".to_string(),
21,
0,
21,
));
let out = std::env::temp_dir().join("isomage_test_extract_nul_out");
let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap();
let result = extract_node(&mut file, &root, out.to_str().unwrap());
assert!(
result.is_err(),
"extract must refuse a name with a NUL byte"
);
std::fs::remove_dir_all(&out).ok();
}
#[test]
fn test_validate_entry_name_unit() {
assert!(validate_entry_name("hostname").is_ok());
assert!(validate_entry_name(".bashrc").is_ok());
assert!(validate_entry_name("name with spaces").is_ok());
assert!(validate_entry_name("").is_err());
assert!(validate_entry_name(".").is_err());
assert!(validate_entry_name("..").is_err());
assert!(validate_entry_name("../etc/passwd").is_err());
assert!(validate_entry_name("foo/bar").is_err());
assert!(validate_entry_name("foo\\bar").is_err());
assert!(validate_entry_name("/etc/passwd").is_err());
assert!(validate_entry_name("a\0b").is_err());
}
struct BrokenPipeAfter {
budget: usize,
written: usize,
}
impl Write for BrokenPipeAfter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.written >= self.budget {
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"downstream closed",
));
}
let take = buf.len().min(self.budget - self.written);
self.written += take;
Ok(take)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn test_cat_node_swallows_broken_pipe() {
if let Some((mut file, root)) = parse_linux_iso() {
let node = root
.find_node("etc/hostname")
.expect("etc/hostname not found");
let mut w = BrokenPipeAfter {
budget: 0,
written: 0,
};
let result = cat_node(&mut file, node, &mut w);
assert!(
result.is_ok(),
"cat_node should treat BrokenPipe as Ok, got: {:?}",
result
);
}
}
fn make_tiny_udf() -> Vec<u8> {
const S: usize = 2048;
let mut img = vec![0u8; S * 270];
let w16 = |buf: &mut Vec<u8>, off: usize, v: u16| {
buf[off..off + 2].copy_from_slice(&v.to_le_bytes())
};
let w32 = |buf: &mut Vec<u8>, off: usize, v: u32| {
buf[off..off + 4].copy_from_slice(&v.to_le_bytes())
};
img[16 * S + 1..16 * S + 6].copy_from_slice(b"BEA01");
img[17 * S + 1..17 * S + 6].copy_from_slice(b"NSR02");
img[18 * S + 1..18 * S + 6].copy_from_slice(b"TEA01");
let avdp = 256 * S;
w16(&mut img, avdp, 2);
w32(&mut img, avdp + 16, (3 * S) as u32);
w32(&mut img, avdp + 20, 257);
w16(&mut img, 257 * S, 5);
w16(&mut img, 257 * S + 22, 0);
w32(&mut img, 257 * S + 188, 260);
w16(&mut img, 258 * S, 6);
w32(&mut img, 258 * S + 248, S as u32);
w32(&mut img, 258 * S + 252, 0);
w16(&mut img, 258 * S + 256, 0);
w16(&mut img, 259 * S, 8);
w16(&mut img, 260 * S, 256);
w32(&mut img, 260 * S + 400, S as u32);
w32(&mut img, 260 * S + 404, 1);
w16(&mut img, 260 * S + 408, 0);
let rfe = 261 * S;
w16(&mut img, rfe, 261);
w16(&mut img, rfe + 18, 3);
let mut parent = vec![0u8; 40];
parent[0..2].copy_from_slice(&257u16.to_le_bytes());
parent[18] = 0x08;
w32(&mut img, rfe + 172, parent.len() as u32);
img[rfe + 176..rfe + 176 + parent.len()].copy_from_slice(&parent);
img
}
#[test]
fn detect_and_parse_verbose_false_garbage() {
let mut c = std::io::Cursor::new(vec![0u8; 4096]);
let result = detect_and_parse_filesystem_verbose(&mut c, "fake.iso", false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unable to detect"));
}
#[test]
fn detect_and_parse_verbose_true_garbage() {
let mut c = std::io::Cursor::new(vec![0u8; 512 * 1024]); let result = detect_and_parse_filesystem_verbose(&mut c, "fake.iso", true);
assert!(result.is_err());
}
#[test]
fn detect_and_parse_verbose_true_udf() {
let img = make_tiny_udf();
let mut c = std::io::Cursor::new(img);
let result = detect_and_parse_filesystem_verbose(&mut c, "test.udf", true);
assert!(result.is_ok());
}
#[test]
fn safe_join_rejects_path_escape() {
let root = std::path::Path::new("/tmp");
let here = std::path::Path::new("/tmp");
let result = safe_join(root, here, "file.txt");
assert!(result.is_ok());
}
#[test]
fn safe_join_detects_here_outside_root() {
let root = Path::new("/tmp/output");
let here = Path::new("/other_dir");
let result = safe_join(root, here, "file.txt");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("path escape"), "got: {msg}");
}
#[test]
fn extract_node_errors_when_no_file_location() {
let tmp = tempfile::TempDir::new().unwrap();
let node = TreeNode::new_file("orphan.txt".to_string(), 100);
let mut c = std::io::Cursor::new(vec![0u8; 100]);
let result = extract_node(&mut c, &node, tmp.path().to_str().unwrap());
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("location") || msg.contains("not available"),
"got: {msg}"
);
}
#[test]
fn cat_node_swallows_broken_pipe_after_partial_write() {
let data = b"hello world";
let mut img = vec![0u8; 512];
img[..data.len()].copy_from_slice(data);
let mut c = std::io::Cursor::new(img);
let node = TreeNode::new_file_with_location("f".to_string(), 11, 0, 11);
let mut w = BrokenPipeAfter {
budget: 3,
written: 0,
};
let result = cat_node(&mut c, &node, &mut w);
assert!(
result.is_ok(),
"cat_node should treat BrokenPipe as Ok after partial write"
);
}
#[test]
fn extract_node_extracts_file_with_location() {
let content = b"hello world";
let mut img = vec![0u8; 512];
img[..content.len()].copy_from_slice(content);
let mut c = std::io::Cursor::new(img);
let node = TreeNode::new_file_with_location(
"synth.txt".to_string(),
content.len() as u64,
0,
content.len() as u64,
);
let tmp = tempfile::TempDir::new().unwrap();
let result = extract_node(&mut c, &node, tmp.path().to_str().unwrap());
assert!(result.is_ok(), "extract_node should succeed: {:?}", result);
let extracted = std::fs::read(tmp.path().join("synth.txt")).unwrap();
assert_eq!(extracted, content);
}
#[test]
fn cat_node_non_broken_pipe_error_propagates() {
struct FailWriter;
impl Write for FailWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "no write"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let data = b"hello";
let mut img = vec![0u8; 512];
img[..data.len()].copy_from_slice(data);
let mut c = std::io::Cursor::new(img);
let node = TreeNode::new_file_with_location("f".to_string(), 5, 0, 5);
let result = cat_node(&mut c, &node, &mut FailWriter);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("no write") || msg.contains("Permission"),
"got: {msg}"
);
}
fn make_minimal_iso() -> Vec<u8> {
const SECTOR: usize = 2048;
let mut img = vec![0u8; 19 * SECTOR];
let pvd = 16 * SECTOR;
img[pvd] = 1;
img[pvd + 1..pvd + 6].copy_from_slice(b"CD001");
img[pvd + 6] = 1;
let vol_size: u32 = 19;
img[pvd + 80..pvd + 84].copy_from_slice(&vol_size.to_le_bytes());
img[pvd + 84..pvd + 88].copy_from_slice(&vol_size.to_be_bytes());
let block_size: u16 = 2048;
img[pvd + 128..pvd + 130].copy_from_slice(&block_size.to_le_bytes());
img[pvd + 130..pvd + 132].copy_from_slice(&block_size.to_be_bytes());
let rdr = pvd + 156;
img[rdr] = 34;
let root_sector: u32 = 18;
img[rdr + 2..rdr + 6].copy_from_slice(&root_sector.to_le_bytes());
img[rdr + 6..rdr + 10].copy_from_slice(&root_sector.to_be_bytes());
let dir_len: u32 = 2048;
img[rdr + 10..rdr + 14].copy_from_slice(&dir_len.to_le_bytes());
img[rdr + 14..rdr + 18].copy_from_slice(&dir_len.to_be_bytes());
img[rdr + 25] = 0x02;
img[rdr + 32] = 1;
img[rdr + 33] = 0x00;
let term = 17 * SECTOR;
img[term] = 255;
img[term + 1..term + 6].copy_from_slice(b"CD001");
img[term + 6] = 1;
let dir = 18 * SECTOR;
img[dir] = 34;
img[dir + 2..dir + 6].copy_from_slice(&root_sector.to_le_bytes());
img[dir + 6..dir + 10].copy_from_slice(&root_sector.to_be_bytes());
img[dir + 10..dir + 14].copy_from_slice(&dir_len.to_le_bytes());
img[dir + 14..dir + 18].copy_from_slice(&dir_len.to_be_bytes());
img[dir + 25] = 0x02;
img[dir + 32] = 1;
img[dir + 33] = 0x00;
img[dir + 34] = 34;
img[dir + 36..dir + 40].copy_from_slice(&root_sector.to_le_bytes());
img[dir + 40..dir + 44].copy_from_slice(&root_sector.to_be_bytes());
img[dir + 44..dir + 48].copy_from_slice(&dir_len.to_le_bytes());
img[dir + 48..dir + 52].copy_from_slice(&dir_len.to_be_bytes());
img[dir + 59] = 0x02;
img[dir + 66] = 1;
img[dir + 67] = 0x01;
img
}
#[test]
fn test_verbose_detection_succeeds_on_synthetic_iso() {
let img = make_minimal_iso();
let mut cur = std::io::Cursor::new(img);
let result = detect_and_parse_filesystem_verbose(&mut cur, "synthetic.iso", true);
assert!(
result.is_ok(),
"verbose detection should succeed: {:?}",
result
);
let root = result.unwrap();
assert_eq!(root.name, "/");
assert!(root.is_directory);
}
#[test]
fn test_verbose_detection_reports_errors_on_garbage() {
let garbage = vec![0xABu8; 4096];
let mut cur = std::io::Cursor::new(garbage);
let result = detect_and_parse_filesystem_verbose(&mut cur, "garbage.bin", true);
assert!(result.is_err(), "verbose detection on garbage should fail");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("Unable to detect"),
"error should say 'Unable to detect', got: {}",
msg
);
}
struct AlwaysFailWriter;
impl Write for AlwaysFailWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"permission denied",
))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn test_cat_node_propagates_non_broken_pipe_error() {
let img = make_minimal_iso();
let mut cur = std::io::Cursor::new(img);
let node = TreeNode::new_file_with_location("term.bin".to_string(), 6, 17 * 2048, 6);
let mut w = AlwaysFailWriter;
let result = cat_node(&mut cur, &node, &mut w);
assert!(
result.is_err(),
"cat_node should propagate PermissionDenied"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("permission denied") || msg.contains("Permission denied"),
"got: {}",
msg
);
}
#[test]
fn test_safe_join_detects_path_escape() {
let root = std::env::temp_dir().join("isomage_safe_join_root_a");
let here = std::env::temp_dir().join("isomage_safe_join_root_b");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&here).unwrap();
let result = safe_join(&root, &here, "legit.txt");
assert!(result.is_err(), "safe_join must reject target outside root");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("escape") || msg.contains("outside"),
"error should mention path escape, got: {}",
msg
);
std::fs::remove_dir_all(&root).ok();
std::fs::remove_dir_all(&here).ok();
}
#[test]
fn test_extract_directory_child_without_location_errors() {
let img = make_minimal_iso();
let mut cur = std::io::Cursor::new(img);
let mut dir = TreeNode::new_directory("mydir".to_string());
dir.add_child(TreeNode::new_file("orphan.bin".to_string(), 10));
let out = std::env::temp_dir().join("isomage_test_extract_no_loc");
let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap();
let result = extract_node(&mut cur, &dir, out.to_str().unwrap());
assert!(
result.is_err(),
"extracting child with no location must error"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("not available"),
"error should mention 'not available', got: {}",
msg
);
std::fs::remove_dir_all(&out).ok();
}
}