isomage
A pure-Rust reader for ISO 9660 and UDF disc images. Zero dependencies. Read-only. No mount, no FUSE, no
unsafe.
[]
= "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
- ISO 9660 (ECMA-119), including the Joliet Unicode-filenames extension and the Rock Ridge POSIX long-filenames extension.
- UDF (ECMA-167), including metadata partitions and multi-extent files — enough for typical CDs, DVDs, and Blu-rays.
Detection is automatic: detect_and_parse_filesystem tries ISO 9660,
then UDF, returning whichever matches and a tagged-error string
listing both attempts if neither does.
Why a new crate
7z and friends already extract from ISO and UDF. isomage is for a
narrower audience:
- A Rust program that wants to inspect an ISO without shelling out or pulling in a C/C++ FFI dep. There was no pure-Rust crate doing ISO 9660 + UDF together when this was written.
- Embedding into bigger systems: indexers, server-side preview generators, build tooling that needs to read installer ISOs in CI without spawning child processes.
- Investigating malformed discs. Every parser entry point has a
_verbosevariant that prints spec-section-tagged diagnostics (volume descriptors at sector 16, AVDP at 256, partition maps, etc.) to stderr — useful for figuring out why a particular disc won't read. - Auditable code. ~1.7k lines of safe Rust, zero
unsafe, zero runtime dependencies. You can read the whole parser in an afternoon.
If you just want to extract a movie disc on the command line, 7z x movie.iso is faster than getting Rust set up. This crate isn't
trying to replace that.
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 an io::Error rather than panic on unrecognized 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 returnio::Erroron 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. Adding new pub fields is non-breaking; reordering or removing existing ones is. |
| 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.