isomage
Browse and extract files from ISO images without mounting them.
No root. No FUSE. No mount points. Just read the bytes.
|
$ isomage movie.iso
d / (24.8 GB)
d BDMV (24.8 GB)
d STREAM (24.7 GB)
- 00000.m2ts (20.1 GB)
- 00001.m2ts (4.6 GB)
d CLIPINF (1.2 KB)
d PLAYLIST (408 B)
- CERTIFICATE (3.1 KB)
$ isomage -c BDMV/PLAYLIST/00000.mpls movie.iso | hexdump -C | head
00000000 4d 50 4c 53 30 32 30 30 00 00 00 ea 00 00 00
00000010 00 00 01 1a 00 00 00 00 00 01 00 00 00 01 00
$ isomage -x BDMV/STREAM/00001.m2ts -o ./extras movie.iso
Extracted: ./extras/00001.m2ts
Extraction completed successfully.
Contents
- What this is
- Install
- Quick reference
- Usage
- Output contract
- Supported formats
- Architecture
- Invariants and extension points
- Build, test, release
- Contributing
- License
What this is
isomage is a single-binary Rust CLI that reads ISO 9660 and UDF disc
images directly from disk and reconstructs their filesystem tree in
userspace. No kernel mount, no loopback device, no FUSE driver. The
binary opens the file, parses volume descriptors, walks directory
records, and resolves file extents — that's it.
Three modes:
| Mode | Flag | What it does |
|---|---|---|
| List | (none) | Print the directory tree to stdout |
| Cat | -c PATH |
Stream a single file's raw bytes to stdout |
| Extract | -x PATH |
Write a file or directory tree to disk |
Plus -v (verbose, parser diagnostics to stderr) and -o DIR (extract
output directory).
It is read-only by design: there is no code path that mutates an ISO image. The CLI is the only entry point; the library crate is re-exported so other Rust programs can embed the parser.
Install
Homebrew (macOS and Linux):
Cargo (any platform with Rust):
Binary — grab a prebuilt from releases:
# macOS (Apple Silicon)
|
# macOS (Intel)
|
# Linux (x86_64, static musl)
|
# Linux (ARM64, static musl)
|
From source:
&&
Quick reference
isomage IMAGE # list all files and directories
isomage -c PATH IMAGE # stream a file to stdout
isomage -x PATH IMAGE # extract a file or directory to disk
isomage -x PATH -o DIR IMAGE # extract into a specific directory
isomage -v IMAGE # verbose: show filesystem parsing details
PATH is a path inside the ISO. Leading slash is optional —
etc/hostname and /etc/hostname are equivalent. Use / with -x to
extract everything.
All diagnostic output (verbose, progress, errors) goes to stderr.
Only file data goes to stdout, so -c is binary-safe and
pipe-friendly.
Usage
List contents
d / (24.8 GB)
d BDMV (24.8 GB)
d STREAM (24.7 GB)
- 00000.m2ts (20.1 GB)
- 00001.m2ts (4.6 GB)
d CLIPINF (1.2 KB)
d PLAYLIST (408 B)
- CERTIFICATE (3.1 KB)
d = directory, - = file. Indentation is two spaces per level. Size
is human-readable (B, KB, MB, GB, TB) and includes
descendants for directories.
Stream a file to stdout (-c)
# Inspect a text file
# Pipe to other tools
|
# Page through a large file
|
# Play video directly from the ISO (verbose output stays on stderr)
|
# Stream to disk
-c does not buffer to a temp file; it preads in 8 MB chunks and
writes straight to stdout.
Extract to disk (-x)
# One file (into current directory)
# A directory tree, into a specific output directory
# The entire disc
The output directory is created if it doesn't exist. Per-file extraction paths are printed to stderr as each file completes. Files over 100 MB get a percentage progress meter on stderr.
Verbose / debug (-v)
File size: 26663725056 bytes (24.84 GB)
Scanning key sectors for filesystem signatures...
Sector 16 (ISO 9660 PVD / UDF VRS): 01 43 44 30 30 31 01 00 |.CD001..|
Sector 256 (UDF AVDP): 02 00 02 00 ...
Attempting ISO 9660 parsing...
Found Primary Volume Descriptor at sector 16
...
-v is safe to combine with -c — diagnostics stay on stderr:
|
Output contract
This is the contract the CLI guarantees. Don't break it without a major version bump.
| Mode | stdout | stderr |
|---|---|---|
| List | One line per entry: [indent]{d|-} NAME (SIZE) |
empty |
Cat (-c) |
The file's raw bytes, exactly | Errors only (and -v if set) |
Extract (-x) |
empty | Per-file Extracted: <path>, progress, errors |
Any mode + -v |
(same as above) | Parser diagnostics added |
Exit codes:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Any error (file not found, parse failure, path not in ISO, I/O error) |
Supported formats
- ISO 9660, including the Joliet (Unicode filenames) and Rock Ridge (POSIX long filenames) extensions
- UDF, including metadata partitions and multi-extent files
Covers CDs, DVDs, and Blu-rays. Detection is automatic: the parser tries ISO 9660 first, then UDF, and reports both errors if neither succeeds.
Architecture
isomage is small (~1.7k lines of Rust across five files). Read the modules in this order if you want to understand the whole system:
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
└── main.rs Clap CLI; orchestrates lib calls; owns stdout/stderr
Data model: TreeNode (src/tree.rs)
Everything the rest of the codebase touches is a 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.
Helpers on TreeNode:
find_node(path)— slash-separated path lookup. Leading slash is tolerated. Returns the node orNone.calculate_directory_size()— recursive sum of descendants. Called by the parser once after the tree is built.new_file_with_location(name, size, location, length)— the constructor parsers should use for real files.
Public library API (src/lib.rs)
The crate is exposed both as a binary and as a library. Embedders use four functions:
detect_and_parse_filesystem
detect_and_parse_filesystem is a thin wrapper that calls the
_verbose variant with false. The verbose variant prints a hex dump
of key sectors and the names of each parser it tries, all to stderr.
I/O is sequential pread-style reads using Seek + Read. There is no
mmap. The chunk size for both cat and extract is EXTRACT_CHUNK_SIZE = 8 MB.
Parsers
Both parsers expose the same pair of entry points:
parse_iso9660
The _verbose variants print spec-section-tagged diagnostics to
stderr. lib.rs always calls the _verbose variant and threads the
flag from the CLI.
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.
CLI (src/main.rs)
The CLI is intentionally thin. It parses args with clap, opens the
file, calls detect_and_parse_filesystem_verbose, and dispatches to
one of three branches:
- List — recursive
print_treeto stdout - Cat —
find_nodethencat_nodeinto a lockedstdout - Extract —
find_nodethenextract_nodeto the output directory
print_tree is the only stdout-side renderer; everything else uses
eprintln!. MAX_TREE_DEPTH = 100 guards against pathological inputs.
format_size is the human-readable size formatter (1024.0-based
binary units, despite the units being labelled KB/MB/GB/TB).
Invariants and extension points
These are the rules the codebase relies on. Break them and something in CI or downstream will notice.
Invariants
- stdout is sacred in
-cand-x. Only-cwrites file bytes to stdout;-xwrites nothing to stdout. Everything else goes to stderr. Don'tprintln!fromlib.rs— useeprintln!. - 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. isomage targets large Blu-ray images (50+ GB). Use
Seek+Readwith the existing chunk size (EXTRACT_CHUNK_SIZE). - No panic on bad input. Parsers return
io::Error; the CLI maps errors to exit code 1. If you find a.unwrap()on parser-derived data, it's a bug. - Paths normalize the same way everywhere.
path.trim_start_matches('/')is the canonical normalization. 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.- 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 CLI subcommand | Extend Cli in src/main.rs. Keep the stdout/stderr contract above. |
| Add a new metadata field to entries (timestamps, permissions) | Add fields to TreeNode in src/tree.rs, populate from each parser, render where appropriate. |
| Make parsing faster | Look at EXTRACT_CHUNK_SIZE in src/lib.rs and the inner read loops in iso9660.rs / udf.rs. No mmap. |
Add a new diagnostic in -v mode |
eprintln! from inside the parser, gated on verbose. |
Build, test, release
Cross-compile from macOS to Linux:
Release flow:
- Bump
versioninCargo.toml. - Add a
prompts/entry recording the bump (the CI gate fires onCargo.toml). - Merge to
main. - Tag
vX.Y.Zand push the tag..github/workflows/release.ymlbuilds binaries for all four targets, creates a GitHub Release, publishes to crates.io, and updates the Homebrew tap.
Contributing
See CONTRIBUTING.md.
isomage follows the promptlog pattern:
every PR that changes source code commits a sanitized log of the
prompts that led to the change. AI agents and humans both follow the
same rule. The spec is in prompts/PROMPTLOG.md;
agents can use the promptlog skill;
the CI gate in .github/workflows/ci.yml
enforces it.
If you're an AI agent reading this, also read CLAUDE.md —
that's the short rulebook for this repo.
Why
I got tired of leaving a container just to mount an image just to
read one file. isomage runs entirely in userspace — it reads the
raw bytes and reconstructs the filesystem tree itself.
Limitations
- Read-only (by design)
- Some exotic UDF variations might not parse correctly
If you hit a disc that doesn't work, run with -v and open an issue.