isomage
A pure-Rust reader and writer for disk images and filesystem formats. Zero default dependencies. No mount, no FUSE, no
unsafe.
isomageis 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.
[]
= "2"
use File;
use ;
let mut iso = open?;
let root = detect_and_parse_filesystem?;
// Walk the tree.
for child in &root.children
// Stream one file into any std::io::Write.
let hostname = root.find_node.ok_or?;
let mut buf = Vecnew;
cat_node?;
// Or extract a subtree to disk — names that try to escape via
// "../" or '/' are refused with a clear error, not silently written.
extract_node?;
# Ok::
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
- What it parses
- Why a new crate
- Public API
- Safety guarantees
- Examples
- Architecture
- Invariants and extension points
- If you want a CLI
- Build and test
- Security
- Contributing
- Changelog
- License
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:
-
extract_nodecannot 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/passwdproduces a clearErrrather than silently writing to the host filesystem. -
cat_nodedoes not panic on closed pipes. If the downstreamWritereturnsErrorKind::BrokenPipe,cat_nodereturnsOk(())— matching standard Unix| headsemantics. 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 File;
use ;
let mut iso = open?;
let root = detect_and_parse_filesystem?;
walk;
# Ok::
Stream one file to stdout
use File;
use io;
use ;
let mut iso = open?;
let root = detect_and_parse_filesystem?;
let node = root.find_node.ok_or?;
let mut stdout = stdout.lock;
cat_node?;
# Ok::
Extract a subtree to disk
use File;
use ;
let mut iso = open?;
let root = detect_and_parse_filesystem?;
let docs = root.find_node.ok_or?;
extract_node?;
# Ok::
Investigate a malformed disc
use File;
use detect_and_parse_filesystem_verbose;
let mut iso = open?;
// Prints to stderr: file size, signatures at key sectors, which
// parser tried what, where it gave up.
let _ = detect_and_parse_filesystem_verbose;
# Ok::
Architecture
The crate is small (~1.7k lines across four files). The natural reading
order is tree.rs → iso9660.rs → udf.rs → lib.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
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
extract_nodenever escapes its output directory. Names are validated and resolved paths are checked against the canonical root. New extract code paths must usesafe_join, notPath::join.- Read-only. No code path writes to the input file. Open in read mode and never seek past EOF without bounds-checking first.
- No mmap, no panics. Use
Seek + Readwith the existing chunk size constant. Parsers returnErr(never panic) on bad input; a.unwrap()on parser-derived data is a bug. u64for lengths, clamp beforeusizecast. Anywhere au64file length meets ausizebuffer, clamp by the buffer size first, then cast. This is what keeps 32-bit targets safe on > 4 GB files.- Paths normalize the same way everywhere.
path.trim_start_matches('/')is the canonical normalization insidefind_node. Use it; don't reinvent it. TreeNodeis the wire format between parsers and the rest. New parsers must produce aTreeNodetree; new consumers must accept one.- Zero runtime dependencies. Adding a
[dependencies]entry needs a real justification in the PR — seeCONTRIBUTING.md. The point of being pure-Rust-and-tiny is that downstream consumers can adopt the crate without auditing a tree. - Promptlog gate. Every PR that changes
src/orCargo.tomlcommits aprompts/YYYYMMDD-HHMMSS-<slug>.mdfile. 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 env;
use File;
use io;
use ExitCode;
use ;
If you need a packaged CLI as a Cargo package, fork it; the
maintainer of isomage is intentionally not distributing one.
Build and test
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:
- Bump
versioninCargo.tomland add a## [vX.Y.Z]block toCHANGELOG.md. - Add a
prompts/entry recording the bump (the CI gate watchesCargo.toml). - Merge to
main. - Tag
vX.Y.Zand push the tag..github/workflows/release.ymlcreates a GitHub Release with auto-generated notes and runscargo 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.