diff --git a/README.md b/README.md index 27c19c8..9560bd1 100644 --- a/README.md +++ b/README.md @@ -2,108 +2,105 @@ [![CI](https://github.com/bgrewell/iso-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/bgrewell/iso-kit/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/bgrewell/iso-kit/graph/badge.svg?token=D15C46IECF)](https://codecov.io/gh/bgrewell/iso-kit) +[![Go Reference](https://pkg.go.dev/badge/github.com/bgrewell/iso-kit.svg)](https://pkg.go.dev/github.com/bgrewell/iso-kit) -**iso-kit** is a Go library for working with ISO 9660 disk images: open existing -images, modify them, or build new ones from scratch — with Rock Ridge, Joliet, -El Torito boot, hybrid (USB-bootable) layouts, and read-only UDF support. +Work with ISO disk images in Go — or straight from the command line. -> **Notice:** The API is pre-1.0 and may still change between releases. +Open an ISO and pull files out of it. Change what's inside and save it back. +Build a brand-new image from a folder, including ones that boot on real +hardware from a USB stick. -## Features +## Command line tools -- **Read**: parse ISO 9660 images including Rock Ridge (POSIX metadata, - symlinks), Joliet (Unicode names), El Torito boot catalogs, multi-extent - (>4 GiB) files, and path tables. Extract full trees to disk, symlinks - included. -- **Create**: build images from scratch or from a local directory tree. - Rock Ridge is written by default; Joliet is opt-in; identifiers can be - enforced at ISO 9660 interchange levels 1–3. -- **Modify**: open an image, add/remove files and directories, and save — - existing file content is streamed and relocated, never fully loaded into - memory. -- **Boot**: register BIOS and EFI El Torito boot entries (with isolinux - boot-info-table patching), and write hybrid MBR/GPT partition structures so - images boot from USB media. -- **UDF**: read-only support for ECMA-167 / UDF images (listing, reading, - extraction). +```bash +go install github.com/bgrewell/iso-kit/cmd/isoextract@latest +go install github.com/bgrewell/iso-kit/cmd/isocreate@latest +go install github.com/bgrewell/iso-kit/cmd/isoview@latest +``` -## Library usage +**Extract an ISO:** -```go -import ( - "os" - - "github.com/bgrewell/iso-kit/pkg/iso9660" - "github.com/bgrewell/iso-kit/pkg/option" -) - -// Create an image from scratch. -img, _ := iso9660.Create("MYVOLUME", option.WithJolietEnabled(true)) -img.AddFile("docs/readme.txt", []byte("hello\n")) -img.AddLocalDirectory("./payload", "/payload") -out, _ := os.Create("out.iso") -img.Save(out) +```bash +isoextract -o ./extracted ubuntu-24.04.iso +``` -// Open, modify, save. -f, _ := os.Open("existing.iso") -img2, _ := iso9660.Open(f) -data, _ := img2.ReadFile("some/file.txt") -img2.AddFile("added.txt", data) -img2.RemoveFile("obsolete.txt") -out2, _ := os.Create("modified.iso") -img2.Save(out2) +**Build an ISO from a folder:** + +```bash +isocreate -V "MY_BACKUP" -o backup.iso ./my-files ``` -Bootable, USB-writable images: +**Build a bootable, USB-writable ISO:** -```go -img.AddBootImage(iso9660.BootImageConfig{ - Path: "isolinux/isolinux.bin", Platform: boot.BIOS, - Emulation: boot.NoEmulation, LoadSize: 4, BootInfoTable: true, -}) -img.AddBootImage(iso9660.BootImageConfig{ - Path: "EFI/BOOT/efiboot.img", Platform: boot.EFI, Emulation: boot.NoEmulation, -}) -img.SetHybridBoot(iso9660.HybridBootConfig{ - MBRBootCode: isohdpfx, // e.g. syslinux isohdpfx.bin - EFIBootImagePath: "EFI/BOOT/efiboot.img", - AddGPT: true, -}) +```bash +isocreate -V "MY_LINUX" -o my-linux.iso \ + --bios-boot isolinux/isolinux.bin \ + --efi-boot EFI/BOOT/efiboot.img \ + --isohybrid-mbr isohdpfx.bin --gpt \ + ./my-linux-root ``` -## Command line tools +**Look inside an ISO:** ```bash -go install github.com/bgrewell/iso-kit/cmd/isoextract@latest -go install github.com/bgrewell/iso-kit/cmd/isocreate@latest -go install github.com/bgrewell/iso-kit/cmd/isoview@latest +isoview ubuntu-24.04.iso +``` + +## Using the library + +```bash +go get github.com/bgrewell/iso-kit +``` + +```go +import "github.com/bgrewell/iso-kit/pkg/iso9660" + +// Open an ISO and read a file out of it. +f, _ := os.Open("image.iso") +img, _ := iso9660.Open(f) +data, _ := img.ReadFile("docs/readme.txt") + +// Change it and save a new copy. +img.AddFile("extras/new-file.txt", []byte("added!\n")) +img.RemoveFile("obsolete.txt") +out, _ := os.Create("modified.iso") +img.Save(out) ``` -- **isoextract** — extract files and boot images from an ISO -- **isocreate** — build an ISO from a directory tree - (`isocreate -V MYVOL -o out.iso ./srcdir`, plus `--bios-boot`, - `--efi-boot`, `--isohybrid`, `--gpt`, `--joliet`, `--level`) -- **isoview** — inspect image structure and layout +```go +// Or build one from scratch. +img, _ := iso9660.Create("MYVOLUME") +img.AddLocalDirectory("./payload", "/") +out, _ := os.Create("new.iso") +img.Save(out) +``` -*Note: ensure `$GOBIN` is in your `$PATH` -(`export PATH=$PATH:$(go env GOPATH)/bin`).* +That's the whole core loop: `Open` or `Create`, change things, `Save`. +Long filenames, mixed case, permissions, and symlinks are preserved +automatically (Rock Ridge is on by default). See the +**[usage guide](docs/USAGE.md)** for bootable images, Windows-friendly +naming (Joliet), and everything else. -## Format support +## What it supports -| Capability | Read | Write | -|---|---|---| +| | Read | Write | +|---|:---:|:---:| | ISO 9660 | ✅ | ✅ | -| Rock Ridge (SUSP/RRIP: SP, CE, ER, PX, NM, SL, TF, PN, CL, PL, RE) | ✅ | ✅ | -| Joliet (UCS-2 hierarchy) | ✅ | ✅ | -| El Torito (multi-boot, BIOS + EFI sections, boot info table) | ✅ | ✅ | -| Hybrid MBR / GPT (USB boot) | ✅ | ✅ | -| Multi-extent files (>4 GiB) | ✅ | ❌ | -| UDF (ECMA-167) | ✅ | ❌ | +| Rock Ridge — POSIX names, permissions, symlinks | ✅ | ✅ | +| Joliet — Windows Unicode names | ✅ | ✅ | +| El Torito — BIOS + EFI boot | ✅ | ✅ | +| Hybrid MBR/GPT — boots from USB | ✅ | ✅ | +| Files over 4 GiB (multi-extent) | ✅ | — | +| UDF | ✅ | — | + +Output is verified against independent tools (xorriso, fdisk, parted) in CI. -Interoperability is verified in CI against xorriso (Rock Ridge, Joliet, -El Torito reporting) and util-linux fdisk / parted (hybrid partition tables). +## Documentation -## Roadmap +- **[Usage guide](docs/USAGE.md)** — the full tour: options, bootable + images, modifying existing ISOs, CLI reference, limitations +- **[Architecture](docs/ARCHITECTURE.md)** — how the library works inside, + for contributors +- **[Roadmap](docs/ROADMAP.md)** — what's done and what's planned -See [docs/ROADMAP.md](docs/ROADMAP.md) for the detailed phase plan and -remaining work (UDF write support, multi-extent write, and more). +> The API is pre-1.0 and may still change between releases. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..ea6595f --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,317 @@ +# iso-kit architecture + +Internal documentation for contributors: how the library is structured, how +the read and write paths work, and the invariants that keep images valid. +User-facing documentation lives in [USAGE.md](USAGE.md). + +- [Package map](#package-map) +- [The read path: Open](#the-read-path-open) +- [The mutable tree](#the-mutable-tree) +- [The write path: Pack and Save](#the-write-path-pack-and-save) +- [Rock Ridge internals](#rock-ridge-internals) +- [Joliet internals](#joliet-internals) +- [El Torito internals](#el-torito-internals) +- [Hybrid boot internals](#hybrid-boot-internals) +- [UDF read path](#udf-read-path) +- [Key invariants](#key-invariants) +- [Testing strategy](#testing-strategy) +- [Adding a feature](#adding-a-feature) + +## Package map + +``` +iso.go Format detection, common ISO interface (iso9660 / udf dispatch) +pkg/ + iso9660/ The ISO 9660 implementation (the bulk of the library) + iso9660.go Public API: Open, Create, mutation, Save, Extract + pack.go Layout engine: sector assignment, record planning + names.go Identifier mangling (ISO + Joliet), collision dedupe + parser/ Sector-level parsing of an existing image + descriptor/ Volume descriptors (PVD, SVD, boot record, partition, terminator) + directory/ Directory records and file flags + pathtable/ L/M path table records + tree/ Mutable in-memory directory tree (source of truth) + extensions/ SUSP/RRIP (Rock Ridge) entry building and parsing + boot/ El Torito boot catalog + systemarea/ System area, MBR, GPT + encoding/ Both-byte-order integers, date/time formats, UCS-2 + validation/ Character sets and interchange-level rules + xattr/ Extended attribute records (parsing) + info/ ImageObject interface for layout inspection + udf/ Read-only UDF (ECMA-167) implementation + filesystem/ FileSystemEntry: flat entry list shared by both formats + option/ Open/Create option structs + consts/, helpers/, logging/, version/ +``` + +Dependency direction: `iso9660.go` and `pack.go` orchestrate; the +subpackages (`descriptor`, `directory`, `tree`, ...) do not import each +other except through the small leaf packages (`encoding`, `consts`, +`info`). The `tree` package depends only on the standard library. + +## The read path: Open + +`iso9660.Open(reader)` walks an existing image: + +``` +sectors 0-15 system area → kept verbatim (may hold MBR/GPT) +sector 16+ volume descriptors → parser.GetPrimaryVolumeDescriptor etc. + boot record → El Torito catalog (boot.UnmarshalBinary) + SVDs → Joliet detection via escape sequences + terminator +path tables → parsed for layout inspection (not used for traversal) +directory tree → parser.WalkDirectoryRecords / BuildFileSystemEntries +``` + +Directory traversal reads each directory extent, decoding records +sector-by-sector (records never cross sector boundaries; a zero length +byte means "skip to the next sector"). For each record: + +- **Rock Ridge**: the system use field is parsed into + `extensions.RockRidgeExtensions`; SUSP `CE` entries chain to + continuation areas, which the parser follows (bounded at 8 hops). +- **Multi-extent files**: consecutive records with the `MultiExtent` flag + merge into one entry backed by `filesystem.MultiExtentReader`, which + presents the concatenated extents as a single `io.ReaderAt`. +- The result is a flat `[]*filesystem.FileSystemEntry` (paths, sizes, + modes, symlink targets) plus the mutable tree built from it. + +Open chooses one hierarchy to traverse: primary (with Rock Ridge names) by +default, or Joliet with `WithPreferJoliet(true)`. + +## The mutable tree + +`tree.Node` is the source of truth between Open/Create and Save. Every +file node has exactly one content source: + +- **Pending**: in-memory `[]byte` (added via `AddFile`), or +- **Reader-backed**: an `io.ReaderAt` plus sector location and size + (parsed from an existing image). + +This split is what makes open→modify→save memory-safe: existing content is +streamed from the source image to its new location at save time and never +fully materialized. `Node.SourceLocation()` exposes the original extent +sector, which the El Torito preservation logic uses to re-match boot images +after relocation. + +Mutations (`AddFile`, `RemoveFile`, ...) set a dirty flag on the `ISO9660` +struct and invalidate the flat entry cache; `ListFiles` lazily rebuilds it +from the tree. + +## The write path: Pack and Save + +`Save` dispatches: a clean opened image re-serializes parsed structures at +their original offsets (passthrough); anything dirty or created runs the +full rebuild — `Pack()` then a sequential write. + +`Pack()` assigns every structure a sector. The layout, in order: + +``` +0-15 system area (verbatim; hybrid MBR/GPT patched in later) +16 PVD +17 boot record (when El Torito entries exist — spec requires sector 17) +next SVD (when Joliet) +next terminator +next primary L path table, M path table +next Joliet L/M path tables (when Joliet) +next Rock Ridge continuation region (when RR: ER entry + SU overflow) +next boot catalog sector (when El Torito) +next primary directory extents, breadth-first +next Joliet directory extents +next file extents (shared by both hierarchies) +[+40 LBAs] backup GPT (hybrid GPT mode only, appended after the ISO) +``` + +The ordering trick: **sizes are computed before locations**. Directory +extent sizes depend only on record lengths (identifiers + system-use +sizes), never on the values inside the records, so Pack can size +everything, assign sectors in one pass, and then fill in cross-references +(PVD/SVD totals, path table locations, the root record, CE pointers, +catalog extents). + +### Record plans + +For every directory record to be written, `buildPlans` produces a +`recordPlan`: the on-disk identifier plus the SUSP entries destined for +its system use field, split between: + +- **inline** — what fits in the record (max total record length is 255 + bytes, one length byte), and +- **continuation** — the overflow, placed in the continuation region and + referenced by a 28-byte `CE` entry reserved in the inline budget. + +The root `.` record always leads with `SP` and defers the 237-byte `ER` +entry to the continuation region. Continuation chunks never cross a sector +boundary (a SUSP requirement), which `assignContinuationOffsets` enforces +when laying chunks into the region. + +### Writing + +`saveRebuild` then writes each region in order. File extents stream through +`io.Copy` from each node's content reader and zero-pad to sector +boundaries, so every allocated sector is written and the image is exactly +`VolumeSpaceSize × 2048` bytes (plus the GPT tail in hybrid GPT mode). +Post-passes patch boot info tables into boot images and hybrid partition +structures into the system area — both need final locations, so they run +last. + +## Rock Ridge internals + +`pkg/iso9660/extensions` implements SUSP (IEEE P1281) and RRIP (P1282): + +- Entry builders produce exact binary layouts: `PX` (36-byte form), + `PN`, `SL` (component records with root/parent/current flags), `NM` + (multi-entry continuation for long names), `TF` (7-byte recording + format), `CL`/`PL`/`RE`, plus SUSP `SP`/`CE`/`ER`/`ST` and the legacy + `RR` bitmap entry. +- The parser (`ParseInto`) is tolerant: unknown entries are skipped, `ST` + terminates, both TF forms (7 and 17 byte) are handled, and NM/SL + continuations accumulate across entries. +- On write, every record gets `RR`+`PX`+`TF`; children add `NM`; + symlinks add `SL`. Identifier mangling happens in `names.go` + (uppercase, d-characters, 31-char cap or 8.3 at level 1, deterministic + `~N` dedupe) with the POSIX name preserved in `NM`. + +Write policy: created images write RR by default +(`WithCreateRockRidgeEnabled(false)` opts out); opened images write RR only +if the source had it, keeping plain-ISO round-trips byte-exact. + +## Joliet internals + +Joliet is a second, parallel directory hierarchy under an SVD whose escape +sequences (`%/@`, `%/C`, `%/E`) mark UCS-2 identifier encoding: + +- Identifiers are sanitized (Joliet forbids `*/:;?\` and control chars), + truncated to 64 UTF-16 units, deduplicated, then **pre-encoded to UCS-2 + big-endian** in the record plans — the directory record marshal writes + identifier bytes verbatim, so no encoding logic lives there. +- Joliet directory extents and path tables get their own sectors; file + extents are shared with the primary hierarchy. An `extentRef` resolver + passed to the record marshal picks the right directory extent per + hierarchy. + +## El Torito internals + +The boot record descriptor (sector 17) points at a one-sector boot catalog: +validation entry, initial/default entry, then section headers (0x90/0x91) +grouping section entries by platform. + +Two parsing subtleties worth knowing: + +- Entry fields live at spec offsets (byte 1 media type, bytes 2-3 load + segment, byte 4 system type); the platform comes from the validation + entry or section header, *not* the entry itself. +- A 0x00 boot indicator means both "not bootable" and "end of catalog". + Only the section header count disambiguates — entries promised by a + header are consumed before the end-of-catalog check applies. + +On write, entries reference tree files by path; Pack resolves them to +packed extents. When modifying an opened bootable image, parsed entries are +re-matched to tree files by source extent location, with a raw sector-copy +fallback for boot images that have no filesystem counterpart (hidden boot +images). The optional boot info table (56 bytes at image offset 8: PVD LBA, +image LBA, length, word-sum checksum from offset 64) is patched after the +image content is written. + +## Hybrid boot internals + +`systemarea` models the two partition schemes written into the 32 KB +system area: + +- **MBR**: 440 bytes boot code, four-entry partition table, 0x55AA + signature. CHS tuples are synthesized for the 64-head/32-sector geometry + isohybrid assumes, saturating at the CHS limit. +- **GPT**: primary header (LBA 1) + 128-entry array (LBA 2-33) + backup + at the end of the device, CRC32s over header and array. GUIDs are + derived deterministically so image builds are reproducible. + +Mode selection matters for interop: partition tools (libfdisk, parted) +ignore a GPT unless the MBR contains a protective 0xEE entry. So MBR-only +mode writes the classic isohybrid layout (bootable whole-image partition + +0xEF ESP entry, Debian-style) while GPT mode writes a protective MBR and +moves the ESP into the GPT (Fedora-style). The backup GPT lives in a +2048-aligned region appended after the ISO data. + +## UDF read path + +`pkg/udf` is an independent, read-only ECMA-167 implementation: + +``` +sector 16+ Volume Recognition Sequence → BEA01 / NSR0x / TEA01 (detection) +sector 256 Anchor Volume Descriptor Pointer + → main VDS (reserve VDS fallback) +VDS PVD (identity), Partition Descriptor (block → sector mapping), + Logical Volume Descriptor (block size, File Set location) +FSD → root directory ICB +ICBs File Entry / Extended File Entry; short_ad, long_ad, or + inline allocation; File Identifier Descriptors per directory +``` + +Descriptor tags are checksum-verified; names decode from OSTA compressed +unicode (8-bit and UCS-2 forms); symlink ICBs decode 4/14.16 path component +sequences. File content reuses `filesystem.MultiExtentReader` over the +resolved extents. Directory walking is depth-bounded against reference +cycles. All mutation APIs return `ErrWriteUnsupported`. + +## Key invariants + +Violating any of these produces images other tools reject: + +1. **Directory records never cross sector boundaries.** The extent writer + zero-pads to the next sector when a record would not fit; readers treat + a zero length byte as "next sector". +2. **Record length ≤ 255 bytes and even.** One length byte; the identifier + pad byte plus a trailing SU pad byte maintain evenness. +3. **Numeric fields are both-byte-order** (little- then big-endian) unless + the spec says otherwise (path table locations are single-endian per + table flavor). `encoding.UnmarshalUint32LSBMSB` rejects mismatched + halves as corruption. +4. **Path table records are breadth-first**, parents before children, + numbering starting at 1 — directory numbers are array indices + 1. +5. **The boot record must be at sector 17** when El Torito is present. +6. **SUSP continuation areas fit within one logical block** (offset + + length ≤ 2048). +7. **Every allocated sector is written.** The rebuild never leaves gaps, so + image size always equals `VolumeSpaceSize × 2048` (+ GPT tail). +8. **`sizes before locations`** in Pack: nothing size-relevant may depend + on an assigned location. If you add a structure whose size depends on + where things land, you have a two-pass problem — look at how CE + pointers handle it (fixed-size reservation, value filled later). + +## Testing strategy + +Three layers, all in the standard `go test` suite: + +1. **Unit tests** pin binary layouts: marshal/unmarshal round-trips assert + exact bytes and value recovery, corrupt input is rejected, and semantic + contracts (CHS saturation, GPT CRCs, load-size saturation) hold. +2. **End-to-end round-trips**: Create→Save→Open→verify and + Open→modify→Save→Open across every feature combination (Rock Ridge + names, Joliet hierarchies, boot catalogs, hybrid layouts). For formats + we cannot author (UDF, multi-extent), tests construct spec-valid images + byte-by-byte and parse those. +3. **Interop verification**: generated images are checked with independent + implementations — xorriso (Rock Ridge names/modes, Joliet trees, + `-report_el_torito`), isoinfo, fdisk and parted (partition tables). + These tests skip when tools are absent locally; CI installs them so + they always run there. + +The interop layer is the important one: internal round-trips can pass with +symmetrical bugs (the pre-rewrite El Torito offsets did exactly that), and +only a foreign reader catches them. + +## Adding a feature + +The typical path for a new on-disk structure: + +1. Model it in the right subpackage with `Marshal`/`Unmarshal` and a unit + test pinning the byte layout (write the test from the spec, not from + the implementation). +2. Parse it in `parser/` (read side) and surface it on the entry/tree. +3. Extend `Pack()`: size it, place it in the layout order above, update + cross-references. Respect invariant 8. +4. Write it in `saveRebuild` at its assigned location. +5. Add a round-trip test, and an interop assertion if any external tool + can see the structure. + +Deferred work and known gaps are tracked in [ROADMAP.md](ROADMAP.md). diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..21d6bb6 --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,353 @@ +# iso-kit usage guide + +The complete tour of the library and CLI tools. For a quick start, see the +[README](../README.md); for internals, see [ARCHITECTURE.md](ARCHITECTURE.md). + +- [Opening images](#opening-images) +- [Inspecting an image](#inspecting-an-image) +- [Reading and extracting](#reading-and-extracting) +- [Creating images](#creating-images) +- [Adding and removing content](#adding-and-removing-content) +- [Modifying existing images](#modifying-existing-images) +- [Bootable images](#bootable-images) +- [USB-bootable (hybrid) images](#usb-bootable-hybrid-images) +- [Names: Rock Ridge, Joliet, and interchange levels](#names-rock-ridge-joliet-and-interchange-levels) +- [UDF images](#udf-images) +- [CLI reference](#cli-reference) +- [Limitations and gotchas](#limitations-and-gotchas) + +## Opening images + +The top-level `iso` package detects the format (ISO 9660 or UDF) and returns +a common interface: + +```go +import "github.com/bgrewell/iso-kit" + +img, err := iso.Open("image.iso") +defer img.Close() +``` + +When you know the format — or need the full ISO 9660 API (modification, +boot configuration) — use the `iso9660` package directly with any +`io.ReaderAt`: + +```go +import ( + "github.com/bgrewell/iso-kit/pkg/iso9660" + "github.com/bgrewell/iso-kit/pkg/option" +) + +f, _ := os.Open("image.iso") +img, err := iso9660.Open(f, + option.WithRockRidgeEnabled(true), // default: POSIX names/permissions from Rock Ridge + option.WithPreferJoliet(false), // set true to walk the Joliet hierarchy instead + option.WithElToritoEnabled(true), // default: parse the boot catalog +) +``` + +Notes on open options: + +- **`WithRockRidgeEnabled(true)`** (default) — names, permissions, uid/gid, + timestamps, and symlinks come from the Rock Ridge extensions when present. + Disable it to see raw ISO 9660 identifiers (`FILE.TXT;1`). +- **`WithPreferJoliet(true)`** — walk the Joliet (Windows Unicode) directory + hierarchy instead of the primary one. Useful for images authored for + Windows where the primary names are mangled. +- **`WithStripVersionInfo(true)`** (default) — hide the `;1` version suffix + when neither Rock Ridge nor Joliet supplies a better name. +- **`WithExtractionProgress(callback)`** — receive per-file progress during + `Extract`. + +## Inspecting an image + +```go +img.GetVolumeID() // volume identifier +img.GetDataPreparerID() // authoring tool +img.GetCreationDateTime() // volume timestamps +img.GetVolumeSize() // size in 2048-byte sectors + +img.HasRockRidge() // extensions present? +img.HasJoliet() +img.HasElTorito() + +files, _ := img.ListFiles() // flat list of all files +dirs, _ := img.ListDirectories() // flat list of all directories +boots, _ := img.ListBootEntries() // El Torito boot images + +layout := img.GetLayout() // every on-disk structure with offsets +``` + +Each entry from `ListFiles` carries `Name`, `FullPath`, `Size`, `Mode`, +`ModTime`, `UID`/`GID` (from Rock Ridge), and `SymlinkTarget` for symbolic +links. Entries can produce their content directly: + +```go +data, _ := entry.GetBytes() +sum, _ := entry.GetSHA256() +``` + +## Reading and extracting + +```go +// One file, by path. The ;1 version suffix is optional. +data, err := img.ReadFile("boot/grub/grub.cfg") + +// The whole image to a directory. Rock Ridge symlinks become real +// symlinks; permissions and timestamps are applied. +err = img.Extract("./extracted") +``` + +If the image is bootable and El Torito parsing is enabled, `Extract` also +writes the boot images to the `[BOOT]` subdirectory (configurable via +`WithBootFileExtractLocation`). + +## Creating images + +```go +img, err := iso9660.Create("MYVOLUME", + option.WithPreparerID("my-tool 1.0"), + option.WithCreateRockRidgeEnabled(true), // default + option.WithJolietEnabled(true), // opt-in + option.WithInterchangeLevel(0), // 0 = relaxed (default) +) +``` + +- **Rock Ridge** is written by default. Your file names, permissions, + ownership, timestamps, and symlinks are preserved exactly; the underlying + ISO 9660 identifiers are generated automatically (see + [names](#names-rock-ridge-joliet-and-interchange-levels)). +- **Joliet** adds a second directory hierarchy with UCS-2 names for Windows. + Both hierarchies share the same file data — the cost is a few sectors of + metadata. +- `Save` writes the complete image to any `io.WriterAt`: + +```go +out, _ := os.Create("new.iso") +defer out.Close() +err = img.Save(out) +``` + +## Adding and removing content + +```go +// In-memory content. Parent directories are created automatically. +img.AddFile("docs/notes/today.txt", []byte("content")) + +// Empty directory. +img.AddDirectory("var/empty") + +// Symbolic link (requires Rock Ridge, the default). +img.AddSymlink("current", "releases/v2") + +// Import a local directory tree, preserving permissions, modification +// times, and symlinks. +img.AddLocalDirectory("./payload", "/payload") + +// Remove things. RemoveDirectory removes the whole subtree. +img.RemoveFile("obsolete.txt") +img.RemoveDirectory("old-stuff") +``` + +Attributes of an added file default to mode 0644 (0755 for directories), +root ownership, and the current time. `AddLocalDirectory` carries the source +attributes over instead. + +## Modifying existing images + +Open, mutate, save — the same APIs as creation: + +```go +f, _ := os.Open("base.iso") +img, _ := iso9660.Open(f) + +img.AddFile("extra/hello.txt", []byte("added\n")) +img.RemoveFile("unwanted.dat") + +out, _ := os.Create("modified.iso") +img.Save(out) // full rebuild: layout is recalculated +``` + +What happens on save: + +- Content already in the source image is **streamed** to its new location — + a 4 GB file is never loaded into memory. +- Rock Ridge and Joliet are preserved if the source image had them. +- The El Torito boot catalog is preserved: entries are re-pointed at the + relocated boot files (or raw-copied when the boot image has no + corresponding file in the directory tree). +- An **unmodified** opened image takes a fast path that re-serializes parsed + structures without relayout. + +Keep the source reader open until `Save` completes — the rebuild reads file +content from it. + +## Bootable images + +Boot images are ordinary files in the image that get registered in the +El Torito boot catalog: + +```go +import "github.com/bgrewell/iso-kit/pkg/iso9660/boot" + +img.AddFile("isolinux/isolinux.bin", isolinuxBin) +img.AddFile("EFI/BOOT/efiboot.img", espImage) + +// BIOS entry: no emulation, load 4 virtual sectors, patch the boot +// info table (what isolinux expects). +img.AddBootImage(iso9660.BootImageConfig{ + Path: "isolinux/isolinux.bin", + Platform: boot.BIOS, + Emulation: boot.NoEmulation, + LoadSize: 4, + BootInfoTable: true, +}) + +// EFI entry: the firmware reads the whole ESP image. +img.AddBootImage(iso9660.BootImageConfig{ + Path: "EFI/BOOT/efiboot.img", + Platform: boot.EFI, + Emulation: boot.NoEmulation, +}) +``` + +The first image added becomes the initial/default entry; additional images +become section entries grouped by platform (the standard BIOS + UEFI +multi-boot layout). + +`BootInfoTable: true` patches a 56-byte table into the image at offset 8 +during save — isolinux requires this (`-boot-info-table` in mkisofs terms). + +## USB-bootable (hybrid) images + +Optical boot uses El Torito; booting from a USB stick requires partition +tables in the system area. `SetHybridBoot` writes them during save: + +```go +// BIOS-only hybrid (classic isohybrid layout): +isohdpfx, _ := os.ReadFile("isohdpfx.bin") // from the syslinux package +img.SetHybridBoot(iso9660.HybridBootConfig{ + MBRBootCode: isohdpfx, +}) + +// BIOS + UEFI hybrid with GPT: +img.SetHybridBoot(iso9660.HybridBootConfig{ + MBRBootCode: isohdpfx, + EFIBootImagePath: "EFI/BOOT/efiboot.img", + AddGPT: true, +}) +``` + +Two layouts are written depending on `AddGPT`: + +- **MBR-only**: a bootable whole-image partition (type `0xCD` by default) + plus a type `0xEF` entry over the ESP image. This is the classic + isohybrid layout (as used by Debian images). +- **GPT**: a protective MBR plus a GUID partition table carrying the EFI + System Partition, with a backup GPT at the end of the image (as used by + Fedora images). Partition tools require the protective MBR to honor a + GPT, which is why the ESP moves into the GPT in this mode. + +The result can be written to a USB drive with `dd` and booted on BIOS or +UEFI machines (given working boot loader images). + +## Names: Rock Ridge, Joliet, and interchange levels + +ISO 9660 identifiers are limited (uppercase A–Z, 0–9, `_`, one dot). The +extensions carry your real names: + +- With **Rock Ridge** (default), the identifier is generated — uppercased, + invalid characters replaced, truncated to 31 characters, deduplicated + with a `~N` tail — and the real name travels in the NM entry. + `lower case.txt` becomes `LOWER_CASE.TXT;1` on disk but reads back as + `lower case.txt`. +- With **Joliet**, names are stored in UCS-2 with a 64-character limit, + preserving case and most punctuation. +- With **neither**, names are written as-is. That maximizes byte-exact + round-trips but can produce non-conforming images; enable an interchange + level to enforce the rules instead. + +`WithInterchangeLevel(n)` selects the strictness applied at save time: + +| Level | File identifiers | Directory identifiers | +|---|---|---| +| 0 (default) | anything | anything | +| 1 | 8.3, d-characters | 8 characters | +| 2, 3 | 31 characters | 31 characters | + +With Rock Ridge enabled the mangler simply produces conforming identifiers +(8.3 at level 1). Without it, a non-conforming name makes `Save` fail with +an explanatory error rather than writing an invalid image. + +## UDF images + +UDF (used by video discs, large-file images, and some OS installers) is +supported read-only: + +```go +import "github.com/bgrewell/iso-kit/pkg/udf" + +f, _ := os.Open("movie.iso") +if udf.IsUDF(f) { + u, _ := udf.Open(f) + files, _ := u.ListFiles() + data, _ := u.ReadFile("VIDEO_TS/VTS_01_0.IFO") + u.Extract("./out") +} +``` + +The top-level `iso.Open` dispatches automatically: bridge images carrying +both ISO 9660 and UDF open through the richer ISO 9660 path. + +## CLI reference + +### isoextract + +``` +isoextract [options] + -o, --output Output directory (default ./extracted) + -b, --boot Also extract El Torito boot images + -bd, --bootdir Directory for boot images (default [BOOT]) + -rr, --rockridge Use Rock Ridge names/permissions (default true) + -s, --strip Strip ;1 version suffixes (default true) +``` + +### isocreate + +``` +isocreate [options] + -o, --output Output ISO path (required) + -V, --volid Volume identifier (default ISOIMAGE) + -p, --preparer Data preparer identifier + -rr, --rockridge Write Rock Ridge (default true) + -J, --joliet Write a Joliet hierarchy + -l, --level Interchange level to enforce (1/2/3, default 0) + -b, --bios-boot In-image path of the BIOS boot image + -e, --efi-boot In-image path of the EFI boot image + -H, --isohybrid Write hybrid MBR partition structures + -m, --isohybrid-mbr File with MBR boot code (e.g. isohdpfx.bin) + -g, --gpt Add a GPT with an ESP entry (requires --efi-boot) +``` + +### isoview + +``` +isoview Inspect image structure and layout +``` + +## Limitations and gotchas + +- **Files over 4 GiB** read fine (multi-extent assembly) but cannot yet be + written — `Save` fails with an explicit error rather than truncating. +- **UDF is read-only**; mutation APIs return `ErrWriteUnsupported`. +- **Symlinks require Rock Ridge.** In an image saved without it, symlinks + are silently absent (there is nowhere to record them). +- **Modifying drops nothing silently**: Joliet, Rock Ridge, and El Torito + all survive an open→modify→save cycle. Multi-session images and + ISO 9660:1999 (Enhanced Volume Descriptors) are not supported. +- **Keep the source open.** After `iso9660.Open(f)`, the returned image + reads file content from `f` lazily — closing it before `Save` or + `ReadFile` breaks those calls. +- **Booting is bring-your-own-bootloader.** iso-kit writes the catalog and + partition structures; the boot images themselves (isolinux, GRUB, an ESP + FAT image) come from your toolchain. diff --git a/pkg/iso9660/cleanup_test.go b/pkg/iso9660/cleanup_test.go index a074373..dc86ae7 100644 --- a/pkg/iso9660/cleanup_test.go +++ b/pkg/iso9660/cleanup_test.go @@ -19,9 +19,7 @@ func TestExtractMaterializesSymlinks(t *testing.T) { iso, err := Create("SYMEXT") require.NoError(t, err) require.NoError(t, iso.AddFile("target.txt", []byte("content"))) - _, err = iso.root.AddSymlink("link.txt", "target.txt") - require.NoError(t, err) - iso.markDirty() + require.NoError(t, iso.AddSymlink("link.txt", "target.txt")) path := saveToTempFile(t, iso) f, err := os.Open(path) diff --git a/pkg/iso9660/iso9660.go b/pkg/iso9660/iso9660.go index 07e1f14..054e96f 100644 --- a/pkg/iso9660/iso9660.go +++ b/pkg/iso9660/iso9660.go @@ -650,6 +650,21 @@ func (iso *ISO9660) AddFile(path string, data []byte) error { return nil } +// AddSymlink adds a symbolic link at the given path pointing at target, +// creating parent directories as needed. The link is recorded as a Rock +// Ridge SL entry, so it survives only in images written with Rock Ridge +// enabled (the default for created images). +func (iso *ISO9660) AddSymlink(path, target string) error { + if iso.root == nil { + return errors.New("no filesystem is loaded") + } + if _, err := iso.root.AddSymlink(path, target); err != nil { + return err + } + iso.markDirty() + return nil +} + // AddDirectory creates an empty directory at the given path, creating // parent directories as needed. func (iso *ISO9660) AddDirectory(path string) error { diff --git a/pkg/iso9660/rockridge_test.go b/pkg/iso9660/rockridge_test.go index 13c05b0..3bed8ef 100644 --- a/pkg/iso9660/rockridge_test.go +++ b/pkg/iso9660/rockridge_test.go @@ -103,13 +103,11 @@ func TestRockRidgeSymlink(t *testing.T) { require.NoError(t, err) require.NoError(t, iso.AddFile("target.txt", []byte("data"))) - _, err = iso.root.AddSymlink("link.txt", "target.txt") + err = iso.AddSymlink("link.txt", "target.txt") require.NoError(t, err) - _, err = iso.root.AddSymlink("abs-link", "/usr/share/doc") + err = iso.AddSymlink("abs-link", "/usr/share/doc") require.NoError(t, err) - _, err = iso.root.AddSymlink("rel-link", "../up/../over/./there") - require.NoError(t, err) - iso.markDirty() + require.NoError(t, iso.AddSymlink("rel-link", "../up/../over/./there")) path := saveToTempFile(t, iso) @@ -194,9 +192,7 @@ func TestRockRidgeInteropXorriso(t *testing.T) { require.NoError(t, iso.AddFile("Mixed Case Name.txt", []byte("rr interop\n"))) node := isoLookup(t, iso, "Mixed Case Name.txt") node.SetMode(0o640) - _, err = iso.root.AddSymlink("the-link", "Mixed Case Name.txt") - require.NoError(t, err) - iso.markDirty() + require.NoError(t, iso.AddSymlink("the-link", "Mixed Case Name.txt")) path := saveToTempFile(t, iso) out, err := exec.Command(xorriso, "-indev", path, "-find", "/", "-exec", "lsdl").CombinedOutput()