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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
# isomage

[![Crates.io](https://img.shields.io/crates/v/isomage.svg)](https://crates.io/crates/isomage)
[![docs.rs](https://img.shields.io/docsrs/isomage)](https://docs.rs/isomage)
[![CI](https://github.com/JackDanger/isomage/actions/workflows/ci.yml/badge.svg)](https://github.com/JackDanger/isomage/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![MSRV](https://img.shields.io/badge/MSRV-1.74-blue)](Cargo.toml)
[![Zero deps](https://img.shields.io/badge/dependencies-0-success)](Cargo.toml)

> **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]https://github.com/JackDanger/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.

```toml
[dependencies]
isomage = "2"
```

```rust
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](https://github.com/JackDanger/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`]https://github.com/JackDanger/gzippy | Deflate / GZip — the fastest gzip on any hardware; also provides the `deflate-gzippy` dep isomage can optionally use for compressed ZIP entries |
| [`lazippy`]https://github.com/JackDanger/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]#what-it-parses
- [Why a new crate]#why-a-new-crate
- [Public API]#public-api
- [Safety guarantees]#safety-guarantees
- [Examples]#examples
- [Architecture]#architecture
- [Invariants and extension points]#invariants-and-extension-points
- [If you want a CLI]#if-you-want-a-cli
- [Build and test]#build-and-test
- [Security]#security
- [Contributing]#contributing
- [Changelog]#changelog
- [License]#license

---

## Public API

Everything is on [docs.rs/isomage](https://docs.rs/isomage); this is
the cheat sheet.

| Item | What it does |
|---|---|
| [`detect_and_parse_filesystem`]https://docs.rs/isomage/latest/isomage/fn.detect_and_parse_filesystem.html | Try ISO 9660 then UDF; return the root `TreeNode`. |
| [`detect_and_parse_filesystem_verbose`]https://docs.rs/isomage/latest/isomage/fn.detect_and_parse_filesystem_verbose.html | Same, with spec-tagged diagnostics to stderr. |
| [`cat_node`]https://docs.rs/isomage/latest/isomage/fn.cat_node.html | Stream a file to any `std::io::Write`. BrokenPipe-tolerant. |
| [`extract_node`]https://docs.rs/isomage/latest/isomage/fn.extract_node.html | Extract a file or subtree to disk. Path-traversal-safe. |
| [`TreeNode`]https://docs.rs/isomage/latest/isomage/tree/struct.TreeNode.html | The parsed-tree model: file or directory, with byte-range references into the image. |
| [`TreeNode::find_node`]https://docs.rs/isomage/latest/isomage/tree/struct.TreeNode.html#method.find_node | Slash-separated path lookup, leading `/` tolerated. |
| [`isomage::iso9660`]https://docs.rs/isomage/latest/isomage/iso9660/index.html / [`isomage::udf`]https://docs.rs/isomage/latest/isomage/udf/index.html | The format-specific parsers, exposed for callers that already know what they have. |
| [`isomage::Error`]https://docs.rs/isomage/latest/isomage/type.Error.html / [`isomage::Result`]https://docs.rs/isomage/latest/isomage/type.Result.html | `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](SECURITY.md).

---

## Examples

### List all directories and files

```rust
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

```rust
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

```rust
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

```rust
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.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`

```rust
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`]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](CHANGELOG.md)). About
50 lines of `main.rs` on top of `isomage` reproduces the previous
`isomage IMAGE`, `isomage -c PATH IMAGE`, `isomage -x PATH IMAGE`
behaviour:

```rust
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

```sh
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](https://github.com/JackDanger/isomage/security/advisories/new),
not the public issue tracker — see [`SECURITY.md`](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](CONTRIBUTING.md) and the
[PR template](.github/pull_request_template.md).

The repo follows the [**promptlog pattern**](https://jackdanger.com/promptlog/):
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`](prompts/PROMPTLOG.md); agents can use the
[`promptlog`](.claude/skills/promptlog.md) skill; the CI gate
enforces it.

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

---

## Changelog

See [CHANGELOG.md](CHANGELOG.md) for the curated change list per
release. Auto-generated release notes also live on each
[GitHub Release](https://github.com/JackDanger/isomage/releases).

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](LICENSE).