isomage 2.1.0

Pure-Rust reader for ISO 9660, UDF, FAT, ext2/3/4, NTFS, HFS+, SquashFS, ZIP, TAR, and more. No unsafe, no runtime deps.
Documentation

isomage

Crates.io docs.rs CI License: MIT MSRV Zero deps

A pure-Rust reader and writer for disk images and filesystem formats. Zero default dependencies. No mount, no FUSE, no unsafe.

isomage is one crate in the 7zippy suite. It owns every format that is a disk image or contains a filesystem — ISO, UDF, FAT, NTFS, ext, SquashFS, HFS+, APFS, WIM, DMG, VHD, VMDK, QCOW2, MBR, GPT, ZIP, TAR, and more. Compression algorithms (GZip, BZip2, XZ, LZMA, Deflate, 7z) live in their own sibling crates within 7zippy.

[dependencies]
isomage = "2"
use std::fs::File;
use isomage::{detect_and_parse_filesystem, cat_node, extract_node};

let mut iso = File::open("disc.iso")?;
let root = detect_and_parse_filesystem(&mut iso, "disc.iso")?;

// Walk the tree.
for child in &root.children {
    println!("{} {} ({} bytes)",
        if child.is_directory { "d" } else { "-" },
        child.name, child.size);
}

// Stream one file into any std::io::Write.
let hostname = root.find_node("etc/hostname").ok_or("not in ISO")?;
let mut buf = Vec::new();
cat_node(&mut iso, hostname, &mut buf)?;

// Or extract a subtree to disk — names that try to escape via
// "../" or '/' are refused with a clear error, not silently written.
extract_node(&mut iso, hostname, "/tmp/extracted")?;
# Ok::<(), isomage::Error>(())

What it parses

isomage handles disk images and filesystems. Compression algorithms are out of scope — see the other 7zippy sibling crates for those.

Filesystem / partition formats

  • ISO 9660 (ECMA-119) — Joliet + Rock Ridge extensions.
  • UDF (ECMA-167) — metadata partitions, multi-extent files; covers CDs, DVDs, Blu-rays.
  • FAT12 / FAT16 / FAT32--features fat.
  • ext2 / ext3 / ext4 — extent trees + classical block pointers — --features ext.
  • SquashFS — read-only compressed filesystem — --features squashfs.
  • NTFS — Windows NT filesystem — --features ntfs.
  • HFS+ — macOS HFS Plus — --features hfsplus.
  • APFS — Apple File System — --features apfs.
  • MBR / GPT — partition table readers — --features mbr / --features gpt.

Virtual disk containers

  • VHD (Virtual Hard Disk) — --features vhd.
  • VMDK (VMware Virtual Machine Disk) — --features vmdk.
  • QCOW2 (QEMU Copy On Write) — --features qcow2.
  • WIM (Windows Imaging Format) — --features wim.
  • DMG (Apple Disk Image) — --features dmg.

Archive formats with filesystem-like trees

  • ZIP (PKZIP / ZIP64, stored entries readable via cat_node) — --features zip.
  • TAR (ustar / GNU / PAX) — --features tar.

Detection is automatic: detect_and_parse_filesystem tries all enabled formats and returns the first match with a tagged-error string if none match.


Where isomage fits in 7zippy

7zippy is a suite of pure-Rust crates that together cover the full format matrix that 7-Zip handles. Each crate owns one distinct problem domain:

Crate Scope
isomage (this crate) Disk images and filesystem formats — anything that is a disk image or contains a filesystem tree
gzippy Deflate / GZip — the fastest gzip on any hardware; also provides the deflate-gzippy dep isomage can optionally use for compressed ZIP entries
lazippy LZMA / XZ decompression
bzippy2 BZip2 decompression (7zippy sub-crate)
7zippy 7z archive format umbrella
Other compression algorithms, each isolated

If the format is a disk image or wraps a filesystem — isomage. If the format is a compression algorithm — a sibling 7zippy crate.

This boundary is deliberate: isomage stays zero-dependency (no codec crates in default features), and each codec crate can be audited, fuzz-tested, and versioned independently.


Contents


Public API

Everything is on docs.rs/isomage; this is the cheat sheet.

Item What it does
detect_and_parse_filesystem Try ISO 9660 then UDF; return the root TreeNode.
detect_and_parse_filesystem_verbose Same, with spec-tagged diagnostics to stderr.
cat_node Stream a file to any std::io::Write. BrokenPipe-tolerant.
extract_node Extract a file or subtree to disk. Path-traversal-safe.
TreeNode The parsed-tree model: file or directory, with byte-range references into the image.
TreeNode::find_node Slash-separated path lookup, leading / tolerated.
isomage::iso9660 / isomage::udf The format-specific parsers, exposed for callers that already know what they have.
isomage::Error / isomage::Result Box<dyn std::error::Error + Send + Sync + 'static> and its Result alias — composes cleanly with anyhow and threads.

MSRV is 1.74. The crate has no runtime dependencies and uses no unsafe blocks.


Safety guarantees

The crate parses untrusted binary input. Two specific surfaces are hardened:

  1. extract_node cannot write outside its output directory. Every directory-entry name is validated to reject empty strings, ., .., and any name containing /, \, or NUL bytes. As defense in depth, the output directory is canonicalized once at entry and every resolved write path is checked to stay under it. An adversarial ISO whose directory records claim a name like ../../etc/passwd produces a clear Err rather than silently writing to the host filesystem.

  2. cat_node does not panic on closed pipes. If the downstream Write returns ErrorKind::BrokenPipe, cat_node returns Ok(()) — matching standard Unix | head semantics. Useful when you're streaming a large extent into another process that might stop reading.

The parsers themselves return Err on invalid input rather than panicking. If you find a crafted ISO that panics, that's a bug — see SECURITY.md.


Examples

List all directories and files

use std::fs::File;
use isomage::{detect_and_parse_filesystem, TreeNode};

fn walk(node: &TreeNode, depth: usize) {
    println!("{:width$}{} {}",
        "", if node.is_directory { "d" } else { "-" },
        node.name, width = depth * 2);
    for child in &node.children {
        walk(child, depth + 1);
    }
}

let mut iso = File::open("disc.iso")?;
let root = detect_and_parse_filesystem(&mut iso, "disc.iso")?;
walk(&root, 0);
# Ok::<(), isomage::Error>(())

Stream one file to stdout

use std::fs::File;
use std::io;
use isomage::{detect_and_parse_filesystem, cat_node};

let mut iso = File::open("disc.iso")?;
let root = detect_and_parse_filesystem(&mut iso, "disc.iso")?;
let node = root.find_node("etc/hostname").ok_or("not in ISO")?;

let mut stdout = io::stdout().lock();
cat_node(&mut iso, node, &mut stdout)?;
# Ok::<(), isomage::Error>(())

Extract a subtree to disk

use std::fs::File;
use isomage::{detect_and_parse_filesystem, extract_node};

let mut iso = File::open("disc.iso")?;
let root = detect_and_parse_filesystem(&mut iso, "disc.iso")?;
let docs = root.find_node("docs").ok_or("not in ISO")?;
extract_node(&mut iso, docs, "/tmp/disc-docs")?;
# Ok::<(), isomage::Error>(())

Investigate a malformed disc

use std::fs::File;
use isomage::detect_and_parse_filesystem_verbose;

let mut iso = File::open("weird.iso")?;
// Prints to stderr: file size, signatures at key sectors, which
// parser tried what, where it gave up.
let _ = detect_and_parse_filesystem_verbose(&mut iso, "weird.iso", true);
# Ok::<(), isomage::Error>(())

Architecture

The crate is small (~1.7k lines across four files). The natural reading order is tree.rsiso9660.rsudf.rslib.rs:

src/
├── tree.rs       The TreeNode model used by every other module.
├── iso9660.rs    ISO 9660 parser (incl. Joliet, Rock Ridge).
├── udf.rs        UDF parser (incl. metadata partitions, multi-extent).
└── lib.rs        Public API: detect_and_parse, cat_node, extract_node;
                  re-exports TreeNode and exposes the Error/Result aliases.

Data model: TreeNode

pub struct TreeNode {
    pub name: String,
    pub size: u64,
    pub is_directory: bool,
    pub children: Vec<TreeNode>,
    pub file_location: Option<u64>,   // byte offset into the image
    pub file_length:   Option<u64>,   // file size in bytes
}

Parsers return a fully-built TreeNode tree rooted at "/". Files carry a (file_location, file_length) pair pointing into the original image — there is no in-memory copy of the file bytes. cat_node and extract_node seek to file_location and read file_length bytes.

Parsers

Both parsers expose parse_<fmt>(file) and parse_<fmt>_verbose(file, verbose). The verbose variants print spec-section-tagged diagnostics to stderr. lib.rs always calls the verbose variant and threads the flag from detect_and_parse_filesystem_verbose.

Both parsers seek to sector 16 (the Volume Recognition Sequence) and look for their respective signatures. Both fail gracefully — they return Err rather than panic on unrecognized or malformed input.

I/O is sequential Seek + Read with an 8 MB chunk size for the extract / cat paths. There is no mmap.


Invariants and extension points

These are the rules the codebase relies on. Break them and something in CI or downstream will notice.

Invariants

  1. extract_node never escapes its output directory. Names are validated and resolved paths are checked against the canonical root. New extract code paths must use safe_join, not Path::join.
  2. Read-only. No code path writes to the input file. Open in read mode and never seek past EOF without bounds-checking first.
  3. No mmap, no panics. Use Seek + Read with the existing chunk size constant. Parsers return Err (never panic) on bad input; a .unwrap() on parser-derived data is a bug.
  4. u64 for lengths, clamp before usize cast. Anywhere a u64 file length meets a usize buffer, clamp by the buffer size first, then cast. This is what keeps 32-bit targets safe on > 4 GB files.
  5. Paths normalize the same way everywhere. path.trim_start_matches('/') is the canonical normalization inside find_node. Use it; don't reinvent it.
  6. TreeNode is the wire format between parsers and the rest. New parsers must produce a TreeNode tree; new consumers must accept one.
  7. Zero runtime dependencies. Adding a [dependencies] entry needs a real justification in the PR — see CONTRIBUTING.md. The point of being pure-Rust-and-tiny is that downstream consumers can adopt the crate without auditing a tree.
  8. Promptlog gate. Every PR that changes src/ or Cargo.toml commits a prompts/YYYYMMDD-HHMMSS-<slug>.md file. CI enforces this.

Extension points

You want to… Touch this
Support a new on-disc filesystem (HFS+, exFAT, FAT) Add src/<fs>.rs exposing parse_<fs>{,_verbose}. Register it in detect_and_parse_filesystem_verbose in src/lib.rs after the existing tries.
Add a new metadata field to entries (timestamps, permissions) Add fields to TreeNode in src/tree.rs, populate from each parser, render where appropriate. TreeNode is not #[non_exhaustive], so adding a pub field is a breaking change for downstream code that constructs or destructures it; bump the major version, or gate behind a TreeNode::builder() pattern.
Make parsing faster Look at EXTRACT_CHUNK_SIZE in src/lib.rs and the inner read loops in iso9660.rs / udf.rs. No mmap, no unsafe.
Add a new diagnostic in -verbose mode eprintln! from inside the parser, gated on verbose.
Improve docs.rs landing Crate-level //! doc at the top of src/lib.rs controls the docs.rs front page.

If you want a CLI

There isn't one (any more — see CHANGELOG). About 50 lines of main.rs on top of isomage reproduces the previous isomage IMAGE, isomage -c PATH IMAGE, isomage -x PATH IMAGE behaviour:

use std::env;
use std::fs::File;
use std::io;
use std::process::ExitCode;
use isomage::{detect_and_parse_filesystem, cat_node, extract_node, TreeNode};

fn main() -> ExitCode {
    let args: Vec<String> = env::args().collect();
    let usage = "usage: isomage [-c PATH | -x PATH [-o DIR]] IMAGE";
    let (mode, target, out, image) = match args.as_slice() {
        [_, image]                          => ("list", "",      ".", image.clone()),
        [_, flag, path, image] if flag=="-c"=> ("cat",  path.as_str(), ".", image.clone()),
        [_, flag, path, image] if flag=="-x"=> ("ext",  path.as_str(), ".", image.clone()),
        [_, "-x", path, "-o", dir, image]   => ("ext",  path.as_str(), dir, image.clone()),
        _ => { eprintln!("{usage}"); return ExitCode::from(2); }
    };
    let mut iso = match File::open(&image) {
        Ok(f) => f, Err(e) => { eprintln!("open {image}: {e}"); return ExitCode::from(1); }
    };
    let root = match detect_and_parse_filesystem(&mut iso, &image) {
        Ok(r) => r, Err(e) => { eprintln!("parse {image}: {e}"); return ExitCode::from(1); }
    };
    let result: isomage::Result<()> = match mode {
        "list" => { walk(&root, 0); Ok(()) }
        "cat"  => {
            let n = root.find_node(target).ok_or("not in ISO")?;
            cat_node(&mut iso, n, &mut io::stdout().lock())
        }
        "ext"  => {
            let n = root.find_node(target).ok_or("not in ISO")?;
            extract_node(&mut iso, n, out)
        }
        _ => unreachable!(),
    };
    match result {
        Ok(())  => ExitCode::SUCCESS,
        Err(e)  => { eprintln!("{e}"); ExitCode::from(1) }
    }
}

fn walk(n: &TreeNode, d: usize) {
    println!("{:w$}{} {} ({} B)", "",
        if n.is_directory { "d" } else { "-" }, n.name, n.size, w=d*2);
    for c in &n.children { walk(c, d+1); }
}

If you need a packaged CLI as a Cargo package, fork it; the maintainer of isomage is intentionally not distributing one.


Build and test

make test-data       # generate the synthetic ISOs under test_data/
cargo test           # 30 unit tests + 5 doc-tests
cargo doc --open     # browse the rustdoc locally

CI runs test (macOS + Ubuntu), fmt, clippy --all-targets -D warnings, doc --no-deps with RUSTDOCFLAGS="-D warnings", MSRV-build (Rust 1.74), cargo audit against Cargo.lock, and a cargo package contents check so we don't accidentally ship prompts/ or test data to crates.io.

Release flow:

  1. Bump version in Cargo.toml and add a ## [vX.Y.Z] block to CHANGELOG.md.
  2. Add a prompts/ entry recording the bump (the CI gate watches Cargo.toml).
  3. Merge to main.
  4. Tag vX.Y.Z and push the tag. .github/workflows/release.yml creates a GitHub Release with auto-generated notes and runs cargo publish. That's it — no binaries, no Homebrew tap.

Security

isomage parses untrusted binary data. Vulnerability reports should go to GitHub's private security advisories, not the public issue tracker — see SECURITY.md for the full policy. The current hardening surface (path-traversal guards, BrokenPipe tolerance, 64-bit-safe extract loops) is summarized there.


Contributing

See CONTRIBUTING.md and the PR template.

The repo follows the promptlog pattern: every PR that changes src/ or Cargo.toml commits a sanitized log of the prompts that led to the change. The spec is in prompts/PROMPTLOG.md; agents can use the promptlog skill; the CI gate enforces it.

If you're an AI agent reading this, also read CLAUDE.md — that's the short rulebook for this repo.


Changelog

See CHANGELOG.md for the curated change list per release. Auto-generated release notes also live on each GitHub Release.

The previous CLI binary was discontinued in v2.0.0. If you installed via cargo install isomage from a v1 release, that binary still works locally; future cargo install isomage will fail (the package no longer publishes a [[bin]]). The "If you want a CLI" snippet above reproduces the previous behaviour in your own project in ~50 lines.


License

MIT.