diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e23d97b1..2bf73366 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -482,6 +482,12 @@ jobs: ANYLINUX_REF: 2affcd69e3b3fccab4507dbdbaed5d1a04bedfa9 # Pinned appimagetool release; quick-sharun honours APPIMAGETOOL_LINK. APPIMAGETOOL_VERSION: "0.3.0" + # anylinux.c moved out of Anylinux-AppImages into its own repo on + # 2026-09-10 (Anylinux-sharun commit a84aa4b, "move and build helper + # libraries to this repository"). quick-sharun.sh at the ref above still + # defaults to the old `refs/heads/main` path, which now 404s -- so point + # it at the new home, pinned to a release tag rather than a branch. + ANYLINUX_SHARUN_VERSION: "3.2.1" # See build-windows: keep APP_VERSION consistent across Test and Build. RELEASE_VERSION: ${{ needs.generate-version.outputs.version }} steps: @@ -590,6 +596,14 @@ jobs: export VERSION # Pin appimagetool (quick-sharun downloads via this URL). export APPIMAGETOOL_LINK="https://github.com/pkgforge-dev/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-${{ matrix.sharun_arch }}-linux" + # anylinux.c now lives in Anylinux-sharun (see the env note). The + # script's default still points at the old repo's main branch, which + # 404s -- and its five retries just repeat a request that cannot + # succeed ("Failed to download 5 times!"), which reads like a network + # blip rather than a moved file. + export ANYLINUX_LIB_SOURCE="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-sharun/${ANYLINUX_SHARUN_VERSION}/lib/anylinux.c" + curl -fsI "$ANYLINUX_LIB_SOURCE" >/dev/null \ + || { echo "::error::anylinux.c unreachable at Anylinux-sharun ${ANYLINUX_SHARUN_VERSION}; upstream may have moved it again"; exit 1; } # GITHUB_REPOSITORY is set by Actions; appimagetool auto-emits zsync. mkdir -p "$OUTPATH" @@ -1608,6 +1622,13 @@ jobs: RELEASE_VERSION="$VER" scripts/build-sol9.sh vendor if ! RELEASE_VERSION="$VER" scripts/build-sol9.sh sol9; then echo "::warning::sol9 stage failed; retrying once (minicargo build-script race)" + # The losing worker leaves the build-script marker behind with an + # empty OUT_DIR, so a bare retry trusts the marker, skips re-running + # the script and dies on output that was never written ("Unable to + # open .../private.rs") -- a different error than the one it is + # retrying. Drop every build-script artifact so they genuinely re-run; + # they are seconds each, and the expensive crate compiles stay. + rm -rf "$SOL9_OUT"/build_* "$SOL9_OUT"/host/build_* RELEASE_VERSION="$VER" scripts/build-sol9.sh sol9 fi RELEASE_VERSION="$VER" scripts/build-sol9.sh dist diff --git a/CLAUDE.md b/CLAUDE.md index 20a0d60c..4b52635e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,14 +94,22 @@ Each backup is a folder. Two layouts depending on the chosen output: **Single-file CHD** (CHD output): - `metadata.json` with `layout: "single-file-chd"` and per-partition `offset_in_disk` byte ranges instead of per-file references -- `mbr.json` / `gpt.json` / `apm.json` - parsed partition-table sidecar (raw - bytes live inside the CHD) +- `mbr.json` / `gpt.json` / `apm.json` (or `sun.json`, `next.json`, + `sgi.json`, `sgi_dklabel.json`, `rdb.json`, `ahdi.json`, `x68k.json`) - + parsed partition-table sidecar (raw bytes live inside the CHD) - `.chd` - one disk image with table at sector 0, partitions at their declared offsets, gaps zero-filled. `chdman info` opens it, MAME loads it. +- A partitionless volume (floppy, `.hfv`, bare `.hdf`) is one body from + byte 0 with no table and no sidecar; the CHD's logical size is the + source size, never the packed extent. CHD output never produces per-partition CHDs — the single-file layout is -the only CHD shape rusty-backup writes. +the only CHD shape rusty-backup writes. A CHD holds a whole disk, so a +source the layout cannot assemble must be refused, not downgraded. The +disk-label schemes (Sun, NeXT, SGI, SGI-DkLabel, RDB, AHDI, X68k) are +backed up whole with their head region verbatim and resized on restore by +`partition::restore_patch`; see `docs/backup_partition_schemes.md`. ### Key Design Patterns diff --git a/README.md b/README.md index 7bae931e..ae5376b8 100644 --- a/README.md +++ b/README.md @@ -543,7 +543,7 @@ readable. | Gzip stream | `.gz` | Yes | Yes | DEFLATE per-partition member; the codec shared with crusty-backup (`cb-dos`) so DOS-side backups restore + resize here unchanged. `--format gzip` | | LZ4 stream | `.lz4` | Yes | Yes | LZ4 frame per-partition member; the other codec shared with crusty-backup (`cb-dos` `/CODEC:LZ4`) — faster than gzip on a slow CPU at a lower ratio. Restored + resized exactly like a `.gz` member. `--format lz4` | | cb-dos container | `.cbk` | Yes (native) | Yes (`cbk pack`) | Single-file form of a backup folder (chunked gzip members + index). Opens like any disk image — `inspect`, `ls`/`get` (browse + extract), `fsck`, GUI Inspect, and `restore` all work directly, no extract step. Large partitions are split into ~4 MiB source-span gzip members (via the `partition-N.gz.idx` seek layout), so the lazy reader seeks per-chunk instead of decompressing from the start. `rb-cli cbk pack/unpack` convert to/from a folder. Frozen v1; the eventual cb-dos network transport's on-disk artifact | -| CHD (MAME) | `.chd` | Yes | Yes | Native (MAME's CHD core is bundled — no external `chdman` needed) | +| CHD (MAME) | `.chd` | Yes | Yes | Native (MAME's CHD core is bundled — no external `chdman` needed). `backup --format chd` always writes one whole-disk `.chd`; a partitionless volume (floppy, `.hfv`, bare `.hdf`) becomes one disk image sized to the source, so `chdman info` and MAME see the full volume | | AppImage | `.AppImage` | Yes | No | A type-2 AppImage is an ELF runtime stub with a **SquashFS appended**, so the payload opens in place — browse, extract, and **edit** the application's filesystem without unpacking anything. Because the payload is the tail of the file, a rebuild is free to grow it and the stub in front is left byte-for-byte alone. Detected by content (the `AI\x02` marker in the ELF header), not by name, since AppImages are routinely shipped without an extension. Type-1 AppImages wrap an ISO 9660 instead and are recognised but not opened | | Norton Ghost | `.gho`, `.ghs` | Yes | No | File-aware FAT/NTFS browse, sector + spanned sets, Ghost 7.5, password-protected images decrypted automatically | | WinImage | `.imz` | Yes | No | Including password-protected archives | @@ -566,7 +566,7 @@ readable. | Commodore GCR | `.g64`, `.g71` | Yes (decode) | No | Raw 1541 / 1571 GCR track images (preservation-grade). Decoded to flat sectors so the CBM engine can read them; the `.g71` side-1 mapping is validated against a real VICE `c1541` image. | | Atari disk | `.atr`, `.xfd` | Yes | Yes (in-place edit) | 8-bit Atari (400/800/XL/XE) disk images for the Atari800 core. `.atr` = 16-byte header + sector body; `.xfd` headerless. Read/browse/extract + add/delete on the Atari DOS 2 volume. | | CoCo disk | `.dsk`, `.jvc`, `.vdk` | Yes | Yes (in-place edit) | Tandy Color Computer (CoCo2 / CoCo3 cores) raw 35- / 40-track sector dumps. Headerless flat body (length a multiple of 256). Auto-detects the volume's filesystem: RS-DOS / Disk BASIC (flat granule FS) or OS-9 / NitrOS-9 RBF (hierarchical). Read/browse/extract + add/delete on both. | -| Acorn DFS disk | `.ssd`, `.dsd` | Yes | Yes (in-place edit) | BBC Micro / BBC Master / Acorn Electron (MiSTer BBCMicro / AcornElectron cores) floppy. Flat 40-track (100K) / 80-track (200K) sector dump in logical order. Read/browse/extract + add/delete on the Acorn DFS catalogue. Single-sided `.ssd` opens as one volume; **double-sided `.dsd`** (the two sides stored track-interleaved) is de-interleaved and presented as **two** Acorn DFS partitions (`IMG@1` = side 0, `IMG@2` = side 1) — edits to either side re-interleave back into the `.dsd` on save. | +| Acorn DFS disk | `.ssd`, `.dsd` | Yes | Yes (in-place edit) | BBC Micro / BBC Master / Acorn Electron (MiSTer BBCMicro / AcornElectron cores) floppy. Flat 40-track (100K) / 80-track (200K) sector dump in logical order. Read/browse/extract + add/delete on the Acorn DFS catalogue. Single-sided `.ssd` opens as one volume; **double-sided `.dsd`** (the two sides stored track-interleaved) is de-interleaved and presented as **two** Acorn DFS partitions (`IMG@1` = side 0, `IMG@2` = side 1) — edits to either side re-interleave back into the `.dsd` on save. `backup` of a `.dsd` is refused, since no restore could re-interleave the sides; copy the file instead. | | ZX Spectrum TR-DOS | `.trd` | Yes | Yes (in-place edit) | ZX Spectrum Beta Disk (MiSTer ZX-Spectrum core). Flat raw sector dump in logical track order (80-/40-track, single-/double-sided; 16 × 256-byte sectors). Read/browse/extract + add/delete/rename on the TR-DOS catalogue; geometry auto-detected from the disk-info sector. | | TI-99/4A disk | `.dsk` | Yes | Yes (in-place edit) | TI-99/4A (MiSTer TI-99_4A core) flat V9T9 sector image (SSSD 90K / DSSD / SSDD 180K / DSDD 360K). Read/browse/extract + add/delete/rename on the TI disk filesystem (VIB + FDIR + extent-based FDR files, big-endian); geometry read from the VIB. Content-detected via the "DSK" volume marker. | | Sharp D88 | `.d88` | Yes | Yes (convert + in-place edit) | X68000 / PC-88 / PC-98 / MSX / FM-7 sparse track-table container. Add/delete/mkdir on the contained Human68k FAT volume persist back into the container (decode -> edit -> re-encode). | @@ -580,6 +580,7 @@ readable. | Trident pack | — (raw, size-detected) | Yes | Yes | Trident T-80 / T-300 pack image (ContrAlto2 / dorado layout: `[dummy][header][label 10w][data 1024w]` per sector, little-endian, 2048-byte pages, physical sector interleave). The same Alto file system (TFS) on Trident hardware; recognized by the exact T-80 (~76 MB) / T-300 (~285 MB) size, surfaced as an `Alto BFS` volume. Validated against ContrAlto2's real Spruce print-server T-300 pack. | | Xerox Pilot/Cedar volume | `.pdi` (`fsFamily=2`) | Yes | Yes (create / add file) | D-machine Pilot/Cedar filesystem in a PARC Disk Image. Physical/logical volume roots, subvolume table, VAM, run-table files; both file-ID generations (32-bit Cedar nucleus / 80-bit original Pilot via `flags` bit 2). Surfaced as a read-only `Pilot/Cedar` volume in the GUI; blank-volume + add-file via `pilot_probe`. See `docs/` PARC specs. | | Dwarf 6085 disk | `.zdisk`, `.zdelta` | Yes | No | Dwarf "Draco" 6085/Daybreak emulator rigid-disk image — a zlib stream of label-inclusive Pilot sectors (10-word label + 256-word data; the 6085/IOP stores labels byte-swapped, normalized on read). Opens as a read-only `Pilot/Cedar` volume; lists and extracts files. The disks shipped with Dwarf (ViewPoint 2.0, XDE 5.0) are the real Pilot volumes our reader was validated against. | +| NeXT magneto-optical | `.od` | Yes (decode) | Yes (in-place edit) | The 256 MB Canon MO cartridge of the NeXT Computer, as Previous stores it — the **raw media**, not the drive's user data. Each 1296-byte physical sector is a 36 x 36 array carrying 1024 data bytes plus a cross-interleaved Reed-Solomon(36,32) code, so the user data is `raw[i*36 .. i*36+32]` for `i` in 0..32. Two more things stand between the file and a NeXT disk label: the image begins at the drive's track 4096 while the kernel's logical block 0 is 848 sectors in, and the `od` **driver** (not the drive) does the bad-block sparing — the data area past the front porch is cut into `d_ag_size` groups whose `d_ag_alts` sectors at `d_ag_off` are spares that logical addressing skips. Read linearly and every cylinder group past the first drifts 8 sectors, which still lists a directory tree and still fails `fsck` by tens of thousands of orphans. Decode + de-sparing are transparent, so the NeXT label parser and the big-endian UFS reader see an ordinary image; the ECC encoder is verified byte-for-byte against real media, so `put` / `rm` / `mkdir` / `fsck --repair` edit the volume **in place inside the `.od`** — each touched sector is re-encoded through both ECC passes the way the drive expects. | | Raw physical disk | — | Yes | Yes (restore target) | CF/SD/USB/HDD/SSD — see below | "Yes (convert)" means the format isn't a backup wrapper but is fully @@ -687,13 +688,13 @@ PC Engine CD, CD32, GameCube, Wii, CD-i, and 3DO. |--------|:-----:|:-------------------------------------:|-------| | MBR | Yes | Yes | PC standard. Logical partitions inside an extended container are surfaced read-only. | | GPT | Yes | Yes | Primary + backup header rewritten with refreshed CRCs on every edit. | -| APM | Yes | Yes | Apple Partition Map (68k / PowerPC Macs). `rb-cli optical new mac-hfs` / `mac-hfsplus` synthesizes a classic-Mac CD-ROM image from scratch — DDR + map + one `Apple_HFS` partition holding a blank HFS or HFS+ volume (Mac-only; no ISO 9660 side). | -| RDB | Yes | Bootable flag only; writes whole tables from scratch | Amiga `RDSK`. `rb-cli new hd rdb` lays down an RDSK plus a `PART` chain with the DosType tags you name (`DOS\3`, `PFS\3`, `SFS\0`, …), cylinder-aligned from `--heads` / `--sectors`; the output is read back cleanly by `amitools`' `rdbtool`. Editing an *existing* RDB is still bootable-flag-only, deferred until the DosEnv geometry story is settled. | -| SGI | Yes | Yes | SGI Volume Header (IRIX). 16 fixed slots; checksum recomputed on every write; geometry (`vh_dp`) preserved across edits. `rb-cli new hd sgi-efs` synthesizes a dvh + EFS-root hard disk from scratch (IRIX 5.3-6.5). | -| SGI-DkLabel | Yes | Yes; also writes whole labels from scratch | The pre-IRIX SGI disk label, on IRIS 2000 / 3000 series disks. One `struct disk_label` at block 0: drive geometry, the alternate-block region, and 8 `{base, size}` slots with no type field — roles come from `d_bootfs` / `d_swapfs` / `d_rootfs`, and the whole-disk wrapper slots are excluded from the list. Big-endian, packed for the 68020's 2-byte alignment, and **auto-detects the byte-swapped images** period SGI disk controllers produce. The detected orientation is reported by `inspect` / `ls` and on the GUI's Inspect tab, which also offers a *Swap Word Order...* button; `rb-cli swab16` is the scriptable equivalent. Surfaces its slots to the SGI EFS v1 driver (browse / inspect / extract, and editing the volumes). The eight `{d_base, d_size}` slots are editable — resize / move / add / delete, plus moving `d_bootfs` — with edits written back in the image's own word order and partial slot overlap refused (whole-disk wrapper slots are allowed, since containment is how the label spells "the whole drive"). There is no per-slot type field, so `set-type` is refused with that reason. `rb-cli new hd sgi-dklabel` writes a fresh label with the slots you size and give a role (`root` / `swap` / `boot` / `slice`), cylinder-aligned from `--heads` / `--sectors`, plus the whole-disk wrapper slot every label of the era carries; on the reference geometry (987c/7h/17s) the slots land on the same blocks a real IRIS 3130 uses. Fill them with `rb-cli new volume efs-v1` and `--fill N=PATH`, then `rb-cli swab16` if the target machine wants the controller's reversed-word order. Full-disk backup is future work. | -| AHDI | Yes | No (browse); writes whole tables from scratch | Atari ST / TT / Falcon hard disks. Four primary entries at 0x1C6 plus XGM extended chains, big-endian, no magic number — detection keys off the 0x1234 word-sum and plausible geometry. `rb-cli new hd atari` writes a fresh root sector with the tags you name (GEM / BGM / RAW); a GEM partition over 16 MiB is promoted to BGM, which is what TOS needs. Creating an XGM chain, and grafting in a bootable bootstrap, are future work. | -| Sun | Yes | No (browse); writes whole labels from scratch | Sun disk label / SMI VTOC (SPARC Solaris / SunOS). 8 big-endian slices (magic `0xDABE`), geometry-derived offsets; the whole-disk "backup" slice is excluded from the list. Surfaces the UFS slices to the existing big-endian-SPARC UFS reader (browse / inspect / extract). `rb-cli new hd sun` writes a fresh label with the slice tags you name (`root`, `usr`, `swap`, … or a bare tag number), cylinder-aligned from `--heads` / `--sectors`, with slice 2 reserved for the whole-disk alias. Parser and writer both cross-validated against `fdisk` / `sfdisk`; editing an existing label and full-disk backup are future work. | -| NeXT | Yes | Yes | NeXT disk label (NeXTSTEP / OPENSTEP, black m68k hardware **and** NeXTSTEP/Intel). Up to 8 partitions, big-endian on both architectures, written as four checksummed copies at 512-byte blocks 0/15/30/45 — all four share one checksum, because it is computed with `dl_label_blkno` read as zero. Partition offsets are counted in the label's own `d_secsize` (1024 bytes on every disk we have) and measured from the end of a front porch, so `PartitionInfo` carries an explicit byte offset for them. Probed ahead of MBR because a NeXTSTEP/Intel disk also carries a valid `0xAA55` boot sector with an empty partition table. Surfaces its partitions to the existing big-endian UFS reader (browse / inspect / extract / edit). `rb-cli new hd next` writes a fresh `dlV3` label with the partitions you name (`4.3BSD`, `swap`, or any 8-byte type string), a 160-sector front porch, and the entry's optional NAME field as the partition's `p_mountpt`; `--heads` / `--sectors` set the recorded geometry and are counted in the label's own 1024-byte sectors. Fill it with `rb-cli new volume ufs-43bsd`, which writes the pre-4.4BSD UFS1 NeXTSTEP actually reads. The 8 slots are editable — resize / move / add / delete, `set-type` on the `p_type` name, and `set-bootable` moving `d_rootpartition` — with every edit converted out of 512-byte LBAs into porch-relative label sectors, overlap refused, and **only the copies the disk actually has** rewritten, so a NeXTSTEP/Intel disk keeps the PC boot sector that stands where its block-0 copy would be. Full-disk backup is future work. | +| APM | Yes | Yes | Apple Partition Map (68k / PowerPC Macs). `rb-cli optical new mac-hfs` / `mac-hfsplus` synthesizes a classic-Mac CD-ROM image from scratch — DDR + map + one `Apple_HFS` partition holding a blank HFS or HFS+ volume (Mac-only; no ISO 9660 side). An `Apple_Rhapsody_UFS` slice — Mac OS X Server 1.x / Rhapsody — nests a **NeXT disk label** in its own first sectors, so the UFS begins past that label's front porch rather than at the slice's first byte; the label is probed on open and the filesystem addressed where it says. Backup compacts it too: the head region rides verbatim at the front of the layout-preserving UFS stream, so free blocks become zeros without the label going with them, and every partition-level size the tool reports adds those bytes back — the filesystem's own minimum is 160 KiB short of the slice, and shrinking to it would cut the last 160 KiB off the end. | +| RDB | Yes | Bootable flag only; writes whole tables from scratch | Amiga `RDSK`. `rb-cli new hd rdb` lays down an RDSK plus a `PART` chain with the DosType tags you name (`DOS\3`, `PFS\3`, `SFS\0`, …), cylinder-aligned from `--heads` / `--sectors`; the output is read back cleanly by `amitools`' `rdbtool`. Editing an *existing* RDB is still bootable-flag-only, deferred until the DosEnv geometry story is settled. **Full-disk backup works through the single-file-CHD layout.** The reserved area ahead of the first partition — RDSK, the `PART` chain, and the `FSHD` / `LSEG` filesystem-driver blocks — rides verbatim, which is the whole point: re-serializing a parsed `rdb.json` would lose the driver chain and the `BADB` list, and that is why a per-partition RDB backup was never restorable. An as-is restore is a byte copy; `restore --size minimum` (or any non-Original size in the GUI) rewrites the RDSK and `PART` blocks on cylinder boundaries inside that verbatim head and leaves the driver chain untouched. A resize at backup or export time runs the same rewrite before the bodies are staged. The per-partition layouts (zstd / gzip / raw / VHD) are refused — they previously *succeeded* and then produced a backup `restore` turned away. An RDB that claims reserved blocks past its first partition is refused with that reason rather than silently losing them. | +| SGI | Yes | Yes | SGI Volume Header (IRIX). 16 fixed slots; checksum recomputed on every write; geometry (`vh_dp`) preserved across edits. `rb-cli new hd sgi-efs` synthesizes a dvh + EFS-root hard disk from scratch (IRIX 5.3-6.5). **Full-disk backup works through the single-file-CHD layout**, with the volume header and its standalone binaries copied verbatim ahead of slot 0 and an as-is restore a byte copy; a resize on restore rewrites the header's slots and recomputes its checksum, and a resize at backup or export time runs the same rewrite before staging. An `fx` disk describes several *alternative* layouts at once, so its slots overlap by design — when they do, there is no per-slot split to make and the drive is imaged as one body with the slot table in `sgi.json`. | +| SGI-DkLabel | Yes | Yes; also writes whole labels from scratch | The pre-IRIX SGI disk label, on IRIS 2000 / 3000 series disks. One `struct disk_label` at block 0: drive geometry, the alternate-block region, and 8 `{base, size}` slots with no type field — roles come from `d_bootfs` / `d_swapfs` / `d_rootfs`, and the whole-disk wrapper slots are excluded from the list. Big-endian, packed for the 68020's 2-byte alignment, and **auto-detects the byte-swapped images** period SGI disk controllers produce. The detected orientation is reported by `inspect` / `ls` and on the GUI's Inspect tab, which also offers a *Swap Word Order...* button; `rb-cli swab16` is the scriptable equivalent. Surfaces its slots to the SGI EFS v1 driver (browse / inspect / extract, and editing the volumes). The eight `{d_base, d_size}` slots are editable — resize / move / add / delete, plus moving `d_bootfs` — with edits written back in the image's own word order and partial slot overlap refused (whole-disk wrapper slots are allowed, since containment is how the label spells "the whole drive"). There is no per-slot type field, so `set-type` is refused with that reason. `rb-cli new hd sgi-dklabel` writes a fresh label with the slots you size and give a role (`root` / `swap` / `boot` / `slice`), cylinder-aligned from `--heads` / `--sectors`, plus the whole-disk wrapper slot every label of the era carries; on the reference geometry (987c/7h/17s) the slots land on the same blocks a real IRIS 3130 uses. Fill them with `rb-cli new volume efs-v1` and `--fill N=PATH`, then `rb-cli swab16` if the target machine wants the controller's reversed-word order. **Full-disk backup works through the single-file-CHD layout**: the label and boot area ahead of the first slot ride verbatim, each slot lands at its declared offset, and an as-is restore is a byte copy — the word order is preserved because those bytes are never interpreted. A resize on restore rewrites the eight slots in the label's own word order. A resize at backup or export time runs the same rewrite before staging; the per-partition layouts (zstd / gzip / raw / VHD) are refused, since restoring one would have to rewrite the label. | +| AHDI | Yes | No (browse); writes whole tables from scratch | Atari ST / TT / Falcon hard disks. Four primary entries at 0x1C6 plus XGM extended chains, big-endian, no magic number — detection keys off the 0x1234 word-sum and plausible geometry. `rb-cli new hd atari` writes a fresh root sector with the tags you name (GEM / BGM / RAW); a GEM partition over 16 MiB is promoted to BGM, which is what TOS needs. Creating an XGM chain, and grafting in a bootable bootstrap, are future work.. **Full-disk backup works through the single-file-CHD layout**: the root sector rides verbatim ahead of the first partition, an as-is restore is a byte copy, and a resize on restore, at backup time or on export rewrites the entries and the 0x1234 word-sum in place. A per-partition backup previously *succeeded* and then failed at restore with "no MBR data available"; it is refused up front now. A disk with an **XGM extended chain** is imaged as one body instead of per-partition — MBR gets away with that shape only because `build_ebr_chain` rebuilds its EBRs on restore, and nothing rebuilds an XGM chain | +| Sun | Yes | No (browse); writes whole labels from scratch | Sun disk label / SMI VTOC (SPARC Solaris / SunOS). 8 big-endian slices (magic `0xDABE`), geometry-derived offsets; the whole-disk "backup" slice is excluded from the list. **SunOS 4.x wrote no VTOC at all** — `sanity`, `version` and `nparts` are zero, so every tag reads 0 and the tag-5 test can't find that alias; there it is identified by geometry instead (a slice from cylinder 0 spanning the label's whole data area), and the slices are shown as `untagged` rather than claiming an `unassigned` tag we never read. Surfaces the UFS slices to the existing big-endian-SPARC UFS reader (browse / inspect / extract). `rb-cli new hd sun` writes a fresh label with the slice tags you name (`root`, `usr`, `swap`, … or a bare tag number), cylinder-aligned from `--heads` / `--sectors`, with slice 2 reserved for the whole-disk alias. Parser and writer both cross-validated against `fdisk` / `sfdisk`; editing an existing label is future work. **Full-disk backup works through the single-file-CHD layout**: the head region — here nothing, because a SunOS root slice starts at cylinder 0 and carries the label in its own first sector — plus every slice at its declared offset, restored as-is by a byte copy, or with the slices rewritten on cylinder boundaries and the checksum restamped when a size changes on restore. The per-partition layouts (zstd / gzip / raw / VHD) are refused with that reason, since restoring one would have to rewrite the label. The same rewrite runs before staging when a size changes at backup or export time. | +| NeXT | Yes | Yes | NeXT disk label (NeXTSTEP / OPENSTEP, black m68k hardware **and** NeXTSTEP/Intel). Up to 8 partitions, big-endian on both architectures, written as four checksummed copies at 512-byte blocks 0/15/30/45 — all four share one checksum, because it is computed with `dl_label_blkno` read as zero. Partition offsets are counted in the label's own `d_secsize` (1024 bytes on every disk we have) and measured from the end of a front porch, so `PartitionInfo` carries an explicit byte offset for them. Probed ahead of MBR because a NeXTSTEP/Intel disk also carries a valid `0xAA55` boot sector with an empty partition table. Surfaces its partitions to the existing big-endian UFS reader (browse / inspect / extract / edit). `rb-cli new hd next` writes a fresh `dlV3` label with the partitions you name (`4.3BSD`, `swap`, or any 8-byte type string), a 160-sector front porch, and the entry's optional NAME field as the partition's `p_mountpt`; `--heads` / `--sectors` set the recorded geometry and are counted in the label's own 1024-byte sectors. Fill it with `rb-cli new volume ufs-43bsd`, which writes the pre-4.4BSD UFS1 NeXTSTEP actually reads. The 8 slots are editable — resize / move / add / delete, `set-type` on the `p_type` name, and `set-bootable` moving `d_rootpartition` — with every edit converted out of 512-byte LBAs into porch-relative label sectors, overlap refused, and **only the copies the disk actually has** rewritten, so a NeXTSTEP/Intel disk keeps the PC boot sector that stands where its block-0 copy would be. **Full-disk backup works through the single-file-CHD layout**: the front porch — label copies, boot blocks and all — is copied verbatim, each partition lands at its declared offset, and an as-is restore is a byte copy; a resize on restore rewrites all four label copies. The per-partition layouts (zstd / gzip / raw / VHD) are refused with that reason, since restoring one would have to rewrite the label. The same rewrite runs before staging when a size changes at backup or export time. | | Solaris-x86 | Yes | Yes | Solaris x86 nests a 16-slice VTOC in **sector 1 of an MBR partition** (type `0x82` on Solaris 2.x-9, `0xBF` on 10+) rather than replacing the MBR the way SPARC's Sun label does. Little-endian, slice offsets relative to the Solaris partition. Type `0x82` is shared with Linux swap, so detection requires the `0x600DDEEE` sanity word, `v_version == 1`, and slices that fit inside the partition. Surfaces the UFS slices to the existing reader; the disk's other MBR primaries and EBR logicals list after them, since a Solaris disk is still an MBR disk. `rb-cli new hd solaris-x86` writes both halves at once: an MBR with one bootable `0x82` entry starting at cylinder 1, and a full `struct dk_label` in its second sector — VTOC, geometry tail, `0xDABE` magic and the XOR checksum Solaris validates. Slices are cylinder-aligned; slice 2 is the whole-partition backup alias, slice 8 the boot cylinder and slice 9 the two alternates cylinders, so user slices start at the disk's fourth cylinder. The 16 slices are editable — resize / move / add / delete, and `set-type` on the VTOC tag by name or number — with absolute LBAs translated into partition-relative sectors, slices bounded by the label's own `dkl_ncyl` data area, partial overlap refused (the backup alias containing everything is not), and the checksum re-stamped so Solaris still accepts the label. `set-bootable` is refused with the reason: the boot bit is on the MBR entry hosting the label, not on the slices inside it. Backup treats the disk as the MBR it is (the Solaris partition rides as one body) and records the VTOC in `solaris_x86.json`. | | X68k | Yes | No (browse); writes whole tables from scratch | Sharp X68000 SASI/SCSI hard disks — Human68k's native scheme. 16-byte header plus 8 entries at byte 2048, big-endian, no magic number. Both geometries are auto-detected: SCSI (`X68SCSI1`, table at 0x800, 1024-byte sectors) and SASI (table at 0x400, 256-byte sectors), including custom-IPL game disks. `rb-cli new hd x68k` synthesizes a bootable disk with the Sharp IPL signature and a Human68k FAT volume. | | DSD | Yes | — (fixed floppy geometry) | Double-sided Acorn DFS (`.dsd`). Not a table on the disk: the two sides are stored track-interleaved, so the reader de-interleaves them and this scheme presents them as **two** Acorn DFS partitions — side 0 at byte 0, side 1 at half the image. Edits to either side re-interleave on save. | diff --git a/docs/RESUME-chd-single-file-invariant.md b/docs/RESUME-chd-single-file-invariant.md new file mode 100644 index 00000000..22a85b70 --- /dev/null +++ b/docs/RESUME-chd-single-file-invariant.md @@ -0,0 +1,367 @@ +# Resume: CHD is a whole disk, and restore must be able to rewrite any label + +Read `CONTRIBUTING.md` and `CLAUDE.md` in full first. This doc is the single +place to resume from; it supersedes its own earlier version (commit `e7449c3`). + +## The rule (from the user, 2026-09-10) + +> A CHD contains a full disk, not a partition. We should never have multiple +> per-partition CHD files. A partitionless disk is fine, because there the +> partition *is* the disk. + +Everything below follows from that. A source the single-file layout cannot +assemble is **refused**, never downgraded to `partition-N.chd`. + +## Status + +| Item | State | +|---|---| +| Task A: partitionless volumes (floppy, HFV, bare HDF) as one whole-disk CHD | **Shipped** (this branch). See "Task A" below. | +| Stage 0: X68k CHD refused naming zstd, `.dsd` backup refused, GPT clear skipped for label schemes, packed bodies grow back on every table, NTFS backup boot sector restored | **Shipped** (this branch). | +| Label rewrite on restore (Sun / NeXT / SGI / SGI-DkLabel / RDB / AHDI / X68k) | **Shipped** (this branch): `src/partition/restore_patch.rs`, wired into `run_single_file_chd_restore_resize`. The silent corruption is closed. | +| X68k as a single-file CHD | **Shipped.** The as-is restore carries the IPL region back byte for byte; `--size minimum` rewrites the table. An unaligned SASI partition is refused for CHD. | +| The three loose ends (0x83 / type-string packed padding, the clock-flaky HFS test, the orphaned floppy-sizes comment) | **Shipped.** | +| Stage 4: resize on CHD backup / export patches the head before staging; raw and VHD exports rewrite the label; the Inspect tab says so when a table cannot be resized | **Shipped.** | + +**Nothing is left open on this track.** `docs/backup_partition_schemes.md` +is the durable description of the design; this file is the record of how it +got here. + +Baseline on `30d4f9b`: preflight green, 3,164 lib tests, `rb-regress` 381/381. +After Task A: 3,165 lib tests (one added), the three new tier-5 cases pass. + +## History, so nobody laps this track again + +The repo changed its mind about superfloppy + CHD three times. Each commit +was locally right and none wrote the rule down where the code enforces it. + +| Date | Commit | What it did | +|---|---|---| +| 2026-05-05 | `5f6378c` | Wrote the CLAUDE.md invariant. Forced superfloppy + CHD to a raw `.img`, silently. Deleted the log line that admitted other tables fall back to per-partition CHD, but not the fallback. | +| 2026-06-04, 07-17 | `f55415f`, `a6fe080` | Added the X68k and DSD tables without touching the CHD gate. Both leaked per-partition CHDs from day one. | +| 2026-08-02 | `5ab770a` | Made the superfloppy forcing warn. | +| 2026-08-02 | `bfbc736` | Removed the forcing entirely to fix a real zstd bug ("honour --format"). Its rationale treated CHD as one more per-partition codec, which reopened the superfloppy leak. | +| 2026-09-10 | `8d814b7`..`30d4f9b` | Routed the disk-label schemes through the single-file layout; per-partition layouts refused (`LABEL_BACKUP_NEEDS_CHD`). Restore side left scheme-blind. | +| 2026-09-10 | `e7449c3` | First version of this doc. It misread the stale comment as "never implemented". | + +The two mental models in conflict were "CHD is a codec" and "CHD is a disk +image". The rule above settles it. + +## Task A (shipped) + +- `single_file_chd::is_supported` accepts `PartitionTable::None`; + `build_patched_head_segments` emits no head for it (the body starts at + byte 0). The `!is_superfloppy` term left the gate in `run_backup_inner`. +- A compacted FAT/NTFS/exFAT body sits *shrunk* inside its full extent in + every single-file CHD (that is the padded-packed design, and it is what an + MBR disk does today: a 15 MB FAT16 partition comes back with a 8 MB BPB + after an as-is restore). For a partitionless volume that would have meant + a 1.44 MB floppy whose BPB says 205 KB, while the per-partition path grows + it back. So `run_single_file_chd_restore_as_is` now grows a compacted + partitionless FAT/NTFS/exFAT volume to its extent, and nothing else. The + MBR/GPT/APM as-is behaviour is unchanged and is a separate decision (see + "Decisions for the user"). +- Pinned by `run_via_staging_round_trip_superfloppy_no_resize`, + `tests/superfloppy_compression.rs::superfloppy_chd_is_one_whole_disk_container`, + and `regression-tests/cases/tier5/roundtrip-partitionless.toml` (floppy, + 300 MB HFV, 500 MB SFS HDF). `roundtrip.format.chd` in + `tier5/roundtrip.toml` was already a superfloppy CHD round trip and still + passes. +- Old per-partition folders still restore: restore dispatches on + `metadata.layout`, verified against a folder made before the change. + +## The live bug behind "B2" + +The old doc could not explain why an X68k prototype produced `Unrecognized +media` on `restore --size minimum` while the resize refusal in +`build_patched_head_segments` never fired. The mechanism: + +1. That refusal is **backup-time only**. Restore never calls it. +2. `run_single_file_chd_restore_resize` (`src/restore/mod.rs`, search the + name) has arms for `"None"`, `"GPT"`, `"APM"` and *everything else is + MBR*. Sun, NeXT, both SGI schemes, RDB, AHDI and X68k take the MBR arm: + `patch_mbr_entries` finds no matching entry, sector 0 is written back + verbatim, and the bodies move anyway. +3. `clear_gpt_structures` zeroes LBAs 1-33 unconditionally. That wipes an + RDB's PART/FSHD chain, one of the four NeXT label copies, and the X68k + table at byte 0x800 (the `Unrecognized media`). + +Reproduced on a Sun label with a FAT slice (`new hd sun --fill`): exit 0, +slice 1's body moves from LBA 67536 to 34272, the label still says 67536. +The tier-5 label cases never pass `--size`, so nothing catches it. +`rb-cli restore --size minimum` and any non-Original choice in the GUI +restore tab reach this path. + +## Plan: label-aware restore + +Scoped with two full inventories on 2026-09-10 (every `PartitionTable` +variant's writer/patcher/sidecar, every write-side dispatch site, every test +that pins the current shape). The short version of the inventory: + +**Assets that exist.** `provision::write_table` has a from-scratch writer +for all 11 schemes (`src/partition/provision.rs`, `WRITABLE_TABLES`), with +`reserved_head` / `reserved_tail` / `size_granularity` / +`uses_cylinder_geometry` per scheme. `editor::apply_edits` patches NeXT, +Solaris x86, SGI and SGI-DkLabel labels in place. Patchers taking +`PartitionSizeOverride` exist for MBR (`patch_mbr_entries`), GPT +(`Gpt::patch_for_restore`), APM (`Apm::patch_for_restore`), RDB +(`Rdb::patch_for_restore`, an overlay of RDSK/PART blocks) and X68k +(`patch_x68k_entries`). Every scheme's parsed table has serde and a JSON +sidecar (`sun.json`, `next.json`, `sgi.json`, `sgi_dklabel.json`, +`rdb.json`, `ahdi.json`, `x68k.json`). + +**Gaps.** Sun has no serializer at all. NeXT, Solaris x86 and AHDI build +from a spec (`AhdiTable::root_to_bytes` zeros the 454-byte bootstrap), not +from parsed bytes. `PartitionSizeOverride` and `calculate_restore_layout` +are 512-byte-LBA only, with a `(255, 63)` CHS fallback that fires for every +label scheme because `detect_alignment` records `(0, 0)` heads/sectors for +them even though the sidecars carry the geometry. `compute_resize_plan` +ignores `PartitionInfo::start_byte` (X68k SASI, 256-byte sectors). +`rbformats::load_table_sidecars` knows only GPT and APM. +`export_whole_disk` / `export_whole_disk_vhd` silently drop size overrides +for anything that is not MBR/APM/RDB. `resize_filesystem_for` has no UFS +resizer, so a Sun or NeXT slice can only ever be restored at its original +size. No regression case resizes a label-scheme backup. + +### Stage 0 — stop the bleeding (shipped 2026-09-11, minus 0c) + +Only changes behaviour where today's behaviour is corruption or an +invariant violation. Each item has a regression case in +`regression-tests/cases/tier5/chd-whole-disk-rule.toml`. Also shipped with +it, per the user's decision: the as-is restore grows a packed FAT/NTFS/exFAT +body back to its partition on **every** table (not just partitionless), and +`ntfs::ensure_backup_boot_sector` puts back the sector the packed NTFS +stream stops short of, on all three restore paths. + +- **0a. Refuse CHD/DVD for tables `is_supported` rejects** (X68k, DSD) in + `run_backup_inner`, right after the gate, with a message naming + `--format zstd`. X68k works per-partition with zstd, and its + resize-on-restore works through the X68k arm of + `reconstruct_disk_from_backup` (pinned by + `tests/resize_suite/x68000_resize.rs`, which does not use CHD). Delete the + two-line comment above the split-size check that describes the leak. +- **0b. Refuse `backup` of a `.dsd` in every format.** Its restore already + fails ("no MBR data available", `src/rbformats/mod.rs`, the MBR fallback + of `reconstruct_disk_from_backup`), so nothing that works is lost. See + "DSD" below for the alternative. +- **0c. (Skipped by decision: go straight to Stage 3.)** Refuse the resize restore for label schemes. In `run_restore`'s + single-file dispatch and in `calculate_restore_layout`, bail for any + `partition_table_type` outside `MBR / GPT / APM / None` when any size + choice is not Original: "resizing a Sun disk on restore is not supported + yet; restore at Original size". The GUI restore tab calls + `calculate_restore_layout` for its fit projection, so the message shows + there too; also force the size-mode column to Original for those schemes. +- **0d. `clear_gpt_structures` only for MBR.** Its job is "we are writing an + MBR disk over a possibly-GPT target". GPT writes its own structures; a + label scheme must not have LBAs 1-33 zeroed. +- Regression: tier-5 `restore --size minimum` on each label fixture exits 1 + with the message, and Original still round-trips; X68k `--format chd` + exits 1 naming zstd; X68k zstd + `--size minimum` still shrinks 32 -> 16 MiB. + +### Stage 1 — one head patcher per scheme (shipped as `partition::restore_patch`) + +Add `partition::restore_patch::patch_head_for_restore(table, head: &mut +[u8], overrides, target_size) -> Result<()>` with an exhaustive match, the +same shape as `editor::apply_edits`. It patches **only** start/size fields +and checksums on the verbatim head bytes, so boot code, driver chains and +bootstraps survive. Per scheme: + +| Scheme | Patcher | Work | +|---|---|---| +| MBR | `patch_mbr_entries` | exists | +| GPT | `patch_for_restore` + `build_primary_gpt` / `build_backup_gpt` | exists (head + tail) | +| APM | `patch_for_restore` + overlay `build_apm_blocks` | exists (already the overlay pattern) | +| RDB | `Rdb::patch_for_restore` overlay, adapted from `Read+Seek` source to the head buffer | small | +| X68k | `patch_x68k_entries` at `X68K_TABLE_OFFSET(_SASI)`, units = sector size, refresh `disk_size_field` (mirror the X68k arm of `reconstruct_disk_from_backup`) | small | +| Sun | **new**: 8 slots at byte 444 (`cyl u32 BE`, `nblocks u32 BE`), unit = cylinder (`ntrks * nsect`), XOR-16 checksum at 510; slice 2 untouched | ~60 lines | +| NeXT | in-buffer mutators exist (`set_partition_extent`, `stamp_checksum`, `write_copies` in `src/partition/next.rs`); units are 1024-byte sectors past `d_front`; restamp all four copies | ~40 lines glue | +| SGI volhdr | `SgiVolumeHeader::to_bytes` (checksum recomputed); overlapping-slot `fx` disks are already whole-disk bodies, so no override reaches them | small | +| SGI-DkLabel | `write_into` + `apply_byte_order`; must honour the detected word order | small | +| AHDI | **new**: 4 entries at 0x1C6 (flag, id[3], start BE u32, size BE u32), recompute the 0x1234 word-sum; XGM disks are already whole-disk bodies | ~50 lines | +| Solaris x86 | defer: the VTOC lives in the Solaris partition's second sector with slice offsets relative to it; today it is backed up as MBR + `solaris_x86.json`. Refuse resizing the Solaris partition until `write_label` is wired | refuse | +| None | no-op | — | +| DSD | bail | — | + +Tests per scheme: build with `provision::write_table`, parse, patch with an +override, re-parse, assert the new start/size, a valid checksum, and that +every byte outside the entries is unchanged (boot code, FSHD chain, +bootstrap). `every_writable_table_writes_and_reparses` in `provision.rs` is +the pattern. + +### Stage 2 — layout rules per scheme (shipped in reduced form: each patcher repacks on its own unit and returns the layout it wrote) + +- A `SchemeLayoutRules { granularity, reserved_head, reserved_tail, + cylinder_bytes: Option, fixed_slots }` derived from the sidecar JSON, + reusing `provision::{reserved_head, reserved_tail, size_granularity, + uses_cylinder_geometry}`. `calculate_restore_layout` consults it instead + of the MBR assumptions (`first_partition_lba`, EBR gaps, `(255, 63)`). + Fixed slots: Sun slice 2 (whole disk), the SGI volume-header slots, the + RDB reserved blocks through `rdb_blk_hi`. +- Record geometry for label schemes in `AlignmentMetadata` at backup time + (`detect_alignment` returns `(0, 0)` today; the labels carry + `ntrks/nsect`, `ntracks/nsectors/front_porch`, `heads/sectors`, + `surfaces/blk_per_trk`). Additive; old backups fall back to the sidecar. +- `compute_resize_plan` works in bytes (`byte_offset()`), so X68k SASI plans + correctly. +- Ask `in_place_resize_support` before accepting a non-Original size: a + UFS/FFS slice reports `Unsupported`, and the layout refuses instead of + moving a body it cannot shrink. The min-size runner probes the filesystem, + so the GUI "Minimum" for such a slice must collapse to Original. + +### Stage 3 — wire it in (shipped) + +- `run_single_file_chd_restore_resize`: read the **head region** from the + CHD (bytes before the first partition, same rule as + `read_label_head_region`), call the patcher, write head + bodies; GPT tail + as today; `clear_gpt_structures` only for MBR (Stage 0d). +- `build_patched_head_segments`: replace the verbatim arm's refusal with the + patcher for schemes that have one. `is_verbatim_head_scheme` keeps meaning + "the head is carried as the base bytes"; the refusal keys on patcher + availability. Update `is_supported_covers_the_disk_label_schemes` and the + `end_to_end_round_trip_*` assertions accordingly. +- `reconstruct_disk_from_backup`'s MBR fallback bails with a scheme-named + message instead of "no MBR data available". Per-partition layouts for + label schemes stay refused at backup time (RDB's FSHD/LSEG chain needs the + verbatim head, which only the CHD carries). +- `rbformats::load_table_sidecars` learns every sidecar. +- Regression: tier-5 `restore --size minimum` for Sun (FAT slice), NeXT, + SGI, SGI-DkLabel, RDB (the PFS3 compacted fixture), AHDI and X68k: inspect + shows the shrunk layout, fsck clean, an extracted file byte-identical. + Stage 0c's refusal cases flip to success one scheme at a time. + +### Stage 4 — exports and GUI (shipped) + +- `export_whole_disk` / `export_whole_disk_vhd` route through the patcher or + refuse; today they drop overrides silently for non-MBR/APM/RDB. +- `inspect_tab::build_chd_partition_context` returns `None` for an + unsupported table and the export silently loses its resize; surface it. +- Restore tab: size-mode column enabled per scheme by patcher availability + and `in_place_resize_support`. + +### Stage 5 — X68k as a single-file CHD (shipped) + +With the X68k patcher (Stage 1) and byte-based planning (Stage 2), add X68k +to `is_supported`. Three things from the old doc still apply: the +`packed_partition_reader_padded` early return for type-string partitions +packs Human68k without padding (a follow-up chip covers it); there is no +real SASI/SCSI HDD fixture (only `.d88` floppies; `rb-cli new hd x68k` makes +the 512-byte synthetic shape); and the boot region ahead of the table is +not just boot code: `X68SCSI1` at byte 0 selects the 1024-byte sector unit, +so it must ride verbatim, which the single-file layout does and today's +per-partition restore (zero-fill) does not. Until then Stage 0a's refusal +stands. + +### No-break guarantees + +- `metadata.layout` dispatch is never touched; per-partition folders are + unaffected by every stage. +- Stages 1-2 add code and tests only. Stage 3 flips call sites behind + patcher availability; the tier-5 Original-size cases guard the verbatim + path throughout. +- The single-file as-is restore stays a byte copy for every partitioned + scheme. + +## DSD, and floppy containers generally + +Inventory result: of every container the engine decodes (`.d88`, `.woz`, +`.moof`, `.g64`, `.msa`, `.atr`, `.dim`, `.dc42` Twiggy, `.od`, `.adz`, +Apple II `.do/.po`, ...), **only `.dsd`** has the property "the reader +transforms the bytes *and* the table splits into more than one piece". All +the others decode to one flat volume, land as `PartitionTable::None`, and +back up fine under Task A; restore emits a flat `.img` (documented one-way, +`restore/superfloppy_wrap.rs`). There is no wider "floppy formats conflict +with CHD" class to exclude. The CHD exclusion list is exactly +`!is_supported`, which already holds `Dsd` and `X68k`; what was missing is +the refusal (Stage 0a). + +For `.dsd` itself: + +- Its backup is already unrestorable in every format (see Stage 0b). +- A de-interleaved `side0 || side1` image is not a format anything reads. +- Recommended: refuse `backup` of a `.dsd` (Stage 0b), keep read / edit / + `convert`. A `.dsd` is a 200-400 KB file; copying it is its backup. +- Alternative, if "back up the file bytes" is wanted: route `Dsd` through + `whole_disk_partition` (the raw interleaved container as one body, restore + a byte copy) and give it the same no-head arm as `None`. That yields a + faithful single-file CHD of the container, but MAME will not load a + floppy CHD, so it buys nothing over copying the file. + +## Decisions (taken by the user, 2026-09-11) + +1. **DSD**: refuse `backup` in every format. Shipped. +2. **Compacted FAT/NTFS/exFAT inside any single-file CHD**: grow back on the + as-is restore for every table, so the two layouts agree. Shipped, with + the NTFS backup-boot-sector fix it depended on. +3. **Stage 0c**: skipped. Build the real patchers (Stage 3) instead of a + temporary refusal. +4. **Stage 0** as one commit: yes. Shipped. + +## Ground truth (measured 2026-09-10; re-verify before relying on it) + +| Source | `backup --format chd` before Task A | after | +|---|---|---| +| Superfloppy (`PartitionTable::None`) | `partition-0.chd`, logical size 8,704 for a 1.44 MB floppy | `.chd`, logical 1,474,560 | +| DSD | `partition-1.chd` and `partition-2.chd` | unchanged (Stage 0b) | +| X68k | `partition-0.chd` | unchanged (Stage 0a) | + +```bash +rb-cli new floppy fat /tmp/fl.img --size 1440K +rb-cli backup /tmp/fl.img /tmp/bk --name job --format chd && ls /tmp/bk/job +rb-cli inspect /tmp/bk/job/job.chd # Logical size: 1,474,560 bytes +``` + +CHD needs no partition table: `compress_chd` takes a logical size, a hunk +size and codecs; `GDDD` geometry is derived from the size inside +`libchdman-rs`. Do not infer a table for a partitionless volume. + +## Code map + +| Location | What it is | +|---|---| +| `src/backup/mod.rs`, `single_file_chd_planned` | the gate: CHD/DVD, no split, `is_supported` | +| `src/backup/mod.rs`, `LABEL_BACKUP_NEEDS_CHD` | per-partition refusal for the label schemes | +| `src/backup/single_file_chd.rs`, `is_supported` | the scheme allowlist; also gates the GUI Inspect-tab CHD export | +| `src/backup/single_file_chd.rs`, `is_verbatim_head_scheme` | the list Stage 3 re-keys on patcher availability | +| `src/backup/single_file_chd.rs`, `build_patched_head_segments` | backup-time head patching; the None arm and the label refusal live here | +| `src/backup/single_file_chd.rs`, `build_partition_reader` | the `debug_assert_eq!` that hides in release | +| `src/restore/mod.rs`, `run_single_file_chd_restore_as_is` | byte copy; Task A's partitionless grow | +| `src/restore/mod.rs`, `run_single_file_chd_restore_resize` | the scheme-blind resize path (Stage 0c / 3) | +| `src/restore/mod.rs`, `calculate_restore_layout` | 512-LBA layout with MBR assumptions (Stage 2) | +| `src/restore/mod.rs`, `clear_gpt_structures` | zeroes LBAs 1-33 (Stage 0d) | +| `src/rbformats/mod.rs`, `reconstruct_disk_from_backup` | per-partition restore; X68k arm; MBR fallback | +| `src/partition/provision.rs`, `write_table` | the complete set of from-scratch writers to borrow from | +| `src/partition/editor.rs`, `apply_edits` | the in-place label editors (NeXT, Solaris, SGI, DkLabel) | +| `src/fs/mod.rs`, `packed_partition_reader_padded` | padding; the type-string early return is a follow-up chip | +| `src/fs/mod.rs`, `in_place_resize_support` | ask before moving a body whose filesystem cannot shrink | + +## Traps + +- **Do not infer a partition table for a partitionless volume.** CHD has + no such concept. +- **Restore keys off `metadata.layout`.** A scheme-aware arm in the + single-file resize path cannot touch a per-partition folder. Verify with + a folder made before your change anyway. +- **`debug_assert` hides in release.** `build_partition_reader` panics in + debug and degrades to raw passthrough in release with one log line. +- **Do not widen `LABEL_BACKUP_NEEDS_CHD` to partitionless volumes.** They + back up in every format; only their CHD shape was wrong. +- `--split-size` + CHD is already refused by `validate_backup_config`; the + `split_size_mib.is_none()` term in the gate is belt and braces. +- The old per-partition X68k CHD restore zero-fills the boot region; a real + SCSI disk's `X68SCSI1` signature selects the sector unit, so that path is + a fidelity bug, not a nicety. Stage 5 is the fix; do not "improve" the + zero-fill. + +## Verification + +```bash +cargo test --lib +cargo test --test superfloppy_compression +scripts/preflight.sh +./regression-tests/runner/target/release/rb-regress run --tiers 5 --filter roundtrip +./regression-tests/runner/target/release/rb-regress run --tiers 5 --filter chd. +``` + +The `chd.` filter covers `chd-whole-disk-rule.toml` (refusals, packed bodies +growing back) and `chd-label-resize.toml` (every label scheme shrinking its +first partition on restore). diff --git a/docs/backup_partition_schemes.md b/docs/backup_partition_schemes.md new file mode 100644 index 00000000..47949c40 --- /dev/null +++ b/docs/backup_partition_schemes.md @@ -0,0 +1,71 @@ +# Backup and restore, by partition scheme + +The rule that decides every shape below: **a CHD holds a whole disk.** +`backup --format chd` writes exactly one `.chd` whose logical bytes are +the disk, or it refuses. It never writes `partition-N.chd`. A partitionless +volume (a floppy, a BasiliskII `.hfv`, a bare Amiga `.hdf`) is the one case +where the partition *is* the disk, so its CHD is the volume, sized to the +source, with no table and no sidecar. + +Two layouts exist. **Per-partition** (`zstd`, `gzip`, `lz4`, `raw`, `vhd`) +stores one body per partition plus a table sidecar, and restore rebuilds the +table from the sidecar. **Single-file CHD** stores the disk image itself, and +restore is a byte copy unless a size changes. + +## The disk-label schemes + +Sun, NeXT, SGI volume header, SGI disk label, Amiga RDB, Atari AHDI and Sharp +X68k are backed up **whole**: everything before the first partition (label +copies, the RDSK/PART/FSHD/LSEG chain, the IPL and its table, boot blocks) +rides verbatim inside the CHD. Nothing on the restore side has to understand +the label to put the disk back. + +| Scheme | Per-partition layouts | Label rewrite (restore, backup-time resize, raw / VHD export) | Unit the patcher rounds to | +|---|---|---|---| +| Sun | refused | slices rewritten, XOR checksum restamped | cylinder (`ntrks * nsect`) | +| NeXT | refused | all four label copies rewritten | `d_secsize` (1024) past the front porch | +| SGI volume header | refused | slots rewritten, checksum recomputed | 512-byte block | +| SGI disk label | refused | eight slots rewritten in the label's own word order | 512-byte block | +| Amiga RDB | refused | RDSK + PART blocks overlaid, driver chain untouched (raw / VHD export has its own older path) | cylinder, per partition | +| Atari AHDI | refused | root-sector entries rewritten, 0x1234 word-sum restamped | 512-byte sector | +| Sharp X68k | allowed (`zstd` etc.) | table entries rewritten, sector-size aware | logical sector (256 / 512 / 1024) | + +The per-partition layouts are refused for the label schemes because their +sidecar is a *parsed* table, and re-serializing it loses what the head +carries (the RDB driver chain and bad-block list most visibly). X68k is the +exception: its per-partition restore rebuilds the table from `x68k.json` and +zero-fills the IPL region, which is fine for a MiSTer data disk and wrong for +a real SCSI disk whose `X68SCSI1` signature selects the sector size. Use CHD +for a faithful copy. + +**The rewrite** is `partition::restore_patch`, and every path that changes a +size goes through it. On restore, the head region is read out of the CHD, +rewritten, and the bodies are copied to wherever the label now says. A CHD +backup or export with a resize rewrites the head *before* the bodies are +staged, so the CHD carries the new layout and keeps the drive's size. A raw or +VHD export with size overrides copies the bodies to the new layout and writes +the rewritten head after them. Because each scheme counts in its own unit, +the patcher may round a partition up and shift the ones after it; it returns +the layout it actually wrote, and the body copy and filesystem resize follow +that. A filesystem `resize_filesystem_for` cannot shrink (UFS on a real Sun or +NeXT disk) is refused before anything is written. The head is written *after* +the bodies, so a label that lives inside its first slice (SunOS at cylinder 0) +still lands. + +## What cannot be backed up + +- **`.dsd`** (double-sided Acorn DFS): two sides track-interleaved in one + file, which the reader de-interleaves into two volumes. No restore could put + them back, so `backup` refuses it in every format. Copy the file; `ls`, + `get`, `put` and `convert` keep working on it. +- **An X68k SASI disk with a partition off a 512-byte boundary** is refused + for CHD only; the per-partition layouts still work. + +## Compacted bodies in a single-file CHD + +A packed FAT/NTFS/exFAT body sits shrunk inside its full partition in the CHD +(the tail is zeros, which the CHD compresses away). An as-is restore grows the +volume back to its partition, on every table, so the two layouts restore the +same disk. For NTFS that includes the backup boot sector the packed stream +stops short of. `--sector-by-sector` copies the source bytes instead, and +restores byte-identical. diff --git a/docs/build-ppc-mrustc.md b/docs/build-ppc-mrustc.md index 70098e73..0291c504 100644 --- a/docs/build-ppc-mrustc.md +++ b/docs/build-ppc-mrustc.md @@ -1078,6 +1078,34 @@ bin/mrustc hello.rs -o output-1.74.0-powerpc-apple-darwin/hello \ ssh $PPC_HOST './ppc-xbuild/output-1.74.0-powerpc-apple-darwin/hello' ``` +### No nix + +`rb-cli-ppc/Cargo.toml` declares no `nix`: lockfiles are target-agnostic, so +nix 0.31's `libc >= 0.2.186` floor would drag the whole graph onto a libc +mrustc cannot lower. + +The catch is that nothing under `../src` may then name it. `src/os/linux.rs` is +`#[cfg(target_os = "linux")]`, and the PowerPC target is macOS, so it never +reaches the PowerPC build - but the `hostc` / `host` stages transpile for *this* +machine, and on a Linux host that cfg is true. With no extern crate, mrustc +resolves `use nix::...` as a local path and the engine fails to compile: + +``` +src/os/linux.rs:8:5-37 error:0: Cannot find component 2 of crate::os::linux::nix::mount::umount2 +``` + +Those stages had only ever been run on an Apple Silicon host (`HOST_ARCH` +defaults to `aarch64`), where linux.rs is not compiled, so the host path had +never worked on Linux. + +`src/os/linux.rs` therefore reaches its seven POSIX calls - `umount2` plus +`geteuid` / `getuid` / `getgid` / `umask` in the elevation path - through `libc` +directly, in its private `sys` module. That is what nix did anyway: its +`umount2` is `libc::umount2` plus errno handling, and `MntFlags` takes its bits +from `libc`. One implementation serves every manifest, with no feature gate or +stub to drift. `nix` survives only in `src/main.rs` (the GUI binary, not part of +this crate); **a `use nix::` anywhere under `src/` breaks `hostc` again.** + ## rb-cli-ppc deviations (mrustc workarounds) `rb-cli-ppc/Cargo.toml` carries the manifest-level deviations; the vendored diff --git a/docs/full_MiSTer_support_status.md b/docs/full_MiSTer_support_status.md index 5ab3c37d..16e645f5 100644 --- a/docs/full_MiSTer_support_status.md +++ b/docs/full_MiSTer_support_status.md @@ -107,7 +107,9 @@ support the disk types (floppy / hard disk / CD-ROM) of the outstanding cores. SECTOR mode), MSA (Atari ST), EDSK/DSK (Amstrad CPC / PCW / Einstein / etc.), `.d88` (Sharp X68000 / PC-88 / PC-98 / MSX / FM-7), Acorn `.hdf` (bare + Arculator-wrapped), Apple-II `.do` / `.po` / `.dsk` sector-order, - gzip-wrapped Amiga `.adz` / `.hdz`. + gzip-wrapped Amiga `.adz` / `.hdz`, NeXT magneto-optical `.od` (raw MO media + — 1296-byte sectors with a cross-interleaved Reed-Solomon(36,32) code plus the + `od` driver's spare-sector map, both decoded transparently). - **Raw / superfloppy** (partitionless) images are handled. - **Optical / CD-ROM:** rip a physical CD/DVD drive to ISO or BIN/CUE (`optical rip`), list drives (`optical drives`), convert ISO <-> BIN/CUE <-> diff --git a/docs/solaris9-open-items.md b/docs/solaris9-open-items.md index d7104197..90173cee 100644 --- a/docs/solaris9-open-items.md +++ b/docs/solaris9-open-items.md @@ -58,6 +58,14 @@ the TUI, and read-only device enumeration. See `docs/build-sol9-mrustc.md` for t commit and seed failed once and passed on re-run. The CI job retries the stage once, which works because the build is incremental, but the race is upstream in minicargo and that retry is a mitigation rather than a fix. + **2026-09-12:** the retry used to fail on a *different* error than the one it was + retrying. The losing worker leaves the build-script marker behind with an empty + `OUT_DIR`, so the second attempt trusted the marker, skipped re-running the script + and died with `Unable to open .../private.rs`. The retry now clears + `$SOL9_OUT/{,host/}build_*` first, so the scripts genuinely re-run. Upstream, the + two minicargo commits on the `ppc-build-2026-09` branch (`0d3211be`, `debcce0e`) + address the scheduling side, but CI builds from a prebuilt seed that predates them, + so a reseeded toolchain is what would actually retire this item. - [ ] **The seed pins an mrustc commit implicitly.** `scripts/pack-sol9-seed.sh` packs whatever `bin/mrustc` and the stdlib outputs happen to be, with nothing recording which commit built them. Stamping that into the seed would make a stale one obvious. diff --git a/rb-cli-ppc/Cargo.toml b/rb-cli-ppc/Cargo.toml index c9d1f646..a0bb4a90 100644 --- a/rb-cli-ppc/Cargo.toml +++ b/rb-cli-ppc/Cargo.toml @@ -204,11 +204,16 @@ crc = "=3.3.0" # fs/resource_fork.rs. libc = "=0.2.155" -# DEVIATION (see header): no `nix`. It is a linux-only dependency (used only by -# src/os/linux.rs, never compiled for powerpc-apple-darwin), but Cargo lockfiles -# are target-agnostic, so nix 0.31's `libc >= 0.2.186` requirement would drag the -# whole graph onto a libc mrustc cannot lower. Dropping it is what lets the pin -# below work. +# DEVIATION (see header): no `nix`. Cargo lockfiles are target-agnostic, so nix +# 0.31's `libc >= 0.2.186` requirement would drag the whole graph onto a libc +# mrustc cannot lower. Dropping it is what lets the pin below work. +# +# Nothing under ../src needs it: src/os/linux.rs reaches umount2 / geteuid / +# getuid / getgid / umask through `libc` directly (its `sys` module), which is +# the same syscalls nix wrapped. Only the GUI binary src/main.rs still uses nix, +# and that is not part of this crate. Keep it that way: a `use nix::` anywhere +# in ../src breaks the `hostc` transpile proof on a Linux host, where +# target_os = "linux" selects that module and the extern crate is absent. # DEVIATION (see header): the objc2-* stack that rb-cli-vintage declares here is # absent. It is reachable only through src/os/macos.rs, which the `os-stub` diff --git a/regression-tests/cases/tier2/read-batch2-fixtures.toml b/regression-tests/cases/tier2/read-batch2-fixtures.toml new file mode 100644 index 00000000..25d37d88 --- /dev/null +++ b/regression-tests/cases/tier2/read-batch2-fixtures.toml @@ -0,0 +1,149 @@ +# Tier 2 — the second drop: NeXT magneto-optical, SunOS 4.1.3, Rhapsody. +# +# Three fixtures landed on 2026-09-10. Each one is here because it broke a +# different assumption, and each assumption failed *quietly* — every one of +# these opened, listed a plausible directory tree, and was wrong. +# +# fmt.od.next-mo-ns08 An `.od` is the RAW MO MEDIA, not the drive's +# user data: 1296-byte sectors carrying 1024 data +# bytes plus a cross-interleaved RS(36,32) code, +# logical block 0 is 848 sectors in, and the `od` +# DRIVER (not the drive) skips 8 spare sectors per +# 1600 from the label's own alternate-group +# fields. Skip the sparing and the label, the +# superblock and the root directory are all still +# correct — only the cylinder groups past the +# first drift, and fsck finds 58727 orphans. +# +# part.sun.sunos413-sparc SunOS 4.x wrote no VTOC, so every slice tag +# reads 0 and the tag-5 whole-disk alias can only +# be found by geometry. Before that, slice 2 +# listed as a sixth partition overlapping all the +# others. +# +# part.apm.rhapsody12 Rhapsody kept NeXT's disk label and nests one in +# its Apple_Rhapsody_UFS slice, so the filesystem +# starts 160 sectors past the slice. Addressing +# the slice found no superblock and browse fell +# through to the carve view — a 2 GiB +# `whole-disk.img` and 1200 `carved-blk*.txt`. +# +# Same shallow contract as the other tier-2 files: open it, identify it, walk +# the root, ask fsck. Depth belongs in tier 3, which mutates a copy. +# +# TWO OF THESE VOLUMES ARE DAMAGED, and the cases pin the exact damage rather +# than looking away — a reader that miscounts turns the case red: +# +# fmt.od.next-mo-ns08 @2 orphan inode 232, present in the pristine image +# part.sun.sunos413 @2 the destroyed miniroot. SunOS installs by +# copying a 7 MB root into the SWAP slice and +# booting it; swap then overwrites the data +# blocks. Superblock and inodes survive, the root +# directory block is zeros. It is unreadable and +# must SAY so rather than inventing a listing. +# +# Streams: the `Partition @N ...` banner goes to stderr; the inspect table, the +# ls entries and the fsck findings go to stdout. Assert on stdout. + +[meta] +tier = 2 +group = "fs.read.batch2" +description = "Open, identify, walk and fsck the NeXT MO / SunOS 4.1.3 / Rhapsody drop." + +# --- NeXT magneto-optical ------------------------------------------------------ + +[[case]] +id = "read.od.next-mo-ns08" +description = "NeXTSTEP 0.8 on a 256 MB Canon MO cartridge: ECC-coded 1296-byte sectors, an 848-sector origin, and driver-side spare-sector skipping." +fixture = "fmt.od.next-mo-ns08.hd" +[[case.step]] +args = ["inspect", "{fixture}"] +expect_exit = 0 +stdout_contains = ["Partition table: NeXT", "NeXT a (4.3BSD)", "NeXT b (4.3BSD)"] +[[case.step]] +# `odmach` is the optical-boot kernel, which is what makes this an MO disk and +# not a copy of somebody's hard drive. +args = ["ls", "{fixture}@1", "/"] +expect_exit = 0 +stdout_contains = ["odmach", "sdmach", "MyDisk", "lost+found"] +[[case.step]] +args = ["ls", "{fixture}@2", "/"] +expect_exit = 0 +stdout_contains = ["Apps", "Fonts", "Library", "Programming"] +[[case.step]] +# Clean. The sparing map is what this asserts: read the image linearly and +# this same fsck reports 58727 orphan inodes. +args = ["fsck", "{fixture}@1", "--checkonly"] +expect_exit = 0 +stdout_contains = ["5708 files"] +[[case.step]] +# Documented damage, not a regression: one orphan in the pristine image. +args = ["fsck", "{fixture}@2", "--checkonly"] +expect_exit = 1 +stdout_contains = ["OrphanInode", "232"] + +# --- SunOS 4.1.3, Sun label with no VTOC --------------------------------------- + +[[case]] +id = "read.sun.sunos413-sparc" +description = "SunOS 4.1.3 SPARC in a CHD. The label carries no VTOC, so the slices are untagged and the whole-disk alias is found by geometry." +fixture = "part.sun.sunos413-sparc.multipart" +[[case.step]] +# Five slices, not six: the backup alias at LBA 0 spanning the whole disk is +# excluded. `untagged` rather than `unassigned` — there is no VTOC to read. +args = ["inspect", "{fixture}"] +expect_exit = 0 +stdout_contains = ["Partition table: Sun", "Sun untagged (UFS?)", "62320", "2804400"] +stdout_not_contains = ["unassigned"] +[[case.step]] +args = ["ls", "{fixture}@1", "/"] +expect_exit = 0 +stdout_contains = ["lost+found", "export", "usr", "var"] +[[case.step]] +args = ["ls", "{fixture}@4", "/"] +expect_exit = 0 +stdout_contains = ["ucb", "include", "5lib", "ucbinclude"] +[[case.step]] +args = ["fsck", "{fixture}@1", "--checkonly"] +expect_exit = 0 +stdout_contains = ["853 files"] +[[case.step]] +args = ["fsck", "{fixture}@4", "--checkonly"] +expect_exit = 0 +stdout_contains = ["12850 files"] +[[case.step]] +# The destroyed miniroot in the swap slice. A valid superblock over a zeroed +# root directory: the failure has to be reported, not papered over. +args = ["ls", "{fixture}@2", "/"] +expect_exit = 1 +stderr_contains = ["d_reclen"] + +# --- Mac OS X Server 1.2, NeXT label nested in an APM slice --------------------- + +[[case]] +id = "read.apm.rhapsody12-nextlabel" +description = "Mac OS X Server 1.2 PowerPC. Rhapsody kept NeXT's disk label, so the UFS starts 160 sectors past the Apple_Rhapsody_UFS slice." +fixture = "part.apm.rhapsody12-nextlabel.multipart" +[[case.step]] +args = ["inspect", "{fixture}"] +expect_exit = 0 +stdout_contains = [ + "Partition table: APM", + "Apple_Rhapsody_UFS (Mac OS X Server 1.2)", + "Apple_Boot (MOSX_OF3_Booter)", +] +[[case.step]] +# `whole-disk.img` is the carve view's first entry. Seeing it here means the +# nested label was not resolved and we are browsing raw bytes again. +args = ["ls", "{fixture}@3", "/"] +expect_exit = 0 +stdout_contains = ["mach_kernel", "System", "private", "sbin"] +stdout_not_contains = ["whole-disk.img", "carved-blk"] +[[case.step]] +args = ["ls", "{fixture}@3", "/System"] +expect_exit = 0 +stdout_contains = ["Library", "Administration", "Documentation"] +[[case.step]] +args = ["fsck", "{fixture}@3", "--checkonly"] +expect_exit = 0 +stdout_contains = ["21135 files"] diff --git a/regression-tests/cases/tier3/edit-batch2-fixtures.toml b/regression-tests/cases/tier3/edit-batch2-fixtures.toml new file mode 100644 index 00000000..9a9640ee --- /dev/null +++ b/regression-tests/cases/tier3/edit-batch2-fixtures.toml @@ -0,0 +1,155 @@ +# Tier 3 — mutate the NeXT MO / SunOS 4.1.3 / Rhapsody volumes. +# +# The tier-2 file next door proves we can READ these. This one writes to them. +# Every case uses {fixture_copy}: a reference volume a test has written to has +# stopped being a reference. +# +# The gamut is the one the other tier-3 files use: +# +# put -> get -> byte-compare -> chmod/chown -> mkdir -> ls -> rm -> fsck +# +# with the fsck at the end doing the real work. A mutation that returns 0 and +# leaves an inconsistent volume is the failure mode that matters. +# +# The MO case carries an extra obligation the others do not. An `.od` sector is +# 1024 data bytes plus 272 bytes of cross-interleaved Reed-Solomon, so a write +# that lands the data correctly and leaves stale ECC is unreadable on the real +# hardware and looks *fine* to us — we never consult the code on read. Nothing +# in this suite can catch that; what does is `ecc_bytes_are_reproducible` and +# the table pinned against Previous's in `src/rbformats/next_mo.rs`, plus the +# byte-for-byte check against real media recorded there. This case's job is the +# layer above: that an edit through the ECC container commits at all and leaves +# the volume sound. +# +# ON THE MO's SECOND PARTITION the fsck assertion is stronger than "clean": it +# pins the pre-existing orphan inode 232. One orphan before an edit and one +# after is what proves our allocator leaked nothing new. + +[meta] +tier = 3 +group = "edit.batch2" +description = "Write to the NeXT MO / SunOS / Rhapsody volumes; assert each stays sound." + +# --- NeXT magneto-optical, through the ECC container --------------------------- + +[[case]] +id = "edit.batch2.next-mo-ns08" +description = "Editing 4.3BSD UFS inside a raw MO image. Every touched sector is re-encoded through both Reed-Solomon passes and written back in place — no decode-to-temp step." +fixture = "fmt.od.next-mo-ns08.hd" +[[case.step]] +args = ["put", "{fixture_copy}@1", "{cases}/tier3/payload.bin", "/payload.bin"] +expect_exit = 0 +[[case.step]] +args = ["get", "{fixture_copy}@1", "/payload.bin", "{scratch}/out.bin"] +expect_exit = 0 +files_identical = [["{cases}/tier3/payload.bin", "{scratch}/out.bin"]] +[[case.step]] +args = ["chmod", "{fixture_copy}@1", "/payload.bin", "0640"] +expect_exit = 0 +[[case.step]] +args = ["chown", "{fixture_copy}@1", "/payload.bin", "100:20"] +expect_exit = 0 +[[case.step]] +args = ["mkdir", "{fixture_copy}@1", "/rbtest"] +expect_exit = 0 +[[case.step]] +args = ["ls", "{fixture_copy}@1", "/"] +expect_exit = 0 +stdout_contains = ["payload.bin", "rbtest"] +[[case.step]] +args = ["rm", "{fixture_copy}@1", "/payload.bin"] +expect_exit = 0 +[[case.step]] +args = ["rm", "-r", "{fixture_copy}@1", "/rbtest"] +expect_exit = 0 +[[case.step]] +args = ["ls", "{fixture_copy}@1", "/"] +expect_exit = 0 +stdout_not_contains = ["payload.bin", "rbtest"] +[[case.step]] +args = ["fsck", "{fixture_copy}@1", "--checkonly"] +expect_exit = 0 +stdout_contains = ["5708 files"] + +[[case]] +id = "edit.batch2.next-mo-ns08-second" +description = "The MO's second partition, which carries a pre-existing orphan inode. Editing it must leave exactly that and nothing more." +fixture = "fmt.od.next-mo-ns08.hd" +[[case.step]] +args = ["put", "{fixture_copy}@2", "{cases}/tier3/payload.bin", "/payload.bin"] +expect_exit = 0 +[[case.step]] +args = ["get", "{fixture_copy}@2", "/payload.bin", "{scratch}/out.bin"] +expect_exit = 0 +files_identical = [["{cases}/tier3/payload.bin", "{scratch}/out.bin"]] +[[case.step]] +# STILL exactly one orphan, and still inode 232. A second error means our write +# broke something; a clean result means the orphan stopped being reported, +# which is equally a change worth failing on. +args = ["fsck", "{fixture_copy}@2", "--checkonly"] +expect_exit = 1 +stdout_contains = ["OrphanInode", "232"] + +# --- SunOS 4.1.3, through a CHD ------------------------------------------------ + +[[case]] +id = "edit.batch2.sun-sunos413" +description = "Editing big-endian pre-4.4BSD UFS inside a Sun label with no VTOC, through a CHD container." +fixture = "part.sun.sunos413-sparc.multipart" +[[case.step]] +args = ["put", "{fixture_copy}@1", "{cases}/tier3/payload.bin", "/payload.bin"] +expect_exit = 0 +[[case.step]] +args = ["get", "{fixture_copy}@1", "/payload.bin", "{scratch}/out.bin"] +expect_exit = 0 +files_identical = [["{cases}/tier3/payload.bin", "{scratch}/out.bin"]] +[[case.step]] +args = ["mkdir", "{fixture_copy}@1", "/rbtest"] +expect_exit = 0 +[[case.step]] +args = ["rm", "{fixture_copy}@1", "/payload.bin"] +expect_exit = 0 +[[case.step]] +args = ["rm", "-r", "{fixture_copy}@1", "/rbtest"] +expect_exit = 0 +[[case.step]] +args = ["fsck", "{fixture_copy}@1", "--checkonly"] +expect_exit = 0 +stdout_contains = ["853 files"] + +# --- Mac OS X Server 1.2, behind a nested NeXT label --------------------------- + +[[case]] +id = "edit.batch2.rhapsody12" +description = "Editing the Rhapsody UFS. The write path has to resolve the nested NeXT label the same way the read path does, or it writes 160 sectors early." +fixture = "part.apm.rhapsody12-nextlabel.multipart" +[[case.step]] +args = ["put", "{fixture_copy}@3", "{cases}/tier3/payload.bin", "/payload.bin"] +expect_exit = 0 +[[case.step]] +args = ["get", "{fixture_copy}@3", "/payload.bin", "{scratch}/out.bin"] +expect_exit = 0 +files_identical = [["{cases}/tier3/payload.bin", "{scratch}/out.bin"]] +[[case.step]] +args = ["mkdir", "{fixture_copy}@3", "/rbtest"] +expect_exit = 0 +[[case.step]] +args = ["ls", "{fixture_copy}@3", "/"] +expect_exit = 0 +stdout_contains = ["payload.bin", "rbtest", "mach_kernel"] +[[case.step]] +args = ["rm", "{fixture_copy}@3", "/payload.bin"] +expect_exit = 0 +[[case.step]] +args = ["rm", "-r", "{fixture_copy}@3", "/rbtest"] +expect_exit = 0 +[[case.step]] +# The label is what makes the slice bootable: a write that walked over it +# would still fsck clean, so the partition table is re-read as the check. +args = ["inspect", "{fixture_copy}"] +expect_exit = 0 +stdout_contains = ["Apple_Rhapsody_UFS (Mac OS X Server 1.2)"] +[[case.step]] +args = ["fsck", "{fixture_copy}@3", "--checkonly"] +expect_exit = 0 +stdout_contains = ["21135 files"] diff --git a/regression-tests/cases/tier5/chd-label-resize.toml b/regression-tests/cases/tier5/chd-label-resize.toml new file mode 100644 index 00000000..61c7743e --- /dev/null +++ b/regression-tests/cases/tier5/chd-label-resize.toml @@ -0,0 +1,227 @@ +# Tier 5 — resize a disk-label backup on restore. +# +# The label schemes (Sun, NeXT, SGI volume header, SGI disk label, Amiga RDB, +# Atari AHDI, Sharp X68k) are backed up whole: the head region rides verbatim +# in the single-file CHD. A resize is applied on restore, where +# `partition::restore_patch` rewrites the label's start/size fields and +# checksums in those verbatim bytes and the bodies land wherever the label +# now says. Until 2026-09-11 a `--size minimum` restore of any of these +# exited 0 and corrupted the disk: the bodies moved and the label did not. +# +# Every case here shrinks the first partition to its minimum and checks three +# things: the label parses and no longer places the next partition where it +# was, the shrunk filesystem still lists its files, and fsck is clean. + +[meta] +tier = 5 +group = "chd.label-resize" +description = "Shrink the first partition of each disk-label scheme on restore; the label follows." + +# --- FAT slices on the schemes that can hold one --------------------------------- + +[[case]] +id = "chd.label-resize.sun-fat-slice" +description = "Sun label: slice 0 shrinks to a cylinder boundary and slice 1 slides down from LBA 67536." +[[case.step]] +args = ["new", "volume", "fat", "{scratch}/v.img", "--size", "32M"] +expect_exit = 0 +[[case.step]] +args = ["put", "{scratch}/v.img", "{cases}/tier3/payload.bin", "/PAYLOAD.BIN"] +expect_exit = 0 +[[case.step]] +args = ["new", "hd", "sun", "{scratch}/s.img", "--size", "64M", "--partition", "32M", "--partition", "rest", "--fill", "1={scratch}/v.img"] +expect_exit = 0 +[[case.step]] +args = ["backup", "{scratch}/s.img", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img", "--size", "minimum"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["Partition table: Sun", "1008"] +stdout_not_contains = ["67536", "32.5 MiB"] +[[case.step]] +args = ["ls", "{scratch}/out.img@1", "/"] +expect_exit = 0 +stdout_contains = ["PAYLOAD.BIN"] +[[case.step]] +args = ["fsck", "{scratch}/out.img@1", "--checkonly"] +expect_exit = 0 + +[[case]] +id = "chd.label-resize.next-fat-slot" +description = "NeXT label: all four copies rewritten, partition b slides down from LBA 67584." +[[case.step]] +args = ["new", "volume", "fat", "{scratch}/v.img", "--size", "32M"] +expect_exit = 0 +[[case.step]] +args = ["put", "{scratch}/v.img", "{cases}/tier3/payload.bin", "/PAYLOAD.BIN"] +expect_exit = 0 +[[case.step]] +args = ["new", "hd", "next", "{scratch}/n.img", "--size", "64M", "--partition", "32M", "--partition", "rest", "--fill", "1={scratch}/v.img"] +expect_exit = 0 +[[case.step]] +args = ["backup", "{scratch}/n.img", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img", "--size", "minimum"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["Partition table: NeXT", "2048"] +stdout_not_contains = ["67584", "32.0 MiB"] +[[case.step]] +args = ["ls", "{scratch}/out.img@1", "/"] +expect_exit = 0 +stdout_contains = ["PAYLOAD.BIN"] +[[case.step]] +args = ["fsck", "{scratch}/out.img@1", "--checkonly"] +expect_exit = 0 + +[[case]] +id = "chd.label-resize.ahdi-bgm" +description = "Atari AHDI: the root sector's entries and checksum are rewritten, partition 2 slides down from LBA 67584." +[[case.step]] +args = ["new", "volume", "fat", "{scratch}/v.img", "--size", "32M"] +expect_exit = 0 +[[case.step]] +args = ["put", "{scratch}/v.img", "{cases}/tier3/payload.bin", "/PAYLOAD.BIN"] +expect_exit = 0 +[[case.step]] +args = ["new", "hd", "atari", "{scratch}/a.img", "--size", "64M", "--partition", "32M:GEM", "--partition", "rest:GEM", "--fill", "1={scratch}/v.img"] +expect_exit = 0 +[[case.step]] +args = ["backup", "{scratch}/a.img", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img", "--size", "minimum"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["Partition table: AHDI", "2048"] +stdout_not_contains = ["67584", "32.0 MiB"] +[[case.step]] +args = ["ls", "{scratch}/out.img@1", "/"] +expect_exit = 0 +stdout_contains = ["PAYLOAD.BIN"] +[[case.step]] +args = ["fsck", "{scratch}/out.img@1", "--checkonly"] +expect_exit = 0 + +# --- the SGI schemes, on their own filesystems -------------------------------------- + +[[case]] +id = "chd.label-resize.sgi-efs-root" +description = "SGI volume header: the EFS root shrinks to its minimum and the header's slot follows." +[[case.step]] +args = ["new", "hd", "sgi-efs", "{scratch}/g.img", "--size", "64M"] +expect_exit = 0 +[[case.step]] +args = ["put", "{scratch}/g.img@1", "{cases}/tier3/payload.bin", "/payload.bin"] +expect_exit = 0 +[[case.step]] +args = ["backup", "{scratch}/g.img", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img", "--size", "minimum"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["Partition table: SGI", "5040"] +stdout_not_contains = ["62.0 MiB"] +[[case.step]] +args = ["ls", "{scratch}/out.img@1", "/"] +expect_exit = 0 +stdout_contains = ["payload.bin"] +[[case.step]] +args = ["fsck", "{scratch}/out.img@1", "--checkonly"] +expect_exit = 0 + +[[case]] +id = "chd.label-resize.sgi-dklabel-root" +description = "IRIS disk label: the EFS v1 root shrinks, swap slides down from block 34272, and the label keeps its word order." +[[case.step]] +args = ["new", "volume", "efs-v1", "{scratch}/e.img", "--size", "16M"] +expect_exit = 0 +[[case.step]] +args = ["put", "{scratch}/e.img", "{cases}/tier3/payload.bin", "/payload.bin"] +expect_exit = 0 +[[case.step]] +args = ["new", "hd", "sgi-dklabel", "{scratch}/d.img", "--size", "64M", "--partition", "16M:root", "--partition", "8M:swap", "--partition", "rest:slice", "--fill", "1={scratch}/e.img"] +expect_exit = 0 +[[case.step]] +args = ["backup", "{scratch}/d.img", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img", "--size", "minimum"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["Partition table: SGI-DkLabel", "1008", "SGI swap"] +stdout_not_contains = ["34272"] +[[case.step]] +args = ["ls", "{scratch}/out.img@1", "/"] +expect_exit = 0 +stdout_contains = ["payload.bin"] +[[case.step]] +args = ["fsck", "{scratch}/out.img@1", "--checkonly"] +expect_exit = 0 + +# --- Amiga RDB, on a real Workbench disk -------------------------------------------- + +[[case]] +id = "chd.label-resize.rdb-workbench13" +description = "Amiga RDB: the RDSK and PART blocks are rewritten on cylinder boundaries and the FSHD/LSEG chain rides through untouched." +fixture = "fs.affs.workbench13.hd" +timeout_ms = 1200000 +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img", "--size", "minimum"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["Partition table: RDB"] +[[case.step]] +args = ["fsck", "{scratch}/out.img@1", "--checkonly"] +expect_exit = 0 +stdout_contains = ["337 files"] + +# --- Sharp X68000, now a single-file CHD --------------------------------------------- + +[[case]] +id = "chd.label-resize.x68k-single-file" +description = "X68k: one whole-disk CHD, an as-is restore that carries the IPL back byte for byte, and a table rewrite on --size minimum." +[[case.step]] +args = ["new", "hd", "x68k", "{scratch}/x.img", "--size", "32M"] +expect_exit = 0 +[[case.step]] +args = ["backup", "{scratch}/x.img", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/bk/job/job.chd"] +expect_exit = 0 +stdout_contains = ["Logical size: 33,554,432 bytes", "Partition table: X68k"] +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/asis.img"] +expect_exit = 0 +files_identical = [["{scratch}/x.img", "{scratch}/asis.img"]] +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/min.img", "--size", "minimum"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/min.img"] +expect_exit = 0 +stdout_contains = ["Partition table: X68k", "16.1 MiB"] +[[case.step]] +args = ["ls", "{scratch}/min.img@1", "/"] +expect_exit = 0 +stdout_contains = ["HELLO.TXT"] diff --git a/regression-tests/cases/tier5/chd-whole-disk-rule.toml b/regression-tests/cases/tier5/chd-whole-disk-rule.toml new file mode 100644 index 00000000..145ab2ad --- /dev/null +++ b/regression-tests/cases/tier5/chd-whole-disk-rule.toml @@ -0,0 +1,91 @@ +# Tier 5 — a CHD is a whole disk. +# +# `backup --format chd` writes exactly one `.chd` holding the whole disk, +# or refuses. Until 2026-09-11 a table the single-file layout could not +# assemble (X68k, double-sided DFS) fell through to the per-partition loop and +# wrote `partition-N.chd`: a CHD of one partition is not a disk image, and for +# a `.dsd` the two of them were not even restorable. X68k has since joined the +# single-file layout (see chd-label-resize.toml); a `.dsd` stays refused. +# +# The second half pins what an as-is restore of a single-file CHD does with a +# packed body. The packed FAT/NTFS/exFAT reader shrinks the volume to its +# used extent and zero-fills the rest of the partition, so the CHD carries a +# 15 MB partition holding an 8 MB FAT16. The restore grows the volume back to +# its partition, as the per-partition layout always did, and for NTFS that +# includes the backup boot sector the packed stream stops short of. + +[meta] +tier = 5 +group = "chd.rule" +description = "CHD output is one whole-disk image or a refusal, and a packed body grows back on restore." + +# --- refusals ------------------------------------------------------------------ + +[[case]] +id = "chd.rule.dsd-backup-refused" +description = "A double-sided DFS image is two track-interleaved sides in one file and no restore can put them back, so backup refuses it in every format." +fixture = "fs.dfs.double-sided.floppy" +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "zstd"] +expect_exit = 1 +stderr_contains = ["cannot be backed up"] +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 1 +stderr_contains = ["cannot be backed up"] +[[case.step]] +# The file is still fully usable; copying it is its backup. +args = ["ls", "{fixture}@1", "/"] +expect_exit = 0 + +# --- a packed body grows back --------------------------------------------------- + +[[case]] +id = "chd.rule.mbr-fat-grows-back-on-restore" +description = "A FAT16 partition packed to 8 MB inside its 15 MB extent comes back filling the partition, byte-identical to the source." +[[case.step]] +args = ["new", "volume", "fat", "{scratch}/v.img", "--size", "15M"] +expect_exit = 0 +[[case.step]] +args = ["put", "{scratch}/v.img", "{cases}/tier3/payload.bin", "/PAYLOAD.BIN"] +expect_exit = 0 +[[case.step]] +args = ["new", "hd", "mbr", "{scratch}/m.img", "--size", "16M", "--partition", "rest:0x06", "--fill", "1={scratch}/v.img"] +expect_exit = 0 +[[case.step]] +args = ["backup", "{scratch}/m.img", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img"] +expect_exit = 0 +files_identical = [["{scratch}/m.img", "{scratch}/out.img"]] + +[[case]] +id = "chd.rule.ntfs-backup-boot-sector-restored" +description = "The packed NTFS stream stops one sector before the backup boot sector; both layouts must put it back, or fsck flags the volume." +[[case.step]] +args = ["new", "volume", "ntfs", "{scratch}/n.img", "--size", "20M"] +expect_exit = 0 +[[case.step]] +args = ["put", "{scratch}/n.img", "{cases}/tier3/payload.bin", "/payload.bin"] +expect_exit = 0 +[[case.step]] +args = ["backup", "{scratch}/n.img", "{scratch}/bkc", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bkc/job", "{scratch}/outc.img"] +expect_exit = 0 +files_identical = [["{scratch}/n.img", "{scratch}/outc.img"]] +[[case.step]] +args = ["fsck", "{scratch}/outc.img", "--checkonly"] +expect_exit = 0 +[[case.step]] +args = ["backup", "{scratch}/n.img", "{scratch}/bkz", "--name", "job", "--format", "zstd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bkz/job", "{scratch}/outz.img"] +expect_exit = 0 +files_identical = [["{scratch}/n.img", "{scratch}/outz.img"]] +[[case.step]] +args = ["fsck", "{scratch}/outz.img", "--checkonly"] +expect_exit = 0 diff --git a/regression-tests/cases/tier5/roundtrip-label-schemes.toml b/regression-tests/cases/tier5/roundtrip-label-schemes.toml new file mode 100644 index 00000000..9fc18a7e --- /dev/null +++ b/regression-tests/cases/tier5/roundtrip-label-schemes.toml @@ -0,0 +1,279 @@ +# Tier 5 — backup and restore for the disk-label partition schemes. +# +# Sun, NeXT and both SGI schemes bailed out of `backup` entirely until +# 2026-09-10, and an Amiga RDB did something worse — it backed up fine and +# then could not be restored, because the sidecar keeps a *parsed* table and +# re-serializing it loses the FSHD/LSEG driver chain. In every case the +# sidecar JSON was written and the run then aborted with "browse only". They go +# through the single-file-CHD layout, which writes a real disk image (table at +# sector 0, partitions at their declared offsets) and restores by copying the +# CHD's logical bytes, so nothing on the restore side has to understand the +# label at all. +# +# What the layout does need is a head region, and for these two it is copied +# **verbatim** — we have no writer that could patch a Sun or NeXT label for a +# resize. Two consequences these cases pin: +# +# * the per-partition layouts (zstd / gzip / raw / VHD) are refused, and the +# message has to name the layout that works rather than just saying no; +# * on a SunOS disk the head region is empty by construction, because slice 0 +# starts at cylinder 0 and carries the label in its own first sector. +# +# What these cases do NOT assert is whole-image byte equality, and the reason is +# worth writing down because it looks like a bug the first time you see it. The +# single-file-CHD assembler zero-fills everything that is not a partition — for +# every scheme, not just these two — so a NeXT disk's back porch comes back as +# 1248 sectors of zeros where the erased MO media had 0xFF. That region is +# reserved by `d_back` and holds nothing; `--sector-by-sector` is a promise +# about partition bodies, not about gaps. So the assertions here are the +# filesystem's own: fsck walks every cylinder group, which is exactly what a +# lost sparing map or a shifted partition would break, and an extracted file is +# compared byte for byte. + +[meta] +tier = 5 +group = "roundtrip.label" +description = "Back up and restore the disk-label schemes; prove the label and every partition survived." + +# --- NeXT label ---------------------------------------------------------------- + +[[case]] +id = "roundtrip.label.next-mo-ns08" +description = "NeXT MO cartridge: back up straight out of the ECC container, restore to a flat image, and prove both partitions came back whole." +fixture = "fmt.od.next-mo-ns08.hd" +timeout_ms = 1200000 +[[case.step]] +args = ["get", "{fixture}@1", "/odmach", "{scratch}/odmach.src"] +expect_exit = 0 +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "chd", "--sector-by-sector"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["Partition table: NeXT", "NeXT a (4.3BSD)", "NeXT b (4.3BSD)"] +[[case.step]] +# The optical boot kernel, 600 KB spread over enough cylinder groups that a +# lost spare-sector map cannot leave it intact. +args = ["get", "{scratch}/out.img@1", "/odmach", "{scratch}/odmach.out"] +expect_exit = 0 +files_identical = [["{scratch}/odmach.src", "{scratch}/odmach.out"]] +[[case.step]] +args = ["fsck", "{scratch}/out.img@1", "--checkonly"] +expect_exit = 0 +stdout_contains = ["5708 files"] +[[case.step]] +# Still exactly the one orphan the pristine cartridge carries. +args = ["fsck", "{scratch}/out.img@2", "--checkonly"] +expect_exit = 1 +stdout_contains = ["OrphanInode", "232"] + +[[case]] +id = "roundtrip.label.next-mo-refuses-per-partition" +description = "A per-partition backup of a NeXT-labeled disk is refused, and the message names the layout that works." +fixture = "fmt.od.next-mo-ns08.hd" +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "zstd"] +expect_exit = 1 +stderr_contains = ["single-file CHD"] + +# --- Sun label, no VTOC ------------------------------------------------------- + +[[case]] +id = "roundtrip.label.sun-sunos413" +description = "SunOS 4.1.3: five slices and a label that lives inside slice 0's first sector, so the head region is empty." +fixture = "part.sun.sunos413-sparc.multipart" +timeout_ms = 1800000 +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img"] +expect_exit = 0 +[[case.step]] +# The label came back: still five untagged slices at the same offsets, and +# still no sixth entry for the whole-disk alias. +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["Partition table: Sun", "Sun untagged (UFS?)", "62320", "2804400"] +[[case.step]] +args = ["fsck", "{scratch}/out.img@1", "--checkonly"] +expect_exit = 0 +stdout_contains = ["853 files"] +[[case.step]] +args = ["fsck", "{scratch}/out.img@4", "--checkonly"] +expect_exit = 0 +stdout_contains = ["12850 files"] + +# --- APM with a nested NeXT label --------------------------------------------- + +[[case]] +id = "roundtrip.label.rhapsody12" +description = "Rhapsody: the compact stream is the nested label's head region verbatim followed by the layout-preserving UFS, so the restore has to bring the label back with it." +fixture = "part.apm.rhapsody12-nextlabel.multipart" +timeout_ms = 1800000 +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img"] +expect_exit = 0 +[[case.step]] +# Browsing the restored slice at all is the assertion: without the nested +# label the UFS is 160 sectors away and this falls through to the carve view. +args = ["ls", "{scratch}/out.img@3", "/"] +expect_exit = 0 +stdout_contains = ["mach_kernel", "System", "private"] +stdout_not_contains = ["whole-disk.img", "carved-blk"] +[[case.step]] +args = ["fsck", "{scratch}/out.img@3", "--checkonly"] +expect_exit = 0 +stdout_contains = ["21135 files"] + + +# --- SGI volume header -------------------------------------------------------- + +[[case]] +id = "roundtrip.label.sgi-riscos" +description = "An `fx` disk describes several alternative layouts at once, so its slots overlap and the drive is imaged as one body with the slot table in sgi.json." +fixture = "part.sgi.riscos-mips.multipart" +timeout_ms = 1800000 +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img"] +expect_exit = 0 +[[case.step]] +# All fourteen browsable slots back, at the same offsets — that is what proves +# the volume header rode along inside the one body we imaged. +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["Partition table: SGI", "SGI BSD", "2520", "1533000", "VOLHDR"] +[[case.step]] +args = ["ls", "{scratch}/out.img@1", "/"] +expect_exit = 0 +stdout_contains = ["lost+found", "usr", "etc"] + +# --- SGI disk label (pre-IRIX IRIS) ------------------------------------------- + +[[case]] +id = "roundtrip.label.sgi-dklabel-iris" +description = "The IRIS 3130 disk label: three non-overlapping slots behind a 119-sector head, and the image is word-swapped — which the copy never has to know, because those bytes are never interpreted." +fixture = "fs.efs-v1.populated-irix37.multipart" +timeout_ms = 1200000 +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "chd", "--sector-by-sector"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img"] +expect_exit = 0 +[[case.step]] +# This one really is byte-for-byte: the slots are contiguous from the head to +# the end of the last, so there is no gap for the assembler to zero-fill. +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["SGI-DkLabel (byte-swapped)", "SGI root (EFS v1)", "SGI swap"] +files_identical = [["{fixture}", "{scratch}/out.img"]] +[[case.step]] +args = ["ls", "{scratch}/out.img@1", "/"] +expect_exit = 0 +stdout_contains = ["vmunix", "defaultboot", "kernels"] + +[[case]] +id = "roundtrip.label.sgi-refuses-per-partition" +description = "A per-partition backup of an SGI-labeled disk is refused, and the message names the layout that works." +fixture = "fs.efs-v1.populated-irix37.multipart" +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "zstd"] +expect_exit = 1 +stderr_contains = ["single-file CHD"] + + +# --- Amiga RDB ---------------------------------------------------------------- + +[[case]] +id = "roundtrip.label.rdb-workbench13" +description = "AmigaDOS FFS behind an RDB. The reserved area holds RDSK, the PART chain and the FSHD/LSEG driver blocks — the exact thing a re-serialized table loses." +fixture = "fs.affs.workbench13.hd" +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "chd", "--sector-by-sector"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img"] +expect_exit = 0 +files_identical = [["{fixture}", "{scratch}/out.img"]] +[[case.step]] +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["Partition table: RDB", "AmigaDOS FFS (DH0)", "2020"] +[[case.step]] +args = ["ls", "{scratch}/out.img@1", "/"] +expect_exit = 0 +stdout_contains = ["AmigaBASIC", "BasicDemos"] + +[[case]] +id = "roundtrip.label.rdb-pfs3-compacted" +description = "The same disk without --sector-by-sector: the body is compacted, so equality is not the expectation — a clean fsck is." +fixture = "fs.affs.workbench13.hd" +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img"] +expect_exit = 0 +[[case.step]] +args = ["fsck", "{scratch}/out.img@1", "--checkonly"] +expect_exit = 0 +stdout_contains = ["337 files"] + +[[case]] +id = "roundtrip.label.rdb-refuses-per-partition" +description = "A per-partition RDB backup used to succeed and then fail to restore. It is refused up front now." +fixture = "fs.pfs3.rdb-cd32saves.hd" +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "zstd"] +expect_exit = 1 +stderr_contains = ["single-file CHD"] +[[case.step]] +# And the CHD one it points at does work, on a PFS3 volume this time. +args = ["backup", "{fixture}", "{scratch}/bk2", "--name", "job", "--format", "chd", "--sector-by-sector"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk2/job", "{scratch}/out.img"] +expect_exit = 0 +files_identical = [["{fixture}", "{scratch}/out.img"]] +[[case.step]] +args = ["ls", "{scratch}/out.img@1", "/"] +expect_exit = 0 +stdout_contains = ["WHDSaves"] + +# --- Atari AHDI --------------------------------------------------------------- + +[[case]] +id = "roundtrip.label.ahdi-base" +description = "Atari AHDI: the root sector at LBA 0 with two primaries. Backed up fine before this and then failed at restore with \"no MBR data available\"." +fixture = "part.ahdi.base.multipart" +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "chd", "--sector-by-sector"] +expect_exit = 0 +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img"] +expect_exit = 0 +files_identical = [["{fixture}", "{scratch}/out.img"]] +[[case.step]] +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["Partition table: AHDI", "AHDI GEM", "AHDI BGM"] + +[[case]] +id = "roundtrip.label.ahdi-refuses-per-partition" +description = "The per-partition layout is refused up front rather than producing a folder restore turns away." +fixture = "part.ahdi.base.multipart" +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "zstd"] +expect_exit = 1 +stderr_contains = ["single-file CHD"] diff --git a/regression-tests/cases/tier5/roundtrip-partitionless.toml b/regression-tests/cases/tier5/roundtrip-partitionless.toml new file mode 100644 index 00000000..51a80191 --- /dev/null +++ b/regression-tests/cases/tier5/roundtrip-partitionless.toml @@ -0,0 +1,85 @@ +# Tier 5 — CHD backup of a partitionless volume is one whole disk. +# +# A `.chd` holds a whole disk. For a source with no partition table the volume +# *is* the disk, so `backup --format chd` has to write one `.chd` whose +# logical size is the source size. Until 2026-09-10 it wrote `partition-0.chd` +# instead: the packed FAT body wearing a `.chd`, which `chdman info` reported +# as an 8.5 KB hard disk. The folder restored fine — only the container was +# not a disk image. These cases pin the container, not just the restore. +# +# The `inspect` of the container is the assertion that the shape is right: a +# per-partition layout has no `job.chd` at all, and a packed one reports the +# packed extent as its logical size. + +[meta] +tier = 5 +group = "roundtrip.partitionless" +description = "A partitionless volume backs up to one whole-disk CHD, and comes back." + +# --- synthetic floppy --------------------------------------------------------- + +[[case]] +id = "roundtrip.partitionless.floppy-chd-is-the-disk" +description = "A 1.44 MB FAT floppy, compacted: the CHD is the full floppy, not the packed volume, and restore is byte-identical." +[[case.step]] +args = ["new", "floppy", "fat", "--size", "1440K", "{scratch}/src.img"] +expect_exit = 0 +[[case.step]] +args = ["backup", "{scratch}/src.img", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/bk/job/job.chd"] +expect_exit = 0 +stdout_contains = ["Logical size: 1,474,560 bytes", "Partition table: None"] +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img"] +expect_exit = 0 +files_identical = [["{scratch}/src.img", "{scratch}/out.img"]] + +# --- BasiliskII HFV: a partitionless hard disk ------------------------------- + +[[case]] +id = "roundtrip.partitionless.hfv-macos81" +description = "A 300 MB BasiliskII HFV with Mac OS 8.1 on it: the CHD is the whole volume, and the restored HFS is clean." +fixture = "fs.hfv.populated-macos81.hd" +timeout_ms = 1800000 +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "chd"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/bk/job/job.chd"] +expect_exit = 0 +stdout_contains = ["Logical size: 314,572,800 bytes", "Partition table: None"] +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/out.img"] +expect_exit = 0 +stdout_contains = ["Partition table: None", "HFS"] +[[case.step]] +args = ["fsck", "{scratch}/out.img", "--checkonly"] +expect_exit = 0 + +# --- Amiga SFS: a bare HDF ---------------------------------------------------- + +[[case]] +id = "roundtrip.partitionless.sfs-workbench-sector-by-sector" +description = "A 500 MB bare Amiga HDF (SFS): sector-by-sector, so the restore has to be byte-identical through the whole-disk CHD." +fixture = "fs.sfs.workbench-dh0.hd" +timeout_ms = 1800000 +[[case.step]] +args = ["backup", "{fixture}", "{scratch}/bk", "--name", "job", "--format", "chd", "--sector-by-sector"] +expect_exit = 0 +[[case.step]] +args = ["inspect", "{scratch}/bk/job/job.chd"] +expect_exit = 0 +stdout_contains = ["Logical size: 522,805,248 bytes"] +[[case.step]] +args = ["restore", "{scratch}/bk/job", "{scratch}/out.img"] +expect_exit = 0 +files_identical = [["{fixture}", "{scratch}/out.img"]] +[[case.step]] +args = ["ls", "{scratch}/out.img", "/", "--fs-type", "SFS\\0"] +expect_exit = 0 +stdout_contains = ["Utilities", "WBStartup", "Fonts"] diff --git a/regression-tests/data/formats.toml b/regression-tests/data/formats.toml index ad98ebbc..213cb9dc 100644 --- a/regression-tests/data/formats.toml +++ b/regression-tests/data/formats.toml @@ -191,6 +191,24 @@ we_read = true we_write = false notes = "Superblock offset differs: NetBSD makefs 8192, FreeBSD newfs 65536" +[[format]] +id = "fs.bfs" +kind = "fs" +name = "BFS (BeOS)" +we_read = true +we_write = true +builder = "new volume bfs" +notes = "Both byte orders. The union at inode offset 0x48 and the index B+trees are where it goes wrong." + +[[format]] +id = "fs.ofs" +kind = "fs" +name = "OFS (BeOS, pre-R3)" +we_read = true +we_write = true +builder = "new volume ofs" +notes = "The 1993 Hobbit prototype is the only specimen there is; Steve White's ofs-extractor.c is the only published description." + [[format]] id = "fs.xfs" kind = "fs" @@ -311,6 +329,18 @@ we_read = true we_write = true builder = "convert --format chd" +[[format]] +id = "fmt.od" +kind = "container" +name = "NeXT magneto-optical media" +we_read = true +we_write = false +notes = """The raw MO cartridge, not the drive's user data: 1296-byte sectors +carrying 1024 data bytes plus a cross-interleaved RS(36,32) code, an origin 848 +sectors in, and driver-side spare-sector skipping from the NeXT label's own +alternate-group fields. Read + in-place edit; there is no writer that creates +one from scratch, which is why we_write is false.""" + [[format]] id = "fmt.vhd-fixed" kind = "container" @@ -995,6 +1025,27 @@ builder = "convert --format chd-cd" # --- Partition --------------------------------------------------------------- +[[format]] +id = "part.next" +kind = "partition" +name = "NeXT disk label" +we_read = true +we_write = true +builder = "new hd next" +notes = """Big-endian on m68k and Intel alike, four checksummed copies at +512-byte blocks 0/15/30/45 sharing one checksum. Partition offsets count the +label's own d_secsize (1024) from the end of a front porch. Rhapsody nests one +of these inside an Apple_Rhapsody_UFS APM slice.""" + +[[format]] +id = "part.solaris-x86" +kind = "partition" +name = "Solaris x86 VTOC" +we_read = true +we_write = true +builder = "new hd solaris-x86" +notes = "A 16-slice VTOC in sector 1 of an MBR partition, not a replacement for the MBR." + [[format]] id = "part.none" kind = "partition" diff --git a/src/backup/mod.rs b/src/backup/mod.rs index 339fb385..1140d37b 100644 --- a/src/backup/mod.rs +++ b/src/backup/mod.rs @@ -49,6 +49,13 @@ use metadata::{ SizePolicy, }; +/// Disk-label schemes back up only through the single-file-CHD layout, which +/// copies the head region verbatim. See `docs/backup_partition_schemes.md`. +const LABEL_BACKUP_NEEDS_CHD: &str = + "disk-label sources (Sun / NeXT / SGI / Amiga RDB / Atari AHDI) back up as a \ + single-file CHD only: re-run with CHD output. The per-partition layout \ + would have to rewrite the table on restore, which is not implemented."; + /// Compression type for backup output. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub enum CompressionType { @@ -985,6 +992,60 @@ fn run_backup_inner( format!("Source size: {} bytes", source_size), ); + // Some tables cannot be split into per-partition bodies at all. Image the + // drive as one body instead; the table rides in its JSON sidecar and, more + // to the point, inside the body itself. + if let Some(why) = partition::whole_disk_body_reason(&table, &partitions) { + log( + &progress, + LogLevel::Info, + format!( + "{} cannot be split per-partition ({why}); imaging the drive as one body", + table.type_name(), + ), + ); + partitions = vec![partition::whole_disk_partition(&table, source_size)]; + } + + // Two DFS sides track-interleaved in one file: no restore has ever put + // them back, and copying the .dsd is its backup. + if matches!(table, PartitionTable::Dsd { .. }) { + bail!( + "a double-sided Acorn DFS image (.dsd) cannot be backed up: its two sides \ + are track-interleaved and a restore cannot put them back. Copy the .dsd \ + file itself; ls, get, put and convert still work on it." + ); + } + // The single-file layout places bodies on 512-byte LBAs; a SASI disk with + // 256-byte sectors can start a partition between two of them. + if matches!( + config.compression, + CompressionType::Chd | CompressionType::Dvd + ) && matches!(table, PartitionTable::X68k { .. }) + && partitions.iter().any(|p| p.byte_offset() % 512 != 0) + { + bail!( + "{} output needs every X68k partition on a 512-byte boundary and this SASI \ + disk has one that is not; use --format zstd", + config.compression.as_str(), + ); + } + // A CHD is a whole disk, so a table the single-file layout cannot assemble + // is refused rather than written as per-partition CHDs (CLAUDE.md). + if matches!( + config.compression, + CompressionType::Chd | CompressionType::Dvd + ) && !single_file_chd::is_supported(&table) + { + bail!( + "{} output is a whole-disk image and the single-file layout cannot assemble \ + {} disks yet; use --format zstd, which backs them up per-partition and still \ + resizes on restore", + config.compression.as_str(), + table.type_name(), + ); + } + // Step 2: Create backup folder set_operation(&progress, "Creating backup folder..."); let backup_folder = format::create_backup_folder(&config.destination_dir, &config.backup_name)?; @@ -1007,8 +1068,7 @@ fn run_backup_inner( let single_file_chd_planned = matches!( config.compression, CompressionType::Chd | CompressionType::Dvd - ) && !is_superfloppy - && config.split_size_mib.is_none() + ) && config.split_size_mib.is_none() && single_file_chd::is_supported(&table); // Sector 0 alone loses GRUB's core.img, a DDO or Boot Manager living @@ -1118,23 +1178,16 @@ fn run_backup_inner( } } PartitionTable::Rdb(rdb) => { - // Emit a JSON sidecar of the RDB layout so inspect tools and - // future round-trip restores can read it. Per-partition data - // backup follows the standard layout-preserving path once the - // AFFS/PFS/SFS readers land. let json = serde_json::to_string_pretty(rdb).context("failed to serialize RDB to JSON")?; std::fs::write(backup_folder.join("rdb.json"), json) .context("failed to write rdb.json")?; log(&progress, LogLevel::Info, "Exported RDB (rdb.json)"); + if !single_file_chd_planned { + bail!("{}", LABEL_BACKUP_NEEDS_CHD); + } } PartitionTable::Sgi(vh) => { - // Step 2 surfaces SGI partitions in the inspect tab; backup of - // SGI disks is a separate workflow (deferred). Emit a JSON - // sidecar so the partition layout is recorded if a future - // session does want to round-trip it, but bail before any - // per-partition data write — the data path needs SGI-aware - // sizing and the EFS/XFS readers to land first. let json = serde_json::to_string_pretty(vh) .context("failed to serialize SGI volume header to JSON")?; std::fs::write(backup_folder.join("sgi.json"), json) @@ -1142,15 +1195,13 @@ fn run_backup_inner( log( &progress, LogLevel::Info, - "Exported SGI volume header (sgi.json) — partition data backup not yet supported", + "Exported SGI volume header (sgi.json)", ); - bail!("backing up SGI disks is not yet supported (browse only)"); + if !single_file_chd_planned { + bail!("{}", LABEL_BACKUP_NEEDS_CHD); + } } PartitionTable::Sun(label) => { - // Mirror the SGI sidecar: record the slice layout in sun.json, but - // defer the per-slice data backup (the data path needs Sun-label - // sizing). Browse / inspect / extract already work via the slice - // list + the existing UFS reader. let json = serde_json::to_string_pretty(label) .context("failed to serialize Sun disk label to JSON")?; std::fs::write(backup_folder.join("sun.json"), json) @@ -1158,14 +1209,13 @@ fn run_backup_inner( log( &progress, LogLevel::Info, - "Exported Sun disk label (sun.json) — partition data backup not yet supported", + "Exported Sun disk label (sun.json)", ); - bail!("backing up Sun-labeled disks is not yet supported (browse only)"); + if !single_file_chd_planned { + bail!("{}", LABEL_BACKUP_NEEDS_CHD); + } } PartitionTable::Next(label) => { - // Same sidecar shape as the Sun label: record the partition layout - // in next.json and defer the per-partition data backup. Browse / - // inspect / extract already work through the big-endian UFS reader. let json = serde_json::to_string_pretty(label) .context("failed to serialize NeXT disk label to JSON")?; std::fs::write(backup_folder.join("next.json"), json) @@ -1173,28 +1223,19 @@ fn run_backup_inner( log( &progress, LogLevel::Info, - "Exported NeXT disk label (next.json) — partition data backup not yet supported", + "Exported NeXT disk label (next.json)", ); - bail!("backing up NeXT-labeled disks is not yet supported (browse only)"); + if !single_file_chd_planned { + bail!("{}", LABEL_BACKUP_NEEDS_CHD); + } } - PartitionTable::SolarisX86 { label, .. } => { - // Same sidecar shape as the Sun label: record the slice layout in - // solaris_x86.json and defer the per-slice data backup. - let json = serde_json::to_string_pretty(label) - .context("failed to serialize Solaris x86 VTOC to JSON")?; - std::fs::write(backup_folder.join("solaris_x86.json"), json) - .context("failed to write solaris_x86.json")?; - log( - &progress, - LogLevel::Info, - "Exported Solaris x86 VTOC (solaris_x86.json) — partition data backup not yet supported", - ); - bail!("backing up Solaris x86 disks is not yet supported (browse only)"); + PartitionTable::SolarisX86 { .. } => { + // Unreachable: the table was rewritten to its host MBR far above, + // with the VTOC already written to solaris_x86.json. Kept so the + // match stays exhaustive, and honest about why it cannot fire. + bail!("internal: a Solaris x86 table reached the sidecar match unrewritten"); } PartitionTable::SgiDkLabel(label) => { - // Same sidecar shape as the Sun label: record the slot layout in - // sgi_dklabel.json and defer the per-slot data backup. Browse / - // inspect / extract already work through the EFS v1 reader. let json = serde_json::to_string_pretty(label) .context("failed to serialize SGI disk label to JSON")?; std::fs::write(backup_folder.join("sgi_dklabel.json"), json) @@ -1202,21 +1243,21 @@ fn run_backup_inner( log( &progress, LogLevel::Info, - "Exported SGI disk label (sgi_dklabel.json) — partition data backup not yet supported", + "Exported SGI disk label (sgi_dklabel.json)", ); - bail!("backing up SGI-disk-label disks is not yet supported (browse only)"); + if !single_file_chd_planned { + bail!("{}", LABEL_BACKUP_NEEDS_CHD); + } } PartitionTable::Ahdi(table) => { - // Mirror the RDB / SGI sidecar shape: emit ahdi.json so a future - // restore knows the AHDI primary slots, XGM chain, and disk-size - // / bad-sector fields. Per-partition FAT data backup rides the - // standard layout-preserving path through the existing FAT - // pipeline. let json = serde_json::to_string_pretty(table) .context("failed to serialize AHDI table to JSON")?; std::fs::write(backup_folder.join("ahdi.json"), json) .context("failed to write ahdi.json")?; log(&progress, LogLevel::Info, "Exported AHDI (ahdi.json)"); + if !single_file_chd_planned { + bail!("{}", LABEL_BACKUP_NEEDS_CHD); + } } PartitionTable::X68k { table, .. } => { // Mirror the AHDI sidecar shape: emit x68k.json so restore @@ -1238,11 +1279,8 @@ fn run_backup_inner( ); } PartitionTable::Dsd { .. } => { - log( - &progress, - LogLevel::Info, - "Double-sided Acorn DFS (.dsd): two DFS partitions, no partition-table sidecar", - ); + // Unreachable: a .dsd is refused before the folder is created. + bail!("internal: a DSD table reached the sidecar match"); } } @@ -1481,12 +1519,6 @@ fn run_backup_inner( single-file CHD backups are unavailable" ); } - // CHD/DVD selected on a source single_file_chd can't handle (only - // superfloppies fit this today — every other shape is_supported). - // Superfloppies route through the per-partition loop with - // `effective_compression` forced to `None` (raw .img), so the user - // ends up with a `partition-0.img` rather than a CHD. We don't emit - // per-partition CHDs anywhere; CHD output is single-file or nothing. if matches!( config.compression, CompressionType::Chd | CompressionType::Dvd @@ -2554,9 +2586,8 @@ fn run_single_file_chd_path( type_name: part.type_name.clone(), partition_type_byte: part.partition_type_byte, start_lba: new_start_lba, - // Single-file CHD relocates partitions and is not used for - // X68000 sources (the only non-512-aligned scheme), so the - // floored 512-LBA offset is authoritative here. + // Every body sits on a 512-byte LBA here (an unaligned SASI + // disk is refused above), so the floored offset is exact. start_byte: None, original_size_bytes: part.size_bytes, imaged_size_bytes: range.length, diff --git a/src/backup/single_file_chd.rs b/src/backup/single_file_chd.rs index 550e28de..23e7e9fe 100644 --- a/src/backup/single_file_chd.rs +++ b/src/backup/single_file_chd.rs @@ -6,12 +6,17 @@ //! //! Scope of the initial implementation (intentionally bounded): //! -//! - **MBR / GPT / APM sources supported.** GPT rebuilds the primary header -//! at LBA 1 and the backup header at the last 33 LBAs from the parsed -//! `Gpt` so any tweaks the parser normalises (e.g. CRCs) are reflected -//! in the synthesised image. APM reads the disk's head region (DDR + -//! partition map + drivers) verbatim from the source up to the first -//! real partition. +//! - **MBR / GPT / APM / Sun / NeXT / SGI sources supported.** GPT rebuilds the +//! primary header at LBA 1 and the backup header at the last 33 LBAs from +//! the parsed `Gpt` so any tweaks the parser normalises (e.g. CRCs) are +//! reflected in the synthesised image. APM reads the disk's head region +//! (DDR, partition map, drivers) verbatim from the source up to the first +//! real partition. The disk-label schemes — Sun, NeXT, both SGI ones — have +//! no writer that could patch them for a resize, so their head region goes +//! out byte for byte and a resize is refused; a SunOS root slice starts at +//! cylinder 0 and carries the label in its own first sector, so there the +//! head is empty already. An Amiga RDB rides the same path — its RDSK, PART, +//! FSHD and LSEG blocks are exactly what a re-serialized table would lose. //! - **Backup-time resize supported.** When the caller passes //! `resize_targets`, partitions get their new sizes via the same //! `PartitionResizePlan` + `PartitionSizeOverride` machinery the restore @@ -200,17 +205,76 @@ pub fn estimate_export_disk_usage( } } -/// True if `inputs` describes a source layout we can currently handle in -/// single-file CHD mode. MBR / GPT / APM are supported; superfloppy is -/// rejected at the GUI layer (CHD output requires a partition table to -/// embed at sector 0). +/// True if `inputs` describes a source layout the single-file CHD pipeline +/// can assemble. A partitionless volume is one body from byte 0 with no head. pub fn is_supported(inputs_table: &PartitionTable) -> bool { matches!( inputs_table, - PartitionTable::Mbr(_) | PartitionTable::Gpt { .. } | PartitionTable::Apm(_) + PartitionTable::Mbr(_) + | PartitionTable::Gpt { .. } + | PartitionTable::Apm(_) + | PartitionTable::Sun(_) + | PartitionTable::Next(_) + | PartitionTable::Sgi(_) + | PartitionTable::SgiDkLabel(_) + | PartitionTable::Rdb(_) + | PartitionTable::Ahdi(_) + | PartitionTable::X68k { .. } + | PartitionTable::None { .. } ) } +/// Schemes whose whole head region (label, boot blocks, front porch, SGI +/// volume header, Amiga RDSK/PART/FSHD chain, X68k IPL) is copied through +/// verbatim; a resize is applied on restore by `partition::restore_patch`. +fn is_verbatim_head_scheme(table: &PartitionTable) -> bool { + matches!( + table, + PartitionTable::Sun(_) + | PartitionTable::Next(_) + | PartitionTable::Sgi(_) + | PartitionTable::SgiDkLabel(_) + | PartitionTable::Rdb(_) + | PartitionTable::Ahdi(_) + | PartitionTable::X68k { .. } + ) +} + +/// An RDB's RDSK / PART / FSHD / LSEG chain has to sit inside the head region: +/// a compacted partition body zero-fills free blocks, so anything past it goes. +fn verify_head_covers_label(table: &PartitionTable, head_len: u64) -> Result<()> { + if let PartitionTable::Rdb(rdb) = table { + let need = (rdb.header.rdb_blk_hi as u64 + 1) * 512; + if need > head_len { + anyhow::bail!( + "the RDB reserves blocks through {} but the first partition starts at \ + byte {head_len}; backing this disk up would drop part of the \ + RDSK/PART/FSHD chain", + rdb.header.rdb_blk_hi, + ); + } + } + if let PartitionTable::X68k { sector_size, .. } = table { + use crate::partition::x68k::{ + X68K_ENTRY_SIZE, X68K_MAX_PARTITIONS, X68K_TABLE_HEADER_SIZE, X68K_TABLE_OFFSET, + X68K_TABLE_OFFSET_SASI, + }; + let table_off = if *sector_size == 256 { + X68K_TABLE_OFFSET_SASI + } else { + X68K_TABLE_OFFSET + }; + let need = + table_off + (X68K_TABLE_HEADER_SIZE + X68K_MAX_PARTITIONS * X68K_ENTRY_SIZE) as u64; + if need > head_len { + anyhow::bail!( + "the X68k table ends at byte {need} but the first partition starts at byte {head_len}" + ); + } + } + Ok(()) +} + /// Inputs for re-exporting an existing disk image (raw or `.chd`) to a /// `.chd` via the single-file pipeline. Differs from `SingleFileChdInputs` /// in two ways: @@ -519,6 +583,32 @@ fn plans_to_overrides(plans: &[PartitionResizePlan]) -> Vec Vec { + plans + .iter() + .map(|p| { + let Some(o) = overrides.iter().find(|o| o.index == p.index) else { + return p.clone(); + }; + let new_start_lba = o.effective_start_lba(); + PartitionResizePlan { + index: p.index, + old_start_lba: p.old_start_lba, + old_size_bytes: p.old_size_bytes, + new_start_lba, + new_size_bytes: o.export_size, + needs_data_move: new_start_lba != p.old_start_lba, + move_delta_bytes: new_start_lba as i64 * 512 - p.old_start_lba as i64 * 512, + } + }) + .collect() +} + /// Compute the shrunken disk envelope for a resize-active backup. The /// envelope is the byte just past the last partition's new extent, rounded /// up to the disk's alignment sector (and with a GPT trailer reservation @@ -542,7 +632,8 @@ pub fn compute_resized_envelope( let any_resize = plans .iter() .any(|p| p.new_size_bytes != p.old_size_bytes || p.new_start_lba != p.old_start_lba); - if !any_resize { + // A disk label describes the whole drive, so its image keeps the drive's size. + if !any_resize || is_verbatim_head_scheme(partition_table) { return source_size; } let alignment_bytes = (alignment_sectors.max(1) * 512).max(512); @@ -916,6 +1007,8 @@ pub struct AssembleFromStagingInputs<'a> { /// the gap after sector 0 (`backup::mbr_gap`), GPT an empty slice; both /// rebuild the table from `source_partition_table_bytes` + `partition_table`. pub source_head_region: &'a [u8], + /// The head already describes `plans` (patched before staging); skip the resize check. + pub head_is_patched: bool, } /// Assemble a single-file CHD by streaming per-partition zstd-compressed @@ -950,6 +1043,18 @@ pub fn assemble_from_staging( caller did not populate it" ); } + // A disk label that stands ahead of the first partition IS the head region; + // assembling without it would drop it and still produce a plausible CHD. + if is_verbatim_head_scheme(inputs.partition_table) + && inputs.source_head_region.is_empty() + && inputs.partitions.iter().all(|p| p.byte_offset() > 0) + { + anyhow::bail!( + "assemble_from_staging: {} source requires source_head_region — \ + caller did not populate it", + inputs.partition_table.type_name(), + ); + } if inputs.plans.is_empty() { anyhow::bail!("assemble_from_staging: empty resize plan"); } @@ -975,6 +1080,7 @@ pub fn assemble_from_staging( inputs.partitions, &overrides, target_size, + inputs.head_is_patched, log_cb, )?; @@ -1181,6 +1287,36 @@ fn read_apm_head_region( Ok(buf) } +/// Sun / NeXT: every byte before the first slice, verbatim. That is the disk +/// label plus whatever boot blocks and front porch sit with it. Empty when a +/// slice starts at LBA 0 — a SunOS root slice contains the label itself. +fn read_label_head_region( + source_file: &File, + partitions: &[PartitionInfo], + source_size: u64, +) -> Result> { + let head_end = partitions + .iter() + .map(|p| p.byte_offset()) + .min() + .unwrap_or(0) + .min(source_size); + if head_end == 0 { + return Ok(Vec::new()); + } + let mut clone = source_file + .try_clone() + .context("clone source for disk-label head region")?; + clone + .seek(SeekFrom::Start(0)) + .context("seek to disk-label head region")?; + let mut buf = vec![0u8; head_end as usize]; + clone + .read_exact(&mut buf) + .context("read disk-label head region")?; + Ok(buf) +} + /// MBR: the sectors after the MBR up to the first partition, so GRUB's /// core.img or a DDO reach the CHD instead of being zero-filled. fn read_mbr_gap_region( @@ -1301,7 +1437,7 @@ pub fn run_via_staging( inputs.partition_table.type_name(), ); } - let plans = match build_resize_plans(&inputs)? { + let mut plans = match build_resize_plans(&inputs)? { Some(p) => p, None => synthesize_noop_plans(inputs.partitions), }; @@ -1332,7 +1468,7 @@ pub fn run_via_staging( // DDR + partition map + any Apple_Driver* partition bodies and // must reach the assembler intact so patched DDR + entries can be // overlaid on top of it. - let source_head_region = if matches!(inputs.partition_table, PartitionTable::Apm(_)) { + let mut source_head_region = if matches!(inputs.partition_table, PartitionTable::Apm(_)) { Some(read_apm_head_region( inputs.source_file, inputs.partitions, @@ -1340,10 +1476,58 @@ pub fn run_via_staging( )?) } else if matches!(inputs.partition_table, PartitionTable::Mbr(_)) { read_mbr_gap_region(inputs.source_file, inputs.partitions)? + } else if is_verbatim_head_scheme(inputs.partition_table) { + let head = + read_label_head_region(inputs.source_file, inputs.partitions, inputs.source_size)?; + verify_head_covers_label(inputs.partition_table, head.len() as u64)?; + Some(head) } else { None }; + // A verbatim head that has to describe a resize is rewritten now, before + // staging, so the bodies are staged to the layout the label will carry. + let mut head_is_patched = false; + let any_resize = plans + .iter() + .any(|p| p.needs_data_move || p.new_size_bytes != p.old_size_bytes); + if any_resize && is_verbatim_head_scheme(inputs.partition_table) { + for plan in &plans { + if plan.new_size_bytes == plan.old_size_bytes { + continue; + } + let Some(part) = inputs.partitions.iter().find(|p| p.index == plan.index) else { + continue; + }; + let mut probe = inputs + .source_file + .try_clone() + .context("clone source for the resize probe")?; + if let crate::fs::InPlaceResize::Unsupported(name) = crate::fs::in_place_resize_support( + &mut probe, + part.byte_offset(), + part.partition_type_string.as_deref(), + ) { + anyhow::bail!( + "partition-{} holds {name}, which cannot be resized in place", + part.index + ); + } + } + let head = source_head_region.clone().unwrap_or_default(); + let overrides = plans_to_overrides(&plans); + let patched = crate::partition::restore_patch::patch_head_for_restore( + inputs.partition_table.type_name(), + &head, + &overrides, + inputs.source_size, + log_cb, + )?; + plans = plans_from_overrides(&plans, &patched.overrides); + source_head_region = Some(patched.head); + head_is_patched = true; + } + // Phase 1: stage each partition's body as zstd. stage_partitions_to_zst( &inputs, @@ -1372,6 +1556,7 @@ pub fn run_via_staging( alignment_sectors: inputs.alignment_sectors, checksum_type: inputs.checksum_type, source_head_region: head_region_slice, + head_is_patched, }; phase_cb("Assembling CHD container from staged partitions"); let result = assemble_from_staging( @@ -1910,6 +2095,7 @@ fn stage_partitions_to_zst( /// tail) as `DiskImageStreamBuilder` segments. Used by /// [`assemble_from_staging`] /// to avoid materialising a scratch disk image just to host the table. +#[allow(clippy::too_many_arguments)] // the head, the table, the plan and the flag are all needed fn build_patched_head_segments( source_partition_table_bytes: &[u8], source_head_region: &[u8], @@ -1917,6 +2103,7 @@ fn build_patched_head_segments( partitions: &[PartitionInfo], overrides: &[PartitionSizeOverride], target_size: u64, + head_is_patched: bool, log_cb: &mut dyn FnMut(&str), ) -> Result<(Vec, Option)> { match table { @@ -2017,50 +2204,56 @@ fn build_patched_head_segments( let head: Segment = (0, head_end, Box::new(std::io::Cursor::new(head_buf))); Ok((vec![head], None)) } - PartitionTable::Rdb(_) => { - anyhow::bail!( - "assemble_from_staging: Amiga RDB sources are not yet supported by single-file CHD" - ); - } - PartitionTable::Sgi(_) => { - anyhow::bail!( - "assemble_from_staging: SGI Volume Header sources are not supported (browse only)" - ); - } - PartitionTable::Sun(_) => { - anyhow::bail!( - "assemble_from_staging: Sun disk-label sources are not supported (browse only)" - ); - } - PartitionTable::Next(_) => { - anyhow::bail!( - "assemble_from_staging: NeXT disk-label sources are not supported (browse only)" + PartitionTable::Sun(_) + | PartitionTable::Next(_) + | PartitionTable::Sgi(_) + | PartitionTable::SgiDkLabel(_) + | PartitionTable::Rdb(_) + | PartitionTable::Ahdi(_) + | PartitionTable::X68k { .. } => { + // The head goes out byte for byte; `run_via_staging` has already + // rewritten it for a resize, or there was none to apply. + if !head_is_patched { + for o in overrides { + let Some(p) = partitions.iter().find(|p| p.index == o.index) else { + continue; + }; + if o.export_size != p.size_bytes || o.effective_start_lba() != p.start_lba { + anyhow::bail!( + "assemble_from_staging: a {} head must be patched before staging", + table.type_name(), + ); + } + } + } + if source_head_region.is_empty() { + log_cb(" table: disk label (inside the first slice, copied with its body)"); + return Ok((Vec::new(), None)); + } + log_cb(&format!( + " table: disk label ({} bytes of head region copied verbatim)", + source_head_region.len(), + )); + let head: Segment = ( + 0, + source_head_region.len() as u64, + Box::new(std::io::Cursor::new(source_head_region.to_vec())), ); + Ok((vec![head], None)) } PartitionTable::SolarisX86 { .. } => { anyhow::bail!( "assemble_from_staging: Solaris x86 VTOC sources are not supported (browse only)" ); } - PartitionTable::SgiDkLabel(_) => { - anyhow::bail!( - "assemble_from_staging: SGI disk-label sources are not supported (browse only)" - ); - } - PartitionTable::Ahdi(_) => { - anyhow::bail!( - "assemble_from_staging: AHDI sources are not yet supported by single-file CHD" - ); - } - PartitionTable::X68k { .. } => { - anyhow::bail!( - "assemble_from_staging: X68000 Human68k sources are not yet supported by single-file CHD" - ); + PartitionTable::None { .. } => { + // The volume is the disk: its body starts at byte 0, so there is + // no table to patch and no head region to carry. + log_cb(" table: none (partitionless volume, body from byte 0)"); + Ok((Vec::new(), None)) } - PartitionTable::None { .. } | PartitionTable::Dsd { .. } => { - anyhow::bail!( - "assemble_from_staging: superfloppy / double-sided-DFS sources are not supported" - ); + PartitionTable::Dsd { .. } => { + anyhow::bail!("assemble_from_staging: double-sided-DFS sources are not supported"); } } } @@ -2089,6 +2282,69 @@ mod tests { mbr } + /// Label schemes take the verbatim-head path, a partitionless volume has no + /// head, and a container the pipeline cannot assemble must stay refused. + #[test] + fn is_supported_covers_the_disk_label_schemes() { + for table in [ + PartitionTable::Sun(sun_label()), + PartitionTable::Next(next_label()), + ] { + assert!(is_supported(&table), "{}", table.type_name()); + assert!(is_verbatim_head_scheme(&table), "{}", table.type_name()); + } + let superfloppy = PartitionTable::None { + size_bytes: 4096, + fs_hint: "Unknown".into(), + }; + assert!(is_supported(&superfloppy)); + assert!(!is_verbatim_head_scheme(&superfloppy)); + let dsd = PartitionTable::Dsd { size_bytes: 409600 }; + assert!(!is_supported(&dsd)); + assert!(!is_verbatim_head_scheme(&dsd)); + } + + fn sun_label() -> crate::partition::sun::SunDiskLabel { + let mut buf = vec![0u8; 512]; + buf[436] = 0; // ntrks high + buf[437] = 2; // ntrks = 2 + buf[439] = 32; // nsect = 32 + buf[433] = 100; // ncyl = 100 + buf[444 + 4 + 3] = 64; // slice 0 num_sectors = 64 + buf[508] = 0xDA; + buf[509] = 0xBE; + let mut csum: u16 = 0; + for w in buf.chunks_exact(2) { + csum ^= u16::from_be_bytes([w[0], w[1]]); + } + buf[510] = (csum >> 8) as u8; + buf[511] = csum as u8; + crate::partition::sun::SunDiskLabel::parse(&buf).expect("synthetic Sun label") + } + + fn next_label() -> crate::partition::next::NextDiskLabel { + use crate::partition::next::{build_label, NextLabelSpec, NextPartitionSpec}; + let spec = NextLabelSpec { + partitions: vec![ + Some(NextPartitionSpec { + base: 0, + size: 64, + ..Default::default() + }), + None, + None, + None, + None, + None, + None, + None, + ], + ..Default::default() + }; + let buf = build_label(&spec); + crate::partition::next::NextDiskLabel::parse(&buf, 0).expect("synthetic NeXT label") + } + #[test] fn is_supported_accepts_mbr() { let mbr_bytes = build_test_mbr(64); @@ -2097,6 +2353,359 @@ mod tests { assert!(is_supported(&table)); } + /// A NeXT-labeled disk keeps its label, boot blocks and front porch in a + /// head region no writer can patch, so the single-file CHD copies those + /// bytes verbatim and the round-trip must reproduce them exactly. + #[test] + fn end_to_end_round_trip_next_label() { + use crate::partition::next::{build_label, NextLabelSpec, NextPartitionSpec}; + + const SECTOR: u64 = 1024; + const FRONT: u64 = 160; + const PART_SECTORS: u64 = 2048; + let total_bytes = (FRONT + PART_SECTORS + 64) * SECTOR; + + let mut spec = NextLabelSpec { + front_porch: FRONT as u16, + ..Default::default() + }; + spec.partitions = vec![ + Some(NextPartitionSpec { + base: 0, + size: PART_SECTORS as i32, + mount_point: "/".to_string(), + ..Default::default() + }), + None, + None, + None, + None, + None, + None, + None, + ]; + let label = build_label(&spec); + + let mut data = vec![0u8; total_bytes as usize]; + for &block in crate::partition::next::LABEL_BLOCKS.iter() { + let at = (block * 512) as usize; + data[at..at + label.len()].copy_from_slice(&label); + // Each copy stamps its own block number; the checksum ignores it. + data[at + 4..at + 8].copy_from_slice(&(block as u32).to_be_bytes()); + } + // Boot code in the porch, past the label copies, and a body pattern. + for i in 0..4096usize { + data[60 * 512 + i] = ((i % 253) as u8).wrapping_add(2); + } + let body = (FRONT * SECTOR) as usize; + for i in 0..(PART_SECTORS * SECTOR) as usize { + data[body + i] = ((i % 251) as u8).wrapping_add(1); + } + + let tmp = tempfile::tempdir().unwrap(); + let source_path = tmp.path().join("source.img"); + std::fs::write(&source_path, &data).unwrap(); + let source_file = File::open(&source_path).unwrap(); + + let mut br = BufReader::new(source_file.try_clone().unwrap()); + let table = PartitionTable::detect(&mut br).expect("detect NeXT label"); + assert!(matches!(table, PartitionTable::Next(_))); + assert!(is_supported(&table)); + let partitions = table.partitions(); + assert_eq!(partitions.len(), 1); + + let output_base = tmp.path().join("disk"); + let head_bytes: [u8; 512] = data[..512].try_into().unwrap(); + let mut log_buf: Vec = Vec::new(); + let result = run_via_staging( + SingleFileChdInputs { + keep_swap: true, + source_file: &source_file, + source_size: total_bytes, + source_partition_table_bytes: &head_bytes, + partition_table: &table, + partitions: &partitions, + partition_filter: None, + sector_by_sector: true, + chd_options: None, + is_dvd: false, + output_base: &output_base, + resize_targets: None, + hfsplus_clone_targets: None, + alignment_sectors: 0, + checksum_type: crate::backup::ChecksumType::Sha256, + }, + &mut |_: u64| {}, + &|| false, + &mut |s: &str| log_buf.push(s.to_string()), + &mut |_, _| {}, + &mut |_| {}, + None, + ) + .expect("NeXT single-file CHD backup"); + assert_eq!(result.container_logical_size, total_bytes); + + let chd_path = tmp.path().join("disk.chd"); + let mut reader = ChdReader::open(&chd_path).unwrap(); + let mut back = vec![0u8; total_bytes as usize]; + reader.seek(SeekFrom::Start(0)).unwrap(); + reader.read_exact(&mut back).unwrap(); + assert_eq!( + &back[..(FRONT * SECTOR) as usize], + &data[..(FRONT * SECTOR) as usize], + "the whole head region — label copies and boot code — is verbatim" + ); + assert_eq!(&back[body..], &data[body..], "partition body is verbatim"); + + let mut br = BufReader::new(ChdReader::open(&chd_path).unwrap()); + let detected = PartitionTable::detect(&mut br).expect("detect after round-trip"); + assert!( + matches!(detected, PartitionTable::Next(_)), + "round-tripped CHD should still parse as a NeXT label, got: {}", + detected.type_name(), + ); + } + + /// The RDSK block and its PART / FSHD / LSEG chain live in the reserved + /// area ahead of the first partition, which is exactly what a re-serialized + /// table would lose — so the head region has to arrive byte for byte. + #[test] + fn end_to_end_round_trip_amiga_rdb() { + use crate::partition::provision::{self, Geometry, PartSpec}; + use crate::partition::type_catalog::TableKind; + + const TOTAL: u64 = 16 * 1024 * 1024; + let geometry = Geometry::default(); + let specs = vec![ + PartSpec { + size: Some(4 * 1024 * 1024), + type_text: Some("DOS\\3".to_string()), + name: Some("DH0".to_string()), + }, + PartSpec { + size: Some(4 * 1024 * 1024), + type_text: Some("PFS\\3".to_string()), + name: Some("DH1".to_string()), + }, + ]; + let align = provision::default_align(TableKind::Rdb, geometry); + let placed = provision::place(&specs, TableKind::Rdb, TOTAL, align, geometry) + .expect("place two RDB partitions"); + + let tmp = tempfile::tempdir().unwrap(); + let source_path = tmp.path().join("source.img"); + { + let mut f = File::create(&source_path).unwrap(); + f.set_len(TOTAL).unwrap(); + provision::write_table(&mut f, TableKind::Rdb, &placed, TOTAL, geometry) + .expect("write the RDB"); + // Recognisable bytes in each body, so a shifted segment shows up. + for (i, p) in placed.iter().enumerate() { + f.seek(SeekFrom::Start(p.start_lba * 512)).unwrap(); + let body: Vec = (0..4096u32).map(|b| (b as u8) ^ (i as u8 + 1)).collect(); + f.write_all(&body).unwrap(); + } + f.flush().unwrap(); + } + let source_file = File::open(&source_path).unwrap(); + let data = std::fs::read(&source_path).unwrap(); + + let mut br = BufReader::new(source_file.try_clone().unwrap()); + let table = PartitionTable::detect(&mut br).expect("detect the RDB"); + assert!(matches!(table, PartitionTable::Rdb(_))); + assert!(is_supported(&table)); + assert!(is_verbatim_head_scheme(&table)); + let partitions = table.partitions(); + assert_eq!(partitions.len(), 2); + let head_len = partitions[0].byte_offset() as usize; + assert!(head_len > 0, "the RDB reserved area stands ahead of DH0"); + + let output_base = tmp.path().join("disk"); + let head_bytes: [u8; 512] = data[..512].try_into().unwrap(); + let result = run_via_staging( + SingleFileChdInputs { + keep_swap: true, + source_file: &source_file, + source_size: TOTAL, + source_partition_table_bytes: &head_bytes, + partition_table: &table, + partitions: &partitions, + partition_filter: None, + sector_by_sector: true, + chd_options: None, + is_dvd: false, + output_base: &output_base, + resize_targets: None, + hfsplus_clone_targets: None, + alignment_sectors: 0, + checksum_type: crate::backup::ChecksumType::Sha256, + }, + &mut |_: u64| {}, + &|| false, + &mut |_: &str| {}, + &mut |_, _| {}, + &mut |_| {}, + None, + ) + .expect("RDB single-file CHD backup"); + assert_eq!(result.container_logical_size, TOTAL); + + let chd_path = tmp.path().join("disk.chd"); + let mut reader = ChdReader::open(&chd_path).unwrap(); + let mut back = vec![0u8; TOTAL as usize]; + reader.seek(SeekFrom::Start(0)).unwrap(); + reader.read_exact(&mut back).unwrap(); + assert_eq!( + &back[..head_len], + &data[..head_len], + "RDSK, PART and FSHD blocks copied verbatim" + ); + for p in &partitions { + let (a, b) = (p.byte_offset() as usize, p.size_bytes as usize); + assert_eq!(&back[a..a + b], &data[a..a + b], "{} body", p.type_name); + } + + let mut br = BufReader::new(ChdReader::open(&chd_path).unwrap()); + let detected = PartitionTable::detect(&mut br).expect("detect after round-trip"); + assert!( + matches!(detected, PartitionTable::Rdb(_)), + "round-tripped CHD should still parse as an RDB, got: {}", + detected.type_name(), + ); + } + + /// The reserved area is where the driver chain lives, and a compacted body + /// zero-fills free blocks — so an RDB claiming blocks past the first + /// partition has to be refused, not quietly truncated. + #[test] + fn an_rdb_reaching_past_the_head_is_refused() { + use crate::partition::provision::{self, Geometry, PartSpec}; + use crate::partition::type_catalog::TableKind; + + const TOTAL: u64 = 8 * 1024 * 1024; + let geometry = Geometry::default(); + let specs = vec![PartSpec { + size: Some(2 * 1024 * 1024), + type_text: Some("DOS\\3".to_string()), + name: Some("DH0".to_string()), + }]; + let align = provision::default_align(TableKind::Rdb, geometry); + let placed = + provision::place(&specs, TableKind::Rdb, TOTAL, align, geometry).expect("place"); + let mut img = std::io::Cursor::new(vec![0u8; TOTAL as usize]); + provision::write_table(&mut img, TableKind::Rdb, &placed, TOTAL, geometry).expect("write"); + img.seek(SeekFrom::Start(0)).unwrap(); + let table = PartitionTable::detect(&mut img).expect("detect"); + let PartitionTable::Rdb(rdb) = table else { + panic!("expected an RDB"); + }; + + let head = placed[0].start_lba * 512; + assert!( + verify_head_covers_label(&PartitionTable::Rdb(rdb.clone()), head).is_ok(), + "the reserved area ends where the first partition starts" + ); + + let mut greedy = rdb; + greedy.header.rdb_blk_hi = (head / 512) as u32 + 8; + let err = verify_head_covers_label(&PartitionTable::Rdb(greedy), head).unwrap_err(); + assert!( + err.to_string().contains("RDSK/PART/FSHD"), + "the error has to name what would be lost: {err}" + ); + } + + /// An SGI volume header stands in front of slot 0 and holds the PROM's + /// standalone binaries, so the head region has to reach the CHD verbatim — + /// there is no writer that could rebuild it. + #[test] + fn end_to_end_round_trip_sgi_volume_header() { + use crate::partition::sgi_hdd_builder::{build_sgi_efs_hdd, SgiHddOptions}; + + let (data, _layout) = build_sgi_efs_hdd(&SgiHddOptions::new(8 * 1024 * 1024, "rbtest")) + .expect("build an SGI EFS hard disk"); + let total_bytes = data.len() as u64; + + let tmp = tempfile::tempdir().unwrap(); + let source_path = tmp.path().join("source.img"); + std::fs::write(&source_path, &data).unwrap(); + let source_file = File::open(&source_path).unwrap(); + + let mut br = BufReader::new(source_file.try_clone().unwrap()); + let table = PartitionTable::detect(&mut br).expect("detect the volume header"); + assert!(matches!(table, PartitionTable::Sgi(_))); + assert!(is_supported(&table)); + assert!(is_verbatim_head_scheme(&table)); + let partitions = table.partitions(); + assert_eq!( + partitions.len(), + 1, + "an fx data disk has one browsable slot" + ); + assert!( + !crate::partition::partitions_overlap(&partitions), + "one slot cannot overlap itself, so this takes the per-slot path" + ); + let head_len = partitions[0].byte_offset() as usize; + assert!(head_len > 0, "the volume header stands ahead of slot 0"); + + let output_base = tmp.path().join("disk"); + let head_bytes: [u8; 512] = data[..512].try_into().unwrap(); + let result = run_via_staging( + SingleFileChdInputs { + keep_swap: true, + source_file: &source_file, + source_size: total_bytes, + source_partition_table_bytes: &head_bytes, + partition_table: &table, + partitions: &partitions, + partition_filter: None, + sector_by_sector: true, + chd_options: None, + is_dvd: false, + output_base: &output_base, + resize_targets: None, + hfsplus_clone_targets: None, + alignment_sectors: 0, + checksum_type: crate::backup::ChecksumType::Sha256, + }, + &mut |_: u64| {}, + &|| false, + &mut |_: &str| {}, + &mut |_, _| {}, + &mut |_| {}, + None, + ) + .expect("SGI single-file CHD backup"); + assert_eq!(result.container_logical_size, total_bytes); + + let chd_path = tmp.path().join("disk.chd"); + let mut reader = ChdReader::open(&chd_path).unwrap(); + let mut back = vec![0u8; total_bytes as usize]; + reader.seek(SeekFrom::Start(0)).unwrap(); + reader.read_exact(&mut back).unwrap(); + assert_eq!( + &back[..head_len], + &data[..head_len], + "volume header and volume directory copied verbatim" + ); + let body = partitions[0].byte_offset() as usize; + let end = body + partitions[0].size_bytes as usize; + assert_eq!( + &back[body..end], + &data[body..end], + "slot 0 body is verbatim" + ); + + let mut br = BufReader::new(ChdReader::open(&chd_path).unwrap()); + let detected = PartitionTable::detect(&mut br).expect("detect after round-trip"); + assert!( + matches!(detected, PartitionTable::Sgi(_)), + "round-tripped CHD should still parse as an SGI volume header, got: {}", + detected.type_name(), + ); + } + /// Build a tiny GPT-formatted disk and round-trip it through /// `single_file_chd::run`, then assert that the resulting CHD has both /// the protective MBR + primary GPT at the start and a backup GPT at @@ -2899,6 +3508,7 @@ mod tests { is_dvd: false, alignment_sectors: 0, source_head_region: &[], + head_is_patched: false, checksum_type: crate::backup::ChecksumType::Sha256, }; @@ -2936,6 +3546,184 @@ mod tests { ); } + /// A partitionless volume is the disk: logical size equals the source size, + /// the one body sits at byte 0, and nothing is written ahead of it. + #[test] + fn run_via_staging_round_trip_superfloppy_no_resize() { + const TOTAL_SECTORS: u32 = 2880; + const SECTOR_SIZE: u64 = 512; + let total_bytes = (TOTAL_SECTORS as u64) * SECTOR_SIZE; + + let tmp = tempfile::tempdir().expect("tempdir"); + let source_path = tmp.path().join("floppy.img"); + + // Not a filesystem the compact readers know, so the body goes through + // raw passthrough and the readback has to match every byte. + let data: Vec = (0..total_bytes as usize) + .map(|i| ((i * 7) % 251) as u8) + .collect(); + std::fs::write(&source_path, &data).unwrap(); + + let source_file = File::open(&source_path).unwrap(); + let table = PartitionTable::None { + size_bytes: total_bytes, + fs_hint: "Unknown".into(), + }; + let partitions = table.partitions(); + assert_eq!(partitions.len(), 1); + assert_eq!(partitions[0].start_lba, 0); + + let mut sector0 = [0u8; 512]; + sector0.copy_from_slice(&data[..512]); + + let output_base = tmp.path().join("disk"); + let inputs = SingleFileChdInputs { + keep_swap: true, + source_file: &source_file, + source_size: total_bytes, + source_partition_table_bytes: §or0, + partition_table: &table, + partitions: &partitions, + partition_filter: None, + sector_by_sector: false, + chd_options: None, + is_dvd: false, + output_base: &output_base, + resize_targets: None, + hfsplus_clone_targets: None, + alignment_sectors: 0, + checksum_type: crate::backup::ChecksumType::Sha256, + }; + + let mut log_buf: Vec = Vec::new(); + let mut log_cb = |s: &str| log_buf.push(s.to_string()); + let mut progress_cb = |_: u64| {}; + let cancel_check = || false; + + let result = run_via_staging( + inputs, + &mut progress_cb, + &cancel_check, + &mut log_cb, + &mut |_, _| {}, + &mut |_| {}, + None, + ) + .expect("run_via_staging on a partitionless volume"); + + assert_eq!(result.container_filename, "disk.chd"); + assert_eq!(result.container_logical_size, total_bytes); + assert_eq!(result.partition_ranges.len(), 1); + assert_eq!(result.partition_ranges[0].offset_in_disk, 0); + assert_eq!(result.partition_ranges[0].length, total_bytes); + + let chd_path = tmp.path().join("disk.chd"); + let mut reader = ChdReader::open(&chd_path).expect("reopen CHD"); + let mut readback = vec![0u8; total_bytes as usize]; + reader.read_exact(&mut readback).expect("read full CHD"); + assert_eq!( + readback, data, + "a partitionless volume must round-trip the whole image byte-for-byte", + ); + let joined = log_buf.join("\n"); + assert!( + joined.contains("partitionless volume"), + "log must say no table was written; got: {joined}", + ); + } + + /// A resize on a verbatim-head scheme is applied before staging: the CHD + /// carries the rewritten label, the bodies where it says, and the drive's size. + #[test] + fn run_export_with_resize_rewrites_a_verbatim_label() { + use crate::partition::provision::{self, Geometry, PartSpec}; + use crate::partition::type_catalog::TableKind; + const DISK: u64 = 64 * 1024 * 1024; + for kind in [ + TableKind::Sun, + TableKind::Next, + TableKind::Sgi, + TableKind::SgiDkLabel, + TableKind::Atari, + TableKind::X68k, + ] { + let tmp = tempfile::tempdir().unwrap(); + let source_path = tmp.path().join("disk.img"); + let geometry = Geometry::default(); + let align = provision::default_align(kind, geometry); + let specs = vec![ + PartSpec { + size: Some(16 * 1024 * 1024), + ..Default::default() + }, + PartSpec { + size: None, + ..Default::default() + }, + ]; + let placed = provision::place(&specs, kind, DISK, align, geometry).unwrap(); + { + let mut file = File::create(&source_path).unwrap(); + file.set_len(DISK).unwrap(); + provision::write_table(&mut file, kind, &placed, DISK, geometry).unwrap(); + } + let mut br = BufReader::new(File::open(&source_path).unwrap()); + let table = PartitionTable::detect(&mut br).unwrap(); + let name = table.type_name(); + let partitions = table.partitions(); + assert_eq!(partitions.len(), 2, "{name}"); + let mut sector0 = [0u8; 512]; + br.seek(SeekFrom::Start(0)).unwrap(); + br.read_exact(&mut sector0).unwrap(); + + let dest_path = tmp.path().join("export.chd"); + let targets = vec![(partitions[0].index, 4 * 1024 * 1024u64)]; + let mut log_buf: Vec = Vec::new(); + let mut log_cb = |s: &str| log_buf.push(s.to_string()); + run_export( + SingleFileChdExportInputs { + source_path: &source_path, + partition_table: &table, + partitions: &partitions, + source_partition_table_bytes: §or0, + alignment_sectors: 0, + dest_path: &dest_path, + chd_options: None, + is_dvd: false, + resize_targets: Some(&targets), + }, + &mut |_| {}, + &|| false, + &mut log_cb, + ) + .unwrap_or_else(|e| panic!("{name}: {e:#}")); + + let mut reader = ChdReader::open(&dest_path).unwrap(); + assert_eq!( + reader.logical_size(), + DISK, + "{name}: the image keeps the drive's size" + ); + let mut image = vec![0u8; DISK as usize]; + reader.read_exact(&mut image).unwrap(); + let again = PartitionTable::detect(&mut std::io::Cursor::new(image)).unwrap(); + let after = again.partitions(); + assert_eq!(after.len(), 2, "{name}"); + assert!( + after[0].size_bytes < partitions[0].size_bytes, + "{name}: the first partition must have shrunk" + ); + assert!( + after[1].start_lba < partitions[1].start_lba, + "{name}: the second partition must have moved down" + ); + assert!( + log_buf.iter().any(|l| l.contains("rewritten")), + "{name}: the label rewrite must be logged: {log_buf:?}" + ); + } + } + /// Commit 1b: `run_via_staging` (no resize, MBR, raw passthrough) /// produces a CHD that round-trips the source disk. Exercises the /// full stage-then-assemble cycle end-to-end and verifies the diff --git a/src/fs/hfs.rs b/src/fs/hfs.rs index 1b1cf36c..19dd943a 100644 --- a/src/fs/hfs.rs +++ b/src/fs/hfs.rs @@ -2536,7 +2536,12 @@ impl HfsFilesystem { // (parent=1, drVN) and rejects volumes with an empty key). let vol_name = self.mdb.volume_name_raw.clone(); let node_size = pick_btree_node_size(catalog_size as u64) as usize; - let buf = build_empty_hfs_catalog_with_node_size(catalog_size, node_size, &vol_name)?; + let buf = build_empty_hfs_catalog_with_node_size( + catalog_size, + node_size, + &vol_name, + hfs_common::hfs_now(), + )?; self.catalog_data = buf; self.write_catalog()?; } @@ -2647,6 +2652,7 @@ fn build_empty_hfs_catalog_with_node_size( catalog_size: usize, node_size: usize, volume_name_raw: &[u8], + now: u32, ) -> Result, FilesystemError> { if volume_name_raw.len() > 27 { return Err(FilesystemError::InvalidData(format!( @@ -2744,7 +2750,6 @@ fn build_empty_hfs_catalog_with_node_size( let r0_data = r0_key + r0_key_total + r0_pad; buf[r0_data] = CATALOG_DIR as u8; BigEndian::write_u32(&mut buf[r0_data + 6..r0_data + 10], 2); // dirDirID = root CNID 2 - let now = hfs_common::hfs_now(); BigEndian::write_u32(&mut buf[r0_data + 10..r0_data + 14], now); // crDate BigEndian::write_u32(&mut buf[r0_data + 14..r0_data + 18], now); // mdDate // Remainder of the 70-byte CdrDirRec stays zero. @@ -2979,6 +2984,25 @@ pub fn create_blank_hfs_sized( volume_name: &str, min_extents_bytes: u32, min_catalog_bytes: u32, +) -> Result, FilesystemError> { + create_blank_hfs_sized_at( + target_size_bytes, + block_size, + volume_name, + min_extents_bytes, + min_catalog_bytes, + hfs_common::hfs_now(), + ) +} + +/// [`create_blank_hfs_sized`] with the volume dates fixed at `now`. +pub(crate) fn create_blank_hfs_sized_at( + target_size_bytes: u64, + block_size: u32, + volume_name: &str, + min_extents_bytes: u32, + min_catalog_bytes: u32, + now: u32, ) -> Result, FilesystemError> { let (front, mdb, image_size) = build_blank_hfs_front( target_size_bytes, @@ -2986,6 +3010,7 @@ pub fn create_blank_hfs_sized( volume_name, min_extents_bytes, min_catalog_bytes, + now, )?; let mut img = vec![0u8; image_size as usize]; img[..front.len()].copy_from_slice(&front); @@ -3008,9 +3033,28 @@ pub fn write_blank_hfs_into( target_size_bytes: u64, block_size: u32, volume_name: &str, +) -> Result { + write_blank_hfs_into_at( + target, + at_offset, + target_size_bytes, + block_size, + volume_name, + hfs_common::hfs_now(), + ) +} + +/// [`write_blank_hfs_into`] with the volume dates fixed at `now`. +pub(crate) fn write_blank_hfs_into_at( + target: &mut W, + at_offset: u64, + target_size_bytes: u64, + block_size: u32, + volume_name: &str, + now: u32, ) -> Result { let (front, mdb, image_size) = - build_blank_hfs_front(target_size_bytes, block_size, volume_name, 0, 0)?; + build_blank_hfs_front(target_size_bytes, block_size, volume_name, 0, 0, now)?; target.seek(SeekFrom::Start(at_offset))?; target.write_all(&front)?; target.seek(SeekFrom::Start(at_offset + image_size - 1024))?; @@ -3029,6 +3073,7 @@ fn build_blank_hfs_front( volume_name: &str, min_extents_bytes: u32, min_catalog_bytes: u32, + now: u32, ) -> Result<(Vec, [u8; 512], u64), FilesystemError> { if block_size == 0 || !block_size.is_multiple_of(512) { return Err(FilesystemError::InvalidData(format!( @@ -3142,6 +3187,7 @@ fn build_blank_hfs_front( catalog_size as usize, catalog_node_size, &name_raw, + now, )?; let catalog_off = first_alloc_block as u64 * 512 + catalog_start as u64 * block_size as u64; front[catalog_off as usize..catalog_off as usize + catalog_bytes.len()] @@ -3149,6 +3195,7 @@ fn build_blank_hfs_front( // Primary MDB at sector 2 let mdb = build_blank_mdb( + now, &name_raw, total_blocks as u16, block_size, @@ -3171,6 +3218,7 @@ fn build_blank_hfs_front( #[allow(clippy::too_many_arguments)] fn build_blank_mdb( + now: u32, name_raw: &[u8], total_blocks: u16, block_size: u32, @@ -3184,7 +3232,6 @@ fn build_blank_mdb( allocated_blocks: u16, ) -> [u8; 512] { let mut mdb = [0u8; 512]; - let now = hfs_common::hfs_now(); BigEndian::write_u16(&mut mdb[0..2], HFS_SIGNATURE); BigEndian::write_u32(&mut mdb[2..6], now); // drCrDate BigEndian::write_u32(&mut mdb[6..10], now); // drLsMod @@ -4791,10 +4838,11 @@ mod tests { #[test] fn write_blank_hfs_into_matches_create_blank_hfs() { let (size, bs) = (8 * 1024 * 1024u64, 4096u32); - let mem = create_blank_hfs(size, bs, "StreamEq").unwrap(); + let now = 0xB0A1_2345; + let mem = create_blank_hfs_sized_at(size, bs, "StreamEq", 0, 0, now).unwrap(); let mut sink = Cursor::new(vec![0u8; 0]); - let written = write_blank_hfs_into(&mut sink, 0, size, bs, "StreamEq").unwrap(); + let written = write_blank_hfs_into_at(&mut sink, 0, size, bs, "StreamEq", now).unwrap(); let mut streamed = sink.into_inner(); streamed.resize(written as usize, 0); @@ -4807,11 +4855,12 @@ mod tests { #[test] fn write_blank_hfs_into_honours_the_offset() { let (size, bs) = (4 * 1024 * 1024u64, 2048u32); - let mem = create_blank_hfs(size, bs, "Offset").unwrap(); + let now = 0xB0A1_2345; + let mem = create_blank_hfs_sized_at(size, bs, "Offset", 0, 0, now).unwrap(); let offset = 64 * 512u64; let mut sink = Cursor::new(vec![0u8; 0]); - let written = write_blank_hfs_into(&mut sink, offset, size, bs, "Offset").unwrap(); + let written = write_blank_hfs_into_at(&mut sink, offset, size, bs, "Offset", now).unwrap(); let mut streamed = sink.into_inner(); streamed.resize((offset + written) as usize, 0); diff --git a/src/fs/mod.rs b/src/fs/mod.rs index ca6cdc49..7bed910c 100644 --- a/src/fs/mod.rs +++ b/src/fs/mod.rs @@ -968,6 +968,38 @@ fn compact_reader_for_detected( } } +/// Longest NeXT front porch we will carry verbatim in a compact stream. Real +/// labels reserve 160-256 sectors; anything larger means the label is wrong. +const MAX_NEXT_HEAD_BYTES: u64 = 1 << 20; + +/// A Rhapsody slice compacts as the NeXT label's head region verbatim followed +/// by the layout-preserving UFS stream; every byte keeps its original offset. +fn next_label_compact_reader( + mut reader: R, + partition_offset: u64, +) -> Option<(Box, CompactResult)> { + let fs_offset = resolve_next_label(&mut reader, partition_offset); + let head_len = fs_offset.checked_sub(partition_offset)?; + if head_len > MAX_NEXT_HEAD_BYTES { + return None; + } + let mut head = vec![0u8; head_len as usize]; + if head_len > 0 { + reader.seek(SeekFrom::Start(partition_offset)).ok()?; + reader.read_exact(&mut head).ok()?; + } + let (compact, info) = CompactUfsReader::new(reader, fs_offset).ok()?; + Some(( + Box::new(std::io::Cursor::new(head).chain(compact)), + CompactResult { + original_size: info.original_size + head_len, + compacted_size: info.compacted_size + head_len, + data_size: info.data_size + head_len, + clusters_used: info.clusters_used, + }, + )) +} + /// HFS or HFS+ at the offset, as MBR type 0xAF and the Apple HFS GUID carry it. /// A wrapped HFS+ volume is left to the wrapper-aware clone path (`None`). fn apple_hfs_compact_reader( @@ -1025,23 +1057,8 @@ impl Read for ZeroPaddedReader { } } -/// Like `layout_preserving_partition_reader`, but for FAT/NTFS/exFAT it -/// returns the *packed* compact reader (allocated clusters at the start, -/// FS metadata shrunk to fit) padded with zeros up to `original_size`. -/// -/// The resulting stream still has length == `original_size`, so it slots -/// into the partition's extent inside a synthesised disk image without -/// changing the partition table. Inside that extent, the OS sees a smaller -/// FAT/NTFS/exFAT volume at offset 0 (BPB / boot sector reflects the -/// shrunken total_sectors) followed by a zero-filled tail. CHD compresses -/// the tail to nothing. -/// -/// HFS/HFS+/ext/btrfs/ProDOS keep their existing layout-preserving stream -/// (those readers are already byte-faithful at `original_size`). -/// -/// Used by single-file CHD backup when not in sector-by-sector mode: the -/// FAT-family partitions emerge defragmented in place, and the streaming -/// pattern is sequential rather than seek-heavy. +/// The partition's compact reader, zero-padded to `original_size` whenever it +/// packs (FAT/NTFS/exFAT/Human68k); layout-preserving readers pass through. pub fn packed_partition_reader_padded( mut reader: R, partition_offset: u64, @@ -1049,64 +1066,48 @@ pub fn packed_partition_reader_padded( partition_type_string: Option<&str>, keep_swap: bool, ) -> Option<(Box, CompactResult)> { - // APM and HFS/ext/btrfs/ProDOS go through the existing dispatcher — - // those readers are already layout-preserving (compacted_size == - // original_size), so no padding is needed. - if partition_type_string.is_some() { - return compact_partition_reader( - reader, - partition_offset, - partition_type, - partition_type_string, - keep_swap, - ); - } - match partition_type { - 0x83 | 0xAF | 0xA8 => { - return compact_partition_reader( + // Padding keys on what the reader reports, not on how it was dispatched: a + // FAT behind type 0x83 (MSX) or a "human68k" type string packs too. + let (compact_reader, info): (Box, CompactResult) = + match (partition_type_string, partition_type) { + (Some(_), _) | (None, 0x83) | (None, 0xAF) | (None, 0xA8) => compact_partition_reader( reader, partition_offset, partition_type, partition_type_string, keep_swap, - ); - } - _ => {} - } - - // For FAT/NTFS/exFAT: build the packed reader (compacted_size < - // original_size) and pad it with zeros to original_size. - let (compact_reader, info): (Box, CompactResult) = match partition_type { - 0x00 => { - let fs_type = detect_filesystem_type(&mut reader, partition_offset); - match fs_type { - "fat" => fat_compact_reader(reader, partition_offset, keep_swap)?, - "ntfs" => ntfs_compact_reader(reader, partition_offset)?, - "exfat" => exfat_compact_reader(reader, partition_offset)?, - _ => { - return compact_partition_reader( + )?, + (None, 0x00) => { + let fs_type = detect_filesystem_type(&mut reader, partition_offset); + match fs_type { + "fat" => fat_compact_reader(reader, partition_offset, keep_swap)?, + "ntfs" => ntfs_compact_reader(reader, partition_offset)?, + "exfat" => exfat_compact_reader(reader, partition_offset)?, + _ => compact_partition_reader( reader, partition_offset, partition_type, partition_type_string, keep_swap, - ); + )?, } } - } - 0x01 | 0x04 | 0x06 | 0x0E | 0x14 | 0x16 | 0x1E | 0x0B | 0x0C | 0x1B | 0x1C => { - fat_compact_reader(reader, partition_offset, keep_swap)? - } - 0x07 => { - let fs_type = detect_0x07_type(&mut reader, partition_offset); - match fs_type { - "ntfs" => ntfs_compact_reader(reader, partition_offset)?, - "exfat" => exfat_compact_reader(reader, partition_offset)?, - _ => return None, + (None, 0x01 | 0x04 | 0x06 | 0x0E | 0x14 | 0x16 | 0x1E | 0x0B | 0x0C | 0x1B | 0x1C) => { + fat_compact_reader(reader, partition_offset, keep_swap)? } - } - _ => return None, - }; + (None, 0x07) => { + let fs_type = detect_0x07_type(&mut reader, partition_offset); + match fs_type { + "ntfs" => ntfs_compact_reader(reader, partition_offset)?, + "exfat" => exfat_compact_reader(reader, partition_offset)?, + _ => return None, + } + } + _ => return None, + }; + if info.compacted_size >= info.original_size { + return Some((compact_reader, info)); + } let original_size = info.original_size; let compacted_size = info.compacted_size; @@ -1132,6 +1133,19 @@ pub fn packed_partition_reader_padded( /// /// Returns `None` if the filesystem type is unsupported or cannot be parsed, /// in which case the caller should fall back to the full partition size. +/// Bytes a partition-embedded disk label stands ahead of the filesystem. Every +/// partition-level size has to carry them; zero for every other partition type. +fn embedded_label_bytes( + reader: &mut R, + partition_offset: u64, + partition_type_string: Option<&str>, +) -> u64 { + if partition_type_string != Some("Apple_Rhapsody_UFS") { + return 0; + } + resolve_next_label(reader, partition_offset).saturating_sub(partition_offset) +} + pub fn effective_partition_size( reader: R, partition_offset: u64, @@ -1157,11 +1171,12 @@ pub fn effective_partition_size( /// missing from the metadata entirely, with nothing anywhere saying why. Give /// callers that can log a way to say what went wrong. pub fn effective_partition_size_reported( - reader: R, + mut reader: R, partition_offset: u64, partition_type: u8, partition_type_string: Option<&str>, ) -> Result { + let head = embedded_label_bytes(&mut reader, partition_offset, partition_type_string); let mut fs = open_filesystem( reader, partition_offset, @@ -1170,6 +1185,7 @@ pub fn effective_partition_size_reported( ) .map_err(|e| format!("cannot open filesystem: {e}"))?; fs.last_data_byte() + .map(|m| m + head) .map_err(|e| format!("last_data_byte failed: {e}")) } @@ -1201,6 +1217,7 @@ pub fn defragmented_partition_size( // real bound is enforced by the eventual resize plan. let wrapper_info = hfsplus_wrapper_clone::detect_wrapped_hfsplus(&mut reader, partition_offset, u64::MAX); + let head = embedded_label_bytes(&mut reader, partition_offset, partition_type_string); let mut fs = open_filesystem( reader, partition_offset, @@ -1213,7 +1230,7 @@ pub fn defragmented_partition_size( let plan = hfsplus_wrapper_clone::plan_wrapped_clone(&info, inner_min).ok()?; Some(plan.new_partition_size) } else { - Some(inner_min) + Some(inner_min + head) } } @@ -1267,6 +1284,8 @@ pub fn fs_name_for(partition_type: u8, partition_type_string: Option<&str>) -> & // Apple APFS GPT partition GUID. "7C3457EF-0000-11AA-AA11-00306543ECAC" => "APFS", "Apple_UNIX_SVR2" => "ext/btrfs/xfs/reiserfs/UFS/JFS", + // Mac OS X Server 1.x / Rhapsody, behind a NeXT disk label. + "Apple_Rhapsody_UFS" => "UFS", "Linux" => "ext/btrfs/xfs/reiserfs/UFS/JFS", // GPT Linux Filesystem / Linux Home GUIDs. "0FC63DAF-8483-4772-8E79-3D69D8477DE4" | "933AC7E1-2EB4-4F13-B844-0E14E2AEF915" => { @@ -1340,6 +1359,7 @@ pub fn is_layout_preserving_fs(partition_type: u8, partition_type_string: Option | "Apple_HFS+" | "Apple_UNIX_SVR2" | "Apple_UNIX_SRVR2" + | "Apple_Rhapsody_UFS" | "Apple_PRODOS" | "Apple_ProDOS" | "Linux" @@ -1429,6 +1449,8 @@ pub fn is_expensive_minimum(partition_type: u8, partition_type_string: Option<&s "Apple_HFS" | "Apple_HFSX" | "Apple_UNIX_SVR2" + // Rhapsody: a UFS bitmap walk, same cost as the SVR2 slices. + | "Apple_Rhapsody_UFS" | "Linux" | "48465300-0000-11AA-AA11-00306543ECAC" | "0FC63DAF-8483-4772-8E79-3D69D8477DE4" @@ -1544,6 +1566,7 @@ pub fn partition_minimum_size_cancellable( progress("Cancelled"); return cancelled(); } + let label_head = embedded_label_bytes(&mut reader, partition_offset, partition_type_string); progress("Opening filesystem..."); let mut fs = match open_filesystem_sized( reader, @@ -1573,7 +1596,10 @@ pub fn partition_minimum_size_cancellable( return cancelled(); } progress("Computing last data byte..."); - let in_place = fs.last_data_byte().ok().map(|m| m.min(partition_size)); + let in_place = fs + .last_data_byte() + .ok() + .map(|m| (m + label_head).min(partition_size)); if cancel() { progress("Cancelled"); return cancelled(); @@ -1629,7 +1655,7 @@ pub fn partition_minimum_size_cancellable( return None; } }, - None => m, + None => m + label_head, }; let clamped = partition_level.min(partition_size); progress(&format!( @@ -2196,6 +2222,11 @@ pub fn open_editable_filesystem_with( ))), }; } + // Rhapsody's NeXT label fronts the UFS; see the read path's arm. + "Apple_Rhapsody_UFS" => { + let fs_offset = resolve_next_label(&mut reader, partition_offset); + return Ok(Box::new(ufs::UfsFilesystem::open(reader, fs_offset)?)); + } "Apple_PRODOS" | "Apple_ProDOS" => { return Ok(Box::new(prodos::ProDosFilesystem::open( reader, @@ -2680,6 +2711,12 @@ fn open_filesystem_by_string( ))), } } + // Mac OS X Server 1.x / Rhapsody: a NeXT disk label fronts the UFS, so + // the filesystem starts past the label's front porch, not at LBA 0. + "Apple_Rhapsody_UFS" => { + let fs_offset = resolve_next_label(&mut reader, partition_offset); + open_filesystem_with_passphrase(reader, fs_offset, 0x00, None, passphrase) + } "Apple_PRODOS" | "Apple_ProDOS" => Ok(Box::new(prodos::ProDosFilesystem::open( reader, partition_offset, @@ -2903,6 +2940,8 @@ fn compact_partition_reader_by_string( _ => Ok(None), } } + // Rhapsody's NeXT label rides in front of the UFS; see the read path's arm. + "Apple_Rhapsody_UFS" => Ok(next_label_compact_reader(reader, partition_offset)), "Apple_PRODOS" | "Apple_ProDOS" => { let (compact, info) = CompactProDosReader::new(reader, partition_offset).map_err(|e| { @@ -3045,6 +3084,8 @@ pub fn is_browsable_type_string(type_str: Option<&str>) -> bool { | "Be_BFS" | "Apple_UNIX_SVR2" | "Apple_UNIX_SRVR2" + // Mac OS X Server 1.x / Rhapsody UFS, behind a NeXT disk label. + | "Apple_Rhapsody_UFS" | "Apple_PRODOS" | "Apple_ProDOS" // GPT "Linux Filesystem" GUID — ext, btrfs, or xfs at runtime. @@ -3265,7 +3306,10 @@ pub fn is_checkable_type(ptype: u8, type_str: Option<&str>) -> bool { if matches!(ptype, 0x80 | 0x81 | 0xA5 | 0xA6 | 0xA9 | 0xBF | 0xEB) || matches!( type_str, - Some("Apple_UNIX_SVR2") | Some("Apple_UNIX_SRVR2") | Some("Be_BFS") + Some("Apple_UNIX_SVR2") + | Some("Apple_UNIX_SRVR2") + | Some("Be_BFS") + | Some("Apple_Rhapsody_UFS") ) { return true; @@ -3414,6 +3458,14 @@ fn hfsplus_partition_len( } } +/// Where a partition that opens with a NeXT disk label keeps its filesystem; +/// `partition_offset` unchanged when there is none. See `src/partition/next.rs`. +pub fn resolve_next_label(reader: &mut (impl Read + Seek), partition_offset: u64) -> u64 { + crate::partition::next::detect_at(reader, partition_offset) + .and_then(|l| crate::partition::next::embedded_fs_offset(&l, partition_offset)) + .unwrap_or(partition_offset) +} + /// Resolve the actual HFS filesystem variant for an "Apple_HFS" APM partition. /// /// Returns `(fs_type, hfsplus_offset)` where `fs_type` is `"hfs"`, `"hfsplus"`, @@ -4729,6 +4781,140 @@ mod min_size_cancel_tests { } } +#[cfg(test)] +mod rhapsody_dispatch_tests { + use super::*; + use crate::fs::ufs::UfsEndian; + use crate::fs::ufs_format::{create_blank_ufs1, Ufs1FormatParams}; + use crate::partition::next::{build_label, NextLabelSpec, NextPartitionSpec, LABEL_BLOCKS}; + use std::io::Cursor; + + const SECTOR: u64 = 1024; + const FRONT: u64 = 160; + const UFS_BYTES: u64 = 8 * 1024 * 1024; + + /// A Rhapsody slice: a NeXT label in its first sectors, then the UFS. + /// `p_base` is recorded from the disk origin, as Rhapsody writes it. + fn rhapsody_slice(slice_offset: u64) -> Vec { + let mut spec = NextLabelSpec { + front_porch: FRONT as u16, + ..Default::default() + }; + spec.partitions = vec![ + Some(NextPartitionSpec { + base: (slice_offset / SECTOR) as i32, + size: (UFS_BYTES / SECTOR) as i32, + block_size: 4096, + fs_type: "4.4BSD".to_string(), + ..Default::default() + }), + None, + None, + None, + None, + None, + None, + None, + ]; + let label = build_label(&spec); + let mut disk = vec![0u8; (slice_offset + FRONT * SECTOR + UFS_BYTES) as usize]; + for &block in LABEL_BLOCKS.iter() { + let at = (slice_offset + block * 512) as usize; + disk[at..at + label.len()].copy_from_slice(&label); + } + let ufs = create_blank_ufs1(&Ufs1FormatParams { + size_bytes: UFS_BYTES, + block_size: 4096, + endian: UfsEndian::Big, + ..Default::default() + }) + .expect("format a blank UFS1"); + let at = (slice_offset + FRONT * SECTOR) as usize; + disk[at..at + ufs.len()].copy_from_slice(&ufs); + disk + } + + #[test] + fn rhapsody_slice_opens_past_the_nested_label() { + let slice_offset = 18952 * 512; + let disk = rhapsody_slice(slice_offset); + let fs = open_filesystem( + Cursor::new(disk), + slice_offset, + 0, + Some("Apple_Rhapsody_UFS"), + ) + .expect("open the UFS behind the label"); + assert_eq!(fs.fs_type(), "UFS1"); + } + + /// Compaction has to start at the slice, not at the filesystem: the head + /// region is what the NeXT label lives in, and dropping it loses the label. + #[test] + fn rhapsody_compaction_keeps_the_label_and_spans_the_whole_slice() { + let slice_offset = 18952 * 512; + let disk = rhapsody_slice(slice_offset); + let head: Vec = + disk[slice_offset as usize..(slice_offset + FRONT * SECTOR) as usize].to_vec(); + let (mut reader, info) = compact_partition_reader( + Cursor::new(disk), + slice_offset, + 0, + Some("Apple_Rhapsody_UFS"), + true, + ) + .expect("a compact reader for the slice"); + assert_eq!( + info.compacted_size, + FRONT * SECTOR + UFS_BYTES, + "layout-preserving: the stream is the whole slice" + ); + assert!( + info.data_size < info.compacted_size, + "free blocks are zeros" + ); + let mut got = vec![0u8; head.len()]; + reader.read_exact(&mut got).unwrap(); + assert_eq!(got, head, "the label rides at the front of the stream"); + } + + #[test] + fn rhapsody_is_layout_preserving() { + assert!(is_layout_preserving_fs(0, Some("Apple_Rhapsody_UFS"))); + assert_eq!(fs_name_for(0, Some("Apple_Rhapsody_UFS")), "UFS"); + assert!(is_checkable_type(0, Some("Apple_Rhapsody_UFS"))); + } + + /// The minimum is a partition-level size, so it has to include the label + /// the filesystem sits behind — shrink to the filesystem's own answer and + /// the last 160 KiB of it falls off the end of the slice. + #[test] + fn rhapsody_minimum_counts_the_label_head() { + let slice_offset = 18952 * 512; + let disk = rhapsody_slice(slice_offset); + let slice_len = FRONT * SECTOR + UFS_BYTES; + let result = partition_minimum_size( + Cursor::new(disk), + slice_offset, + 0, + Some("Apple_Rhapsody_UFS"), + slice_len, + true, + None, + &|_| {}, + ); + let MinimumResult::Computed { in_place, .. } = result else { + panic!("expected a computed minimum"); + }; + let min = in_place.expect("UFS reports a last data byte"); + assert!( + min > FRONT * SECTOR, + "minimum {min} must clear the {} byte label head", + FRONT * SECTOR + ); + } +} + #[cfg(test)] mod human68k_dispatch_tests { use super::*; diff --git a/src/fs/ntfs.rs b/src/fs/ntfs.rs index 5cbee13f..e27eb97d 100644 --- a/src/fs/ntfs.rs +++ b/src/fs/ntfs.rs @@ -5173,6 +5173,54 @@ pub fn resize_ntfs_in_place( Ok(true) } +/// Rewrite the backup boot sector (the sector after the volume) when it no +/// longer matches the VBR; a packed stream ends before it. Ok(true) if written. +pub fn ensure_backup_boot_sector( + file: &mut (impl Read + Write + Seek), + partition_offset: u64, + log_cb: &mut impl FnMut(&str), +) -> Result { + let mut probe = [0u8; 512]; + file.seek(SeekFrom::Start(partition_offset))?; + file.read_exact(&mut probe)?; + if &probe[3..11] != b"NTFS " { + return Ok(false); + } + let bytes_per_sector = u16::from_le_bytes([probe[0x0B], probe[0x0C]]) as u64; + if !(512..=4096).contains(&bytes_per_sector) { + return Ok(false); + } + let total_sectors = u64::from_le_bytes([ + probe[0x28], + probe[0x29], + probe[0x2A], + probe[0x2B], + probe[0x2C], + probe[0x2D], + probe[0x2E], + probe[0x2F], + ]); + let mut vbr = vec![0u8; bytes_per_sector as usize]; + file.seek(SeekFrom::Start(partition_offset))?; + file.read_exact(&mut vbr)?; + let backup_offset = partition_offset + total_sectors * bytes_per_sector; + let mut backup = vec![0u8; bytes_per_sector as usize]; + file.seek(SeekFrom::Start(backup_offset))?; + match file.read_exact(&mut backup) { + Ok(()) if backup == vbr => return Ok(false), + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {} + Err(e) => return Err(e.into()), + } + file.seek(SeekFrom::Start(backup_offset))?; + file.write_all(&vbr)?; + log_cb(&format!( + "NTFS: rewrote the backup boot sector at sector {}", + total_sectors + )); + Ok(true) +} + /// Helper to read the last used cluster from $Bitmap. fn read_last_used_cluster_from_bitmap( file: &mut (impl Read + Seek), @@ -7054,3 +7102,39 @@ mod tests { assert!(matches!(result, Err(FilesystemError::AlreadyExists(_)))); } } + +#[cfg(test)] +mod backup_boot_sector_tests { + use super::ensure_backup_boot_sector; + use std::io::{Cursor, Read, Seek, SeekFrom}; + + /// A packed stream ends before the backup boot sector; the helper puts it + /// back, and does nothing once it matches the VBR. + #[test] + fn rewrites_a_missing_backup_boot_sector_then_leaves_it_alone() { + let size = 32 * 1024 * 1024u64; + let mut img = Cursor::new(vec![0u8; size as usize]); + crate::fs::ntfs_format::create_blank_ntfs(&mut img, size, 64, Some("B")).unwrap(); + let mut vbr = [0u8; 512]; + img.seek(SeekFrom::Start(0)).unwrap(); + img.read_exact(&mut vbr).unwrap(); + let total = u64::from_le_bytes(vbr[0x28..0x30].try_into().unwrap()); + let backup_at = (total * 512) as usize; + assert_eq!(&img.get_ref()[backup_at..backup_at + 512], &vbr[..]); + + img.get_mut()[backup_at..backup_at + 512].fill(0); + let mut logged = Vec::new(); + assert!( + ensure_backup_boot_sector(&mut img, 0, &mut |m| logged.push(m.to_string())).unwrap() + ); + assert_eq!(&img.get_ref()[backup_at..backup_at + 512], &vbr[..]); + assert_eq!(logged.len(), 1); + assert!(!ensure_backup_boot_sector(&mut img, 0, &mut |_| {}).unwrap()); + } + + #[test] + fn ignores_a_volume_that_is_not_ntfs() { + let mut img = Cursor::new(vec![0u8; 1024 * 1024]); + assert!(!ensure_backup_boot_sector(&mut img, 0, &mut |_| {}).unwrap()); + } +} diff --git a/src/gui/inspect_tab.rs b/src/gui/inspect_tab.rs index 9757e62d..d519fc4a 100644 --- a/src/gui/inspect_tab.rs +++ b/src/gui/inspect_tab.rs @@ -2104,6 +2104,12 @@ impl InspectTab { use rusty_backup::backup::single_file_chd; let table = self.partition_table.clone()?; if !single_file_chd::is_supported(&table) { + if self.export_picker_has_resize() { + ctx.log.warn(format!( + "{} sources cannot be resized on export; the disk is exported as-is", + table.type_name() + )); + } return None; } let partitions = self.partitions.clone(); diff --git a/src/model/file_types.rs b/src/model/file_types.rs index 859cfc61..e6fe05d4 100644 --- a/src/model/file_types.rs +++ b/src/model/file_types.rs @@ -97,6 +97,8 @@ pub const DISK_IMAGE_EXTS: &[&str] = &[ "dart", "sparseimage", "smi", + // NeXT magneto-optical media, ECC-coded sectors and all. + "od", ]; /// Extensions that appear in the GUI file-picker dropdown (so a user can @@ -238,6 +240,15 @@ mod tests { assert!(association_exts().contains(&"dsd".to_string())); } + #[test] + fn next_mo_extension_present() { + // A NeXT MO image is a picker-visible, openable container (its 1296-byte + // sectors are decoded by `rbformats::next_mo`) — guard it against a + // future extension-list trim. + assert!(DISK_IMAGE_EXTS.contains(&"od")); + assert!(association_exts().contains(&"od".to_string())); + } + #[test] fn zip_is_picker_only_not_associated() { // `.zip` is openable (a RAW disk image inside a zip), so it must diff --git a/src/model/source_reader.rs b/src/model/source_reader.rs index 599c1a55..720e3714 100644 --- a/src/model/source_reader.rs +++ b/src/model/source_reader.rs @@ -1504,6 +1504,11 @@ pub fn open_container_rw(path: &Path) -> Result { } ContainerRw::Handle(Box::new(reader)) } + // The MO reader re-encodes both ECC passes on write, so an edited + // sector goes back the way the drive expects. See `rbformats::next_mo`. + ImageFormat::NextMo(geo) => ContainerRw::Handle(Box::new( + crate::rbformats::next_mo::NextMoReader::new(rw()?, geo), + )), other => ContainerRw::ReadOnly(format!( "{}: this container decodes for reading but cannot be written back, \ so edits would have nowhere to go. Convert it to a raw image first: \ diff --git a/src/os/linux.rs b/src/os/linux.rs index b83bdfae..36ba3fd7 100644 --- a/src/os/linux.rs +++ b/src/os/linux.rs @@ -5,10 +5,40 @@ use std::fs::{self, File}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use nix::mount::{umount2, MntFlags}; use crate::device::{DiskDevice, MountedPartition}; +/// The POSIX calls this module needs, through `libc` rather than `nix`, which +/// the mrustc manifests cannot carry. See docs/build-ppc-mrustc.md "No nix". +mod sys { + /// `umount2(2)`; `detach` picks the lazy `MNT_DETACH`. True when it worked. + pub fn umount(mount_point: &str, detach: bool) -> bool { + let c_path = match std::ffi::CString::new(mount_point) { + Ok(p) => p, + Err(_) => return false, + }; + let flags = if detach { libc::MNT_DETACH } else { 0 }; + unsafe { libc::umount2(c_path.as_ptr(), flags) == 0 } + } + + pub fn is_root() -> bool { + unsafe { libc::geteuid() == 0 } + } + + pub fn real_uid() -> u32 { + unsafe { libc::getuid() } + } + + pub fn real_gid() -> u32 { + unsafe { libc::getgid() } + } + + /// `umask(0)`, so an elevated run leaves files the real user can still read. + pub fn clear_umask() { + unsafe { libc::umask(0) }; + } +} + /// A parsed entry from `/proc/self/mountinfo`. struct MountInfoEntry { mount_point: String, @@ -312,8 +342,8 @@ pub fn open_target_for_writing(path: &Path) -> Result { /// Unmount `mount_point` for real; fall back to a lazy detach only when it /// is busy, so at least new opens stop landing on the old filesystem. fn unmount_now_or_lazily(mount_point: &str) { - if umount2(mount_point, MntFlags::empty()).is_err() { - let _ = umount2(mount_point, MntFlags::MNT_DETACH); + if !sys::umount(mount_point, false) { + let _ = sys::umount(mount_point, true); } } @@ -373,6 +403,14 @@ pub fn parent_device_name(partition_name: &str) -> String { mod tests { use super::*; + /// A path with an interior NUL must be refused before it reaches umount2, + /// not panic: `nix` rejected these too, via its own CString conversion. + #[test] + fn umount_refuses_a_path_with_an_interior_nul() { + assert!(!sys::umount("/mnt/\0evil", false)); + assert!(!sys::umount("/mnt/\0evil", true)); + } + #[test] fn elevation_target_prefers_appimage_over_fuse_mount() { let fuse = PathBuf::from("/tmp/.mount_RustyAb12/usr/bin/rusty-backup"); @@ -507,7 +545,7 @@ impl LinuxDiskAccess { impl PrivilegedDiskAccess for LinuxDiskAccess { fn check_status(&self) -> Result { // Check if we're running as root - if nix::unistd::geteuid().is_root() { + if sys::is_root() { Ok(AccessStatus::Ready) } else { Ok(AccessStatus::NeedsElevation) @@ -688,8 +726,8 @@ pub fn relaunch_with_elevation() -> Result<()> { if let Ok(user) = std::env::var("USER") { env_args.push(format!("SUDO_USER={}", user)); } - env_args.push(format!("SUDO_UID={}", nix::unistd::getuid())); - env_args.push(format!("SUDO_GID={}", nix::unistd::getgid())); + env_args.push(format!("SUDO_UID={}", sys::real_uid())); + env_args.push(format!("SUDO_GID={}", sys::real_gid())); // Use "env" to inject variables. Exec replaces current process — never // returns on success. @@ -709,7 +747,7 @@ pub fn relaunch_with_elevation() -> Result<()> { /// through so we can resolve the original user's home. Falls back to /// `dirs::home_dir()` if not elevated or if env vars aren't set. pub fn real_user_home() -> Option { - if !nix::unistd::geteuid().is_root() { + if !sys::is_root() { return dirs::home_dir(); } // HOME was passed through pkexec env wrapper @@ -735,8 +773,8 @@ pub fn real_user_home() -> Option { /// Sets umask to 000 so files are created with mode 666 and directories with /// mode 777. The real security boundary is the pkexec prompt itself. pub fn set_permissive_umask_if_elevated() { - if nix::unistd::geteuid().is_root() { - nix::sys::stat::umask(nix::sys::stat::Mode::empty()); + if sys::is_root() { + sys::clear_umask(); } } diff --git a/src/partition/mod.rs b/src/partition/mod.rs index c24cbc71..a05a3652 100644 --- a/src/partition/mod.rs +++ b/src/partition/mod.rs @@ -9,6 +9,7 @@ pub mod next; pub mod provision; pub mod rdb; pub mod resize; +pub mod restore_patch; pub mod sgi; pub mod sgi_dklabel; pub mod sgi_hdd_builder; @@ -168,11 +169,63 @@ impl PartitionInfo { } } -/// Standard floppy disk image sizes (bytes). +/// True when any two of `parts` claim the same byte, ignoring the extended +/// container (which contains its logicals by definition). /// -/// Images matching one of these sizes that lack both a recognized filesystem -/// and a valid MBR/GPT signature are treated as superfloppies with an unknown -/// filesystem rather than producing a confusing partition-table error. +/// SGI volume headers describe several alternative layouts at once — an `fx` +/// disk's slots overlap by design — so a whole-disk backup cannot split such a +/// table into per-partition bodies. See `docs/backup_partition_schemes.md`. +pub fn partitions_overlap(parts: &[PartitionInfo]) -> bool { + let mut extents: Vec<(u64, u64)> = parts + .iter() + .filter(|p| !p.is_extended_container && p.size_bytes > 0) + .map(|p| (p.byte_offset(), p.byte_offset() + p.size_bytes)) + .collect(); + extents.sort_unstable(); + extents.windows(2).any(|w| w[1].0 < w[0].1) +} + +/// Why a table cannot be split into per-partition bodies for a whole-disk +/// backup, or `None` when it can. The text is shown to the user. +/// +/// Two shapes cannot: slots that overlap (an SGI `fx` disk describes several +/// alternative layouts at once), and an AHDI XGM chain, whose follow-up +/// sectors sit in the gaps between logicals with no writer to rebuild them — +/// MBR gets away with the same shape only because `build_ebr_chain` exists. +pub fn whole_disk_body_reason( + table: &PartitionTable, + parts: &[PartitionInfo], +) -> Option<&'static str> { + if partitions_overlap(parts) { + return Some("its slots overlap"); + } + if matches!(table, PartitionTable::Ahdi(_)) && parts.iter().any(|p| p.is_logical) { + return Some("its XGM extended chain has no writer"); + } + None +} + +/// One synthetic partition covering the whole drive, for a table that +/// [`whole_disk_body_reason`] rejects. Its body carries the table, so a +/// restore puts that back too. +pub fn whole_disk_partition(table: &PartitionTable, size_bytes: u64) -> PartitionInfo { + PartitionInfo { + index: 0, + type_name: format!("{} whole disk", table.type_name()), + partition_type_byte: 0, + start_lba: 0, + start_byte: Some(0), + size_bytes, + bootable: false, + is_logical: false, + is_extended_container: false, + partition_type_string: None, + hfs_block_size: None, + rdb_part_block: None, + drv_name: None, + } +} + /// Read sector 0, retrying a transient device error before giving up. /// /// Removable media — USB floppy drives especially — commonly fail the first @@ -1423,7 +1476,7 @@ impl PartitionTable { // so `open_filesystem` auto-detects (finds the UFS super // block, big-endian SPARC variant included). Swap / other // slices simply won't resolve to a browsable filesystem. - type_name: format!("Sun {} (UFS?)", s.tag_name()), + type_name: format!("Sun {} (UFS?)", label.slice_type_name(i)), partition_type_byte: 0, start_lba: s.start_sector, start_byte: None, @@ -1939,6 +1992,7 @@ pub fn largest_free_region( } /// Partition size override for VHD export and restore. +#[derive(Debug, Clone)] pub struct PartitionSizeOverride { pub index: usize, pub start_lba: u64, @@ -3022,6 +3076,114 @@ mod layout_expectation_tests { } } +#[cfg(test)] +mod overlap_tests { + use super::*; + + fn part(index: usize, start_lba: u64, size_bytes: u64) -> PartitionInfo { + PartitionInfo { + index, + type_name: "test".into(), + partition_type_byte: 0, + start_lba, + start_byte: None, + size_bytes, + bootable: false, + is_logical: false, + is_extended_container: false, + partition_type_string: None, + hfs_block_size: None, + rdb_part_block: None, + drv_name: None, + } + } + + #[test] + fn adjacent_partitions_do_not_overlap() { + // 119..17969, 17969..35700, 35700..115430 — the SGI-DkLabel fixture. + let parts = [ + part(0, 119, 17_850 * 512), + part(1, 17_969, 17_731 * 512), + part(2, 35_700, 79_730 * 512), + ]; + assert!(!partitions_overlap(&parts)); + } + + /// An `fx` disk describes several alternative layouts at once, so two of + /// its slots really do claim the same sectors. + #[test] + fn sgi_alternative_layouts_overlap() { + let parts = [ + part(0, 2_520, 39_480 * 512), + part(1, 1_533_000, 39_480 * 512), + part(2, 2_520, 1_569_960 * 512), + ]; + assert!(partitions_overlap(&parts)); + } + + /// Order of the input must not matter — the SGI slot list is not sorted. + #[test] + fn overlap_is_found_whatever_the_slot_order() { + let parts = [part(0, 1_000, 512), part(1, 0, 2_000 * 512)]; + assert!(partitions_overlap(&parts)); + } + + #[test] + fn a_zero_length_slot_cannot_overlap_anything() { + let parts = [part(0, 2_520, 0), part(1, 2_520, 512)]; + assert!(!partitions_overlap(&parts)); + } + + /// MBR gets away with the same shape because `build_ebr_chain` rebuilds + /// its EBRs on restore; nothing rebuilds an AHDI XGM chain, so those disks + /// have to be imaged whole. + #[test] + fn an_ahdi_xgm_chain_forces_a_whole_disk_body() { + let mut logical = part(1, 2_048, 1024); + logical.is_logical = true; + let parts = [part(0, 2, 1024), logical]; + let ahdi = ahdi_table(); + assert_eq!( + whole_disk_body_reason(&ahdi, &parts), + Some("its XGM extended chain has no writer") + ); + // Primaries only: the per-partition split is fine. + assert_eq!(whole_disk_body_reason(&ahdi, &parts[..1]), None); + } + + /// A real two-primary AHDI root sector, built by the provisioner. + fn ahdi_table() -> PartitionTable { + use crate::partition::provision::{self, Geometry, PartSpec}; + use crate::partition::type_catalog::TableKind; + const TOTAL: u64 = 8 * 1024 * 1024; + let geometry = Geometry::default(); + let specs = vec![PartSpec { + size: Some(2 * 1024 * 1024), + type_text: Some("GEM".to_string()), + name: None, + }]; + let align = provision::default_align(TableKind::Atari, geometry); + let placed = + provision::place(&specs, TableKind::Atari, TOTAL, align, geometry).expect("place"); + let mut img = std::io::Cursor::new(vec![0u8; TOTAL as usize]); + provision::write_table(&mut img, TableKind::Atari, &placed, TOTAL, geometry) + .expect("write an AHDI root sector"); + img.set_position(0); + PartitionTable::detect(&mut img).expect("detect AHDI") + } + + #[test] + fn the_whole_disk_stand_in_starts_at_zero_and_spans_the_drive() { + let table = PartitionTable::None { + size_bytes: 4096, + fs_hint: "Unknown".into(), + }; + let p = whole_disk_partition(&table, 4096); + assert_eq!(p.byte_offset(), 0, "its body has to carry the label"); + assert_eq!(p.size_bytes, 4096); + } +} + #[cfg(test)] mod unrecognized_media_tests { use super::*; diff --git a/src/partition/next.rs b/src/partition/next.rs index 5429f91c..395aa6c2 100644 --- a/src/partition/next.rs +++ b/src/partition/next.rs @@ -31,6 +31,15 @@ //! stamps its own `dl_label_blkno` but they otherwise share one checksum, //! because the checksum is computed with `dl_label_blkno` read as zero. See //! [`checksum`]. +//! +//! **Nested labels.** Mac OS X Server 1.x / Rhapsody is an APM disk that keeps +//! this label anyway: its `Apple_Rhapsody_UFS` slice opens with a `dlV3` copy, +//! and the UFS begins past that label's front porch rather than at the slice's +//! first byte. [`detect_at`] probes a label at a given base and +//! [`embedded_fs_offset`] resolves the filesystem behind it. Rhapsody records +//! `p_base` from the **disk** origin even for a nested label, so the label's own +//! answer is already absolute; a whole-disk NeXTSTEP label counts from its own +//! block 0 with a zero container offset, which is why one expression covers both. use byteorder::{BigEndian, ByteOrder}; use serde::{Deserialize, Serialize}; @@ -118,6 +127,18 @@ pub struct NextDiskLabel { pub front_porch: u16, /// `d_back` — sectors reserved after the last partition. pub back_porch: u16, + /// `d_ngroups` — alternate (spare) groups on the media; 0 means no sparing. + #[serde(default)] + pub group_count: u16, + /// `d_ag_size` — sectors per alternate group. + #[serde(default)] + pub group_size: u16, + /// `d_ag_alts` — spare sectors inside each group. + #[serde(default)] + pub group_alts: u16, + /// `d_ag_off` — where in a group the spares sit. See `rbformats::next_mo`. + #[serde(default)] + pub group_off: u16, /// `d_bootfile` — kernel NeXTSTEP boots (`mach_kernel`, `sdmach`). pub boot_file: String, pub hostname: String, @@ -206,6 +227,10 @@ impl NextDiskLabel { rpm: BigEndian::read_u32(&buf[0x6C..0x70]), front_porch, back_porch: BigEndian::read_u16(&buf[0x72..0x74]), + group_count: BigEndian::read_u16(&buf[0x74..0x76]), + group_size: BigEndian::read_u16(&buf[0x76..0x78]), + group_alts: BigEndian::read_u16(&buf[0x78..0x7A]), + group_off: BigEndian::read_u16(&buf[0x7A..0x7C]), boot_file: c_string(&buf[0x84..0x9C]), hostname: c_string(&buf[0x9C..0xBC]), root_partition: printable_letter(buf[0xBC]), @@ -451,9 +476,15 @@ pub fn write_copies( /// Probe the four label copies and return the first that validates. pub fn detect(reader: &mut R) -> Option { + detect_at(reader, 0) +} + +/// [`detect`], but for a label written `base` bytes into the source rather +/// than at block 0 — Mac OS X Server 1.x nests one inside an APM slice. +pub fn detect_at(reader: &mut R, base: u64) -> Option { let disk_size = reader.seek(SeekFrom::End(0)).ok()?; for block in LABEL_BLOCKS { - let offset = block * 512; + let offset = base + block * 512; if offset + LABEL_SPAN as u64 > disk_size { continue; } @@ -476,10 +507,21 @@ pub fn detect(reader: &mut R) -> Option { None } +/// Byte offset of the filesystem a label found at `container_offset` fronts; +/// `None` when no slot is in use. See this module's header for the two forms. +pub fn embedded_fs_offset(label: &NextDiskLabel, container_offset: u64) -> Option { + let (_, p) = label.browsable_partitions().next()?; + Some(if p.start_byte >= container_offset { + p.start_byte + } else { + container_offset + p.start_byte + }) +} + /// Signature, sector size, and checksum all have to agree before we claim a /// disk — a NeXTSTEP/Intel disk also carries a valid `0xAA55` boot sector, so /// this probe runs ahead of MBR parsing and must never fire on a PC disk. -fn validates(buf: &[u8]) -> bool { +pub fn validates(buf: &[u8]) -> bool { if buf.len() < 8 { return false; } @@ -622,6 +664,39 @@ mod tests { assert!(!validates(&buf)); } + /// Mac OS X Server 1.x nests its label in an APM slice, and records + /// `p_base` from the disk origin — so the label's own answer is absolute. + #[test] + fn detect_at_finds_a_label_nested_in_a_partition() { + const SLICE: u64 = 18952 * 512; + let mut img = vec![0u8; SLICE as usize + 4 * 1024 * 1024]; + let mut buf = synth_label(); + // p_base counts 1024-byte sectors from the disk origin, as Rhapsody writes it. + BigEndian::write_i32( + &mut buf[PART_TABLE_OFF..PART_TABLE_OFF + 4], + (SLICE / 1024) as i32, + ); + NextDiskLabel::stamp_checksum(&mut buf, NEXT_LABEL_V3); + img[SLICE as usize..SLICE as usize + LABEL_SPAN].copy_from_slice(&buf); + let mut cur = std::io::Cursor::new(img); + + assert!(detect(&mut cur).is_none(), "no label at the disk's block 0"); + let label = detect_at(&mut cur, SLICE).expect("label at the slice's block 0"); + assert_eq!( + embedded_fs_offset(&label, SLICE), + Some(SLICE + 160 * 1024), + "filesystem starts past the front porch" + ); + } + + /// A whole-disk label counts from its own block 0, so the same call with a + /// zero container offset must not double-count. + #[test] + fn embedded_fs_offset_of_a_whole_disk_label_is_the_front_porch() { + let label = NextDiskLabel::parse(&synth_label(), 0).unwrap(); + assert_eq!(embedded_fs_offset(&label, 0), Some(160 * 1024)); + } + #[test] fn detect_rejects_a_plain_mbr() { let mut img = vec![0u8; 64 * 1024]; diff --git a/src/partition/provision.rs b/src/partition/provision.rs index e458b290..926bf46d 100644 --- a/src/partition/provision.rs +++ b/src/partition/provision.rs @@ -445,19 +445,22 @@ pub fn write_table( } fn write_mbr(out: &mut W, placed: &[Placed]) -> Result<()> { - let entries: Vec<(u8, u32, u32, bool)> = placed - .iter() - .map(|p| { - let byte = - u8::from_str_radix(p.type_text.trim().trim_start_matches("0x"), 16).unwrap_or(0x83); - ( - byte, - p.start_lba as u32, - (p.size_bytes / SECTOR) as u32, - false, + let mut entries: Vec<(u8, u32, u32, bool)> = Vec::with_capacity(placed.len()); + for p in placed { + let text = p.type_text.trim(); + // A name that is not a hex type byte used to land silently as Linux. + let byte = u8::from_str_radix(text.trim_start_matches("0x"), 16).map_err(|_| { + anyhow::anyhow!( + "MBR partition type '{text}' is not a hex type byte; see `partmap types --table mbr`" ) - }) - .collect(); + })?; + entries.push(( + byte, + p.start_lba as u32, + (p.size_bytes / SECTOR) as u32, + false, + )); + } let bytes = mbr::build_minimal_mbr(0x5253_5459, &entries, 255, 63); out.seek(SeekFrom::Start(0))?; out.write_all(&bytes).context("writing the MBR")?; diff --git a/src/partition/restore_patch.rs b/src/partition/restore_patch.rs new file mode 100644 index 00000000..7871cd12 --- /dev/null +++ b/src/partition/restore_patch.rs @@ -0,0 +1,726 @@ +//! Restore-time rewrite of a disk label the single-file CHD carries verbatim. +//! +//! Every scheme here is backed up whole: the head region (label copies, the +//! RDSK/PART chain, the IPL, boot blocks) rides byte for byte, and a resize is +//! only ever applied on restore. The patchers rewrite the start/size fields +//! and checksums *in the verbatim bytes*, so everything else survives. +//! +//! Each scheme counts partitions in its own unit (cylinders on Sun and RDB, +//! 1024-byte sectors past a porch on NeXT, 256/512/1024 on X68k), so a patcher +//! may round a partition up and shift the ones after it. It returns the +//! overrides it actually applied, and the caller copies the bodies from those. + +#[cfg(feature = "rust173-polyfill")] +use crate::rust173_compat::IntIsMultipleOf as _; +use std::io::Cursor; + +use anyhow::{bail, Context, Result}; +use byteorder::{BigEndian, ByteOrder}; + +use super::PartitionSizeOverride; + +/// A head region with its table rewritten, plus the layout it now describes. +#[derive(Debug)] +pub struct PatchedHead { + pub head: Vec, + pub overrides: Vec, +} + +/// Table types (as `metadata.partition_table_type` spells them) with a patcher. +pub fn can_patch(table_type: &str) -> bool { + matches!( + table_type, + "Sun" | "NeXT" | "SGI" | "SGI-DkLabel" | "RDB" | "AHDI" | "X68k" + ) +} + +/// Rewrite the label in `head` for `overrides`; the disk will be `target_size`. +pub fn patch_head_for_restore( + table_type: &str, + head: &[u8], + overrides: &[PartitionSizeOverride], + target_size: u64, + log_cb: &mut dyn FnMut(&str), +) -> Result { + match table_type { + "Sun" => patch_sun(head, overrides, target_size, log_cb), + "NeXT" => patch_next(head, overrides, target_size, log_cb), + "SGI" => patch_sgi(head, overrides, target_size, log_cb), + "SGI-DkLabel" => patch_sgi_dklabel(head, overrides, target_size, log_cb), + "RDB" => patch_rdb(head, overrides, target_size, log_cb), + "AHDI" => patch_ahdi(head, overrides, target_size, log_cb), + "X68k" => patch_x68k(head, overrides, target_size, log_cb), + other => bail!("no restore-time label patcher for {other} tables"), + } +} + +/// Lay `overrides` out in start order on a grid of `unit_bytes`, never below +/// `floor_byte`, rounding each size up; the shifted list is what gets written. +fn repack( + overrides: &[PartitionSizeOverride], + unit_bytes: u64, + floor_byte: u64, +) -> Result> { + if unit_bytes == 0 || !unit_bytes.is_multiple_of(512) { + bail!("partition unit of {unit_bytes} bytes is not a multiple of 512"); + } + let mut order: Vec = (0..overrides.len()).collect(); + order.sort_by_key(|&i| overrides[i].effective_start_lba()); + let mut out = overrides.to_vec(); + let mut cursor = floor_byte.div_ceil(unit_bytes) * unit_bytes; + for i in order { + let o = &overrides[i]; + let wanted = o.effective_start_lba() * 512; + let start = wanted.max(cursor).div_ceil(unit_bytes) * unit_bytes; + let size = o.export_size.div_ceil(unit_bytes) * unit_bytes; + out[i].export_size = size; + out[i].new_start_lba = if start / 512 != o.start_lba { + Some(start / 512) + } else { + None + }; + cursor = start + size; + } + Ok(out) +} + +fn last_end(overrides: &[PartitionSizeOverride]) -> u64 { + overrides + .iter() + .map(|o| o.effective_start_lba() * 512 + o.export_size) + .max() + .unwrap_or(0) +} + +fn check_fits(overrides: &[PartitionSizeOverride], limit: u64, what: &str) -> Result<()> { + let end = last_end(overrides); + if end > limit { + bail!( + "the new layout ends at byte {end}, past {what} ({limit} bytes); \ + pick smaller sizes or a larger target" + ); + } + Ok(()) +} + +fn need(head: &[u8], len: usize, what: &str) -> Result<()> { + if head.len() < len { + bail!( + "the head region is {} bytes; {what} needs {len}", + head.len() + ); + } + Ok(()) +} + +// --- Sun ------------------------------------------------------------------------ + +fn patch_sun( + head: &[u8], + overrides: &[PartitionSizeOverride], + target_size: u64, + log_cb: &mut dyn FnMut(&str), +) -> Result { + use super::sun::SunDiskLabel; + need(head, 512, "a Sun label")?; + let label = SunDiskLabel::parse(&head[..512]).context("Sun label in the backup")?; + let spc = label.sectors_per_cylinder; + if spc == 0 { + bail!("Sun label has zero sectors per cylinder"); + } + let unit = spc * 512; + let floor = overrides + .iter() + .map(|o| o.start_lba * 512) + .min() + .unwrap_or(0); + let adjusted = repack(overrides, unit, floor)?; + let data_bytes = label.ncyl as u64 * unit; + check_fits( + &adjusted, + target_size.min(data_bytes), + "the label's data cylinders", + )?; + + let mut out = head.to_vec(); + for o in &adjusted { + let slice = label + .slices + .get(o.index) + .ok_or_else(|| anyhow::anyhow!("Sun slice {} does not exist", o.index))?; + if slice.start_sector != o.start_lba { + bail!( + "Sun slice {} starts at sector {} in the label but {} in the backup", + o.index, + slice.start_sector, + o.start_lba + ); + } + let base = 444 + o.index * 8; + let start_cyl = (o.effective_start_lba() * 512 / unit) as u32; + let nblocks = (o.export_size / 512) as u32; + BigEndian::write_u32(&mut out[base..base + 4], start_cyl); + BigEndian::write_u32(&mut out[base + 4..base + 8], nblocks); + } + let mut csum: u16 = 0; + for w in out[..510].chunks_exact(2) { + csum ^= u16::from_be_bytes([w[0], w[1]]); + } + BigEndian::write_u16(&mut out[510..512], csum); + log_cb(" table: Sun label rewritten (slices moved to cylinder boundaries)"); + Ok(PatchedHead { + head: out, + overrides: adjusted, + }) +} + +// --- NeXT ----------------------------------------------------------------------- + +fn patch_next( + head: &[u8], + overrides: &[PartitionSizeOverride], + target_size: u64, + log_cb: &mut dyn FnMut(&str), +) -> Result { + use super::next::{self, NextDiskLabel, LABEL_SPAN}; + need(head, LABEL_SPAN, "a NeXT label")?; + let label = NextDiskLabel::parse(&head[..LABEL_SPAN], 0).context("NeXT label in the backup")?; + let ss = label.sector_size as u64; + if ss == 0 || !ss.is_multiple_of(512) { + bail!("NeXT label sector size {ss} is not a multiple of 512"); + } + let floor = label.front_porch as u64 * ss; + let adjusted = repack(overrides, ss, floor)?; + let usable = target_size.saturating_sub(label.back_porch as u64 * ss); + check_fits(&adjusted, usable, "the back porch")?; + + let mut copy = head[..LABEL_SPAN].to_vec(); + for o in &adjusted { + let p = label + .partitions + .get(o.index) + .ok_or_else(|| anyhow::anyhow!("NeXT partition {} does not exist", o.index))?; + if p.start_byte != o.start_lba * 512 { + bail!( + "NeXT partition {} starts at byte {} in the label but {} in the backup", + o.index, + p.start_byte, + o.start_lba * 512 + ); + } + let base = (o.effective_start_lba() * 512 / ss) as i64 - label.front_porch as i64; + let size = (o.export_size / ss) as i64; + if base < 0 || base > i32::MAX as i64 || size > i32::MAX as i64 { + bail!( + "NeXT partition {} would not fit the label's 32-bit fields", + o.index + ); + } + next::set_partition_extent(&mut copy, o.index, base as i32, size as i32); + } + NextDiskLabel::stamp_checksum(&mut copy, label.version); + + let blocks = next::present_copies(&mut Cursor::new(head.to_vec())); + if blocks.is_empty() { + bail!("no valid NeXT label copy in the head region"); + } + let mut out = Cursor::new(head.to_vec()); + next::write_copies(&mut out, ©, &blocks).context("rewrite the NeXT label copies")?; + log_cb(&format!( + " table: NeXT label rewritten in {} copies", + blocks.len() + )); + Ok(PatchedHead { + head: out.into_inner(), + overrides: adjusted, + }) +} + +// --- SGI volume header ---------------------------------------------------------- + +fn patch_sgi( + head: &[u8], + overrides: &[PartitionSizeOverride], + target_size: u64, + log_cb: &mut dyn FnMut(&str), +) -> Result { + use super::sgi::{SgiPartitionEntry, SgiVolumeHeader, SGI_NUM_PARTITIONS}; + need(head, 512, "an SGI volume header")?; + let mut vh = SgiVolumeHeader::parse(&head[..512]).context("SGI volume header in the backup")?; + let adjusted = repack(overrides, 512, 0)?; + check_fits(&adjusted, target_size, "the target")?; + while vh.partitions.len() < SGI_NUM_PARTITIONS { + vh.partitions.push(SgiPartitionEntry { + blocks: 0, + first: 0, + partition_type_raw: 0, + }); + } + for o in &adjusted { + let e = vh + .partitions + .get_mut(o.index) + .ok_or_else(|| anyhow::anyhow!("SGI slot {} does not exist", o.index))?; + if e.first as u64 != o.start_lba { + bail!( + "SGI slot {} starts at block {} in the header but {} in the backup", + o.index, + e.first, + o.start_lba + ); + } + e.first = o.effective_start_lba() as u32; + e.blocks = (o.export_size / 512) as u32; + } + let mut out = head.to_vec(); + out[..512].copy_from_slice(&vh.to_bytes()); + log_cb(" table: SGI volume header rewritten"); + Ok(PatchedHead { + head: out, + overrides: adjusted, + }) +} + +// --- SGI disk label (IRIS 2000/3000) ---------------------------------------------- + +fn patch_sgi_dklabel( + head: &[u8], + overrides: &[PartitionSizeOverride], + target_size: u64, + log_cb: &mut dyn FnMut(&str), +) -> Result { + use super::sgi_dklabel::{apply_byte_order, SgiDiskLabel, OFF_MAP}; + need(head, 512, "an SGI disk label")?; + let label = SgiDiskLabel::parse(&head[..512]).context("SGI disk label in the backup")?; + let adjusted = repack(overrides, 512, 0)?; + let usable = if label.altstart > 0 { + target_size.min(label.altstart as u64 * 512) + } else { + target_size + }; + check_fits(&adjusted, usable, "the alternates region")?; + let mut out = head.to_vec(); + for o in &adjusted { + let m = label + .map + .get(o.index) + .ok_or_else(|| anyhow::anyhow!("SGI disk label slot {} does not exist", o.index))?; + if m.base as u64 != o.start_lba { + bail!( + "SGI disk label slot {} starts at block {} in the label but {} in the backup", + o.index, + m.base, + o.start_lba + ); + } + let off = OFF_MAP + o.index * 8; + BigEndian::write_u32(&mut out[off..off + 4], o.effective_start_lba() as u32); + BigEndian::write_u32(&mut out[off + 4..off + 8], (o.export_size / 512) as u32); + // The label's own word order: a swabbed image wants these words swapped too. + apply_byte_order(label.byte_order, &mut out[off..off + 8]); + } + log_cb(&format!( + " table: SGI disk label rewritten ({} word order)", + label.byte_order.display_name() + )); + Ok(PatchedHead { + head: out, + overrides: adjusted, + }) +} + +// --- Amiga RDB ------------------------------------------------------------------ + +fn patch_rdb( + head: &[u8], + overrides: &[PartitionSizeOverride], + target_size: u64, + log_cb: &mut dyn FnMut(&str), +) -> Result { + use super::rdb::Rdb; + let rdb = Rdb::parse(&mut Cursor::new(head.to_vec())).context("RDB in the backup")?; + // PART blocks count in cylinders, and the patcher below refuses a start off + // one; lay the disk out on the cylinder grid first and hand it explicit starts. + let cyl_units: Vec = rdb + .partitions + .iter() + .map(|p| p.surfaces as u64 * p.blk_per_trk as u64 * p.fs_block_size() / 512) + .collect(); + let unit_lbas = cyl_units.iter().copied().max().unwrap_or(0); + if unit_lbas == 0 || cyl_units.iter().any(|&c| c == 0 || unit_lbas % c != 0) { + bail!("the RDB's partitions do not share a cylinder size, so they cannot be repacked"); + } + let floor = overrides + .iter() + .map(|o| o.start_lba * 512) + .min() + .unwrap_or(0); + let explicit: Vec = repack(overrides, unit_lbas * 512, floor)? + .into_iter() + .map(|mut o| { + o.new_start_lba = Some(o.effective_start_lba()); + o + }) + .collect(); + let plan = rdb + .patch_for_restore(&explicit, &mut Cursor::new(head.to_vec())) + .context("patch the RDSK/PART blocks")?; + if plan.new_disk_size_bytes > target_size { + bail!( + "the new Amiga layout needs {} bytes but the target is {} bytes", + plan.new_disk_size_bytes, + target_size + ); + } + let mut out = head.to_vec(); + let mut place = |block: u64, buf: &[u8; 512]| -> Result<()> { + let off = (block * 512) as usize; + if off + 512 > out.len() { + bail!("RDB block {block} lies past the head region"); + } + out[off..off + 512].copy_from_slice(buf); + Ok(()) + }; + place(plan.rdsk_block.0, &plan.rdsk_block.1)?; + for (block, buf) in &plan.part_blocks { + place(*block, buf)?; + } + // The plan is in PART-chain order, which is how `partitions()` numbers them. + let mut adjusted = overrides.to_vec(); + for o in adjusted.iter_mut() { + let p = plan + .partition_plans + .get(o.index) + .ok_or_else(|| anyhow::anyhow!("RDB partition {} does not exist", o.index))?; + if p.source_lba != o.start_lba { + bail!( + "RDB partition {} starts at LBA {} in the chain but {} in the backup", + o.index, + p.source_lba, + o.start_lba + ); + } + o.export_size = p.export_size; + o.new_start_lba = if p.dest_lba != o.start_lba { + Some(p.dest_lba) + } else { + None + }; + } + log_cb(&format!( + " table: RDSK and {} PART block(s) rewritten on cylinder boundaries", + plan.part_blocks.len() + )); + Ok(PatchedHead { + head: out, + overrides: adjusted, + }) +} + +// --- Atari AHDI ----------------------------------------------------------------- + +fn patch_ahdi( + head: &[u8], + overrides: &[PartitionSizeOverride], + target_size: u64, + log_cb: &mut dyn FnMut(&str), +) -> Result { + use super::atari::{AhdiTable, AHDI_NUM_SLOTS}; + need(head, 512, "an AHDI root sector")?; + let table = AhdiTable::parse_root(&head[..512]).context("AHDI root sector in the backup")?; + let adjusted = repack(overrides, 512, 0)?; + check_fits(&adjusted, target_size, "the target")?; + let mut out = head.to_vec(); + for o in &adjusted { + if o.index >= AHDI_NUM_SLOTS { + bail!( + "AHDI logical partition {} lives in an XGM chain no writer rebuilds", + o.index + ); + } + let e = &table.primary[o.index]; + if e.start_sector as u64 != o.start_lba { + bail!( + "AHDI slot {} starts at sector {} in the root sector but {} in the backup", + o.index, + e.start_sector, + o.start_lba + ); + } + let off = 0x1C6 + o.index * 12; + BigEndian::write_u32(&mut out[off + 4..off + 8], o.effective_start_lba() as u32); + BigEndian::write_u32(&mut out[off + 8..off + 12], (o.export_size / 512) as u32); + } + if table.disk_size_sectors != 0 { + BigEndian::write_u32(&mut out[0x1F6..0x1FA], (target_size / 512) as u32); + } + // Only a bootable root sector carries the 0x1234 word-sum; keep a plain one plain. + if table.checksum_valid { + let mut sum: u32 = 0; + for w in out[..0x1FE].chunks_exact(2) { + sum = sum.wrapping_add(u16::from_be_bytes([w[0], w[1]]) as u32); + } + let cksum = (0x1234u32.wrapping_sub(sum) & 0xFFFF) as u16; + BigEndian::write_u16(&mut out[0x1FE..0x200], cksum); + } + log_cb(" table: AHDI root sector rewritten"); + Ok(PatchedHead { + head: out, + overrides: adjusted, + }) +} + +// --- Sharp X68000 --------------------------------------------------------------- + +fn patch_x68k( + head: &[u8], + overrides: &[PartitionSizeOverride], + target_size: u64, + log_cb: &mut dyn FnMut(&str), +) -> Result { + use super::x68k::{ + X68kPartitionTable, X68K_ENTRY_SIZE, X68K_MAX_PARTITIONS, X68K_TABLE_HEADER_SIZE, + }; + // The same probe `PartitionTable::detect` runs: the table's offset decides + // the sector size when the boot signature does not. + let (_, table_off, ss) = + X68kPartitionTable::detect_with_geometry(&mut Cursor::new(head.to_vec())) + .context("probe the X68k table in the head region")? + .ok_or_else(|| anyhow::anyhow!("no X68k table in the head region"))?; + let table_off = table_off as usize; + let table_len = X68K_TABLE_HEADER_SIZE + X68K_MAX_PARTITIONS * X68K_ENTRY_SIZE; + need(head, table_off + table_len, "the X68k table")?; + let floor = overrides + .iter() + .map(|o| o.start_lba * 512) + .min() + .unwrap_or(0); + // Bodies sit on 512-byte LBAs, so a 256-byte SASI disk rounds to two sectors. + let adjusted = repack(overrides, ss.max(512), floor)?; + check_fits(&adjusted, target_size, "the target")?; + let mut out = head.to_vec(); + let block = &mut out[table_off..table_off + table_len]; + for o in &adjusted { + // Slots are matched by where the partition starts; the high byte of the + // start word carries flags and is kept. + let slot = (0..X68K_MAX_PARTITIONS).find(|&i| { + let e = X68K_TABLE_HEADER_SIZE + i * X68K_ENTRY_SIZE; + let start = (BigEndian::read_u32(&block[e + 8..e + 12]) & 0x00FF_FFFF) as u64; + let len = BigEndian::read_u32(&block[e + 12..e + 16]); + len != 0 && start * ss == o.start_lba * 512 + }); + let Some(slot) = slot else { + bail!( + "no X68k slot starts at byte {} for partition {}", + o.start_lba * 512, + o.index + ); + }; + let e = X68K_TABLE_HEADER_SIZE + slot * X68K_ENTRY_SIZE; + let flags = BigEndian::read_u32(&block[e + 8..e + 12]) & 0xFF00_0000; + let new_start = (o.effective_start_lba() * 512 / ss) as u32 & 0x00FF_FFFF; + BigEndian::write_u32(&mut block[e + 8..e + 12], flags | new_start); + BigEndian::write_u32(&mut block[e + 12..e + 16], (o.export_size / ss) as u32); + } + if BigEndian::read_u32(&block[4..8]) != 0 { + let sectors = (target_size / ss) as u32; + BigEndian::write_u32(&mut block[4..8], sectors); + BigEndian::write_u32(&mut block[8..12], sectors); + } + log_cb(&format!( + " table: X68k table rewritten ({ss}-byte sectors)" + )); + Ok(PatchedHead { + head: out, + overrides: adjusted, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::partition::provision::{self, Geometry, PartSpec}; + use crate::partition::type_catalog::TableKind; + use crate::partition::PartitionTable; + use std::io::{Read, Seek, SeekFrom}; + + const DISK: u64 = 64 * 1024 * 1024; + + /// A two-partition disk of `kind` from the provisioner, as raw bytes. + fn disk_of(kind: TableKind) -> Vec { + let geometry = Geometry::default(); + let align = provision::default_align(kind, geometry); + let specs = vec![ + PartSpec { + size: Some(16 * 1024 * 1024), + ..Default::default() + }, + PartSpec { + size: None, + ..Default::default() + }, + ]; + let placed = provision::place(&specs, kind, DISK, align, geometry) + .unwrap_or_else(|e| panic!("{} place: {e:#}", kind.label())); + let mut file = tempfile::tempfile().unwrap(); + file.set_len(DISK).unwrap(); + provision::write_table(&mut file, kind, &placed, DISK, geometry) + .unwrap_or_else(|e| panic!("{} write: {e:#}", kind.label())); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).unwrap(); + bytes + } + + /// Shrink the first partition to 4 MiB, slide the second down after it. + fn shrink_first(parts: &[crate::partition::PartitionInfo]) -> Vec { + let a = &parts[0]; + let b = &parts[1]; + let new_a = 4 * 1024 * 1024; + vec![ + PartitionSizeOverride { + index: a.index, + start_lba: a.start_lba, + original_size: a.size_bytes, + export_size: new_a, + new_start_lba: None, + heads: 0, + sectors_per_track: 0, + }, + PartitionSizeOverride { + index: b.index, + start_lba: b.start_lba, + original_size: b.size_bytes, + export_size: b.size_bytes, + new_start_lba: Some(a.start_lba + new_a / 512), + heads: 0, + sectors_per_track: 0, + }, + ] + } + + fn head_len(parts: &[crate::partition::PartitionInfo]) -> usize { + parts + .iter() + .map(|p| p.byte_offset()) + .min() + .unwrap_or(512) + .max(512) as usize + } + + /// Every scheme: patch, re-detect, and the label describes the new layout. + #[test] + fn every_patchable_scheme_reparses_to_the_new_layout() { + for kind in [ + TableKind::Sun, + TableKind::Next, + TableKind::Sgi, + TableKind::SgiDkLabel, + TableKind::Rdb, + TableKind::Atari, + TableKind::X68k, + ] { + let bytes = disk_of(kind); + let table = PartitionTable::detect(&mut Cursor::new(bytes.clone())).unwrap(); + let name = table.type_name(); + assert!(can_patch(name), "{name}"); + let parts = table.partitions(); + assert_eq!(parts.len(), 2, "{name}"); + let overrides = shrink_first(&parts); + let head = &bytes[..head_len(&parts)]; + let mut log = Vec::new(); + let patched = patch_head_for_restore(name, head, &overrides, DISK, &mut |m| { + log.push(m.to_string()) + }) + .unwrap_or_else(|e| panic!("{name}: {e:#}")); + assert_eq!(patched.head.len(), head.len(), "{name}: head length"); + + let mut disk = bytes.clone(); + disk[..patched.head.len()].copy_from_slice(&patched.head); + let again = PartitionTable::detect(&mut Cursor::new(disk)).unwrap(); + let after = again.partitions(); + assert_eq!(after.len(), 2, "{name}: partition count after patch"); + for (o, p) in patched.overrides.iter().zip(after.iter()) { + assert_eq!(p.index, o.index, "{name}: slot"); + assert_eq!(p.start_lba, o.effective_start_lba(), "{name}: start"); + assert_eq!(p.size_bytes, o.export_size, "{name}: size"); + } + assert!( + after[0].size_bytes < parts[0].size_bytes, + "{name}: the first partition must have shrunk" + ); + assert!( + after[1].start_lba < parts[1].start_lba, + "{name}: the second must have moved down" + ); + assert!(!log.is_empty(), "{name}: the patcher must say what it did"); + } + } + + /// Bytes outside the entries are untouched: boot code, driver chains, the IPL. + #[test] + fn a_patch_leaves_the_rest_of_the_head_verbatim() { + for kind in [ + TableKind::Sgi, + TableKind::Atari, + TableKind::X68k, + TableKind::Rdb, + ] { + let bytes = disk_of(kind); + let table = PartitionTable::detect(&mut Cursor::new(bytes.clone())).unwrap(); + let name = table.type_name(); + let parts = table.partitions(); + let head = &bytes[..head_len(&parts)]; + let patched = + patch_head_for_restore(name, head, &shrink_first(&parts), DISK, &mut |_| {}) + .unwrap(); + let changed = head + .iter() + .zip(patched.head.iter()) + .filter(|(a, b)| a != b) + .count(); + assert!( + changed > 0 && changed <= 4 * 512, + "{name}: {changed} bytes changed" + ); + } + } + + #[test] + fn a_layout_past_the_target_is_refused() { + let bytes = disk_of(TableKind::Sgi); + let table = PartitionTable::detect(&mut Cursor::new(bytes.clone())).unwrap(); + let parts = table.partitions(); + let mut overrides = shrink_first(&parts); + overrides[1].export_size = DISK; + let err = patch_head_for_restore("SGI", &bytes[..512], &overrides, DISK, &mut |_| {}) + .unwrap_err(); + assert!(err.to_string().contains("past"), "{err:#}"); + } + + #[test] + fn a_slot_that_moved_since_backup_is_refused() { + let bytes = disk_of(TableKind::Atari); + let table = PartitionTable::detect(&mut Cursor::new(bytes.clone())).unwrap(); + let parts = table.partitions(); + let mut overrides = shrink_first(&parts); + overrides[0].start_lba += 1; + let err = patch_head_for_restore("AHDI", &bytes[..512], &overrides, DISK, &mut |_| {}) + .unwrap_err(); + assert!(err.to_string().contains("starts at"), "{err:#}"); + } + + #[test] + fn repack_rounds_up_and_never_overlaps() { + let overrides = vec![ + PartitionSizeOverride::size_only(0, 100, 1000 * 512, 1001 * 512), + PartitionSizeOverride::size_only(1, 2000, 500 * 512, 500 * 512), + ]; + let out = repack(&overrides, 100 * 512, 0).unwrap(); + assert_eq!(out[0].export_size, 1100 * 512); + assert_eq!(out[0].new_start_lba, None); + assert_eq!(out[1].effective_start_lba(), 2000); + let out = repack(&overrides, 100 * 512, 150 * 512).unwrap(); + assert_eq!(out[0].effective_start_lba(), 200); + } +} diff --git a/src/partition/sun.rs b/src/partition/sun.rs index 3306866d..c0fc4e3a 100644 --- a/src/partition/sun.rs +++ b/src/partition/sun.rs @@ -218,13 +218,52 @@ impl SunDiskLabel { } /// Non-empty slices in slice order, skipping the whole-disk "backup" - /// alias (tag 5), which overlaps the real slices. + /// alias, which overlaps the real slices. pub fn browsable_slices(&self) -> impl Iterator { + let tagged = self.has_tags(); + let data_sectors = self.data_sectors(); self.slices .iter() .enumerate() - .filter(|(_, s)| !s.is_empty() && !s.is_whole_disk()) + .filter(move |(_, s)| !s.is_empty() && !whole_disk(s, tagged, data_sectors)) } + + /// True when any slice carries a VTOC tag. SunOS 4.x wrote no VTOC at all, + /// so its labels come back all-zero and the tag cannot name anything. + pub fn has_tags(&self) -> bool { + self.slices.iter().any(|s| s.tag != 0) + } + + /// Sectors the label's own geometry says the data area holds. + pub fn data_sectors(&self) -> u64 { + self.ncyl as u64 * self.sectors_per_cylinder + } + + /// Whether slice `i` is the whole-disk "backup" alias. + pub fn is_whole_disk_slice(&self, i: usize) -> bool { + self.slices + .get(i) + .is_some_and(|s| whole_disk(s, self.has_tags(), self.data_sectors())) + } + + /// Type name for slice `i`. An untagged label has no VTOC to read, so + /// naming its slices "unassigned" would claim a tag we never saw. + pub fn slice_type_name(&self, i: usize) -> &'static str { + if self.has_tags() { + self.slices.get(i).map_or("slice", |s| s.tag_name()) + } else { + "untagged" + } + } +} + +/// Tag 5 is the alias when the label has a VTOC. Without one the geometry is +/// the only evidence: a slice from cylinder 0 spanning the whole data area. +fn whole_disk(s: &SunSlice, tagged: bool, data_sectors: u64) -> bool { + if tagged { + return s.is_whole_disk(); + } + s.start_cylinder == 0 && data_sectors > 0 && s.num_sectors as u64 >= data_sectors } /// The label checksum is a 16-bit XOR of all 256 big-endian words in the @@ -249,6 +288,54 @@ mod tests { /// Image bytes + `fdisk -l`'s `(start_sector, num_sectors)` per slice. type SunFixture = (Vec, Vec<(u64, u64)>); + /// A SunOS 4.1.3 label: `sanity`, `version` and `nparts` are all zero, so + /// every tag reads 0 and the backup alias has to be found by geometry. + fn untagged_label(ncyl: u16, ntrks: u16, nsect: u16, slices: &[(u32, u32)]) -> SunDiskLabel { + let mut buf = vec![0u8; LABEL_SIZE]; + BigEndian::write_u16(&mut buf[432..434], ncyl); + BigEndian::write_u16(&mut buf[436..438], ntrks); + BigEndian::write_u16(&mut buf[438..440], nsect); + for (i, (cyl, sectors)) in slices.iter().enumerate() { + let base = 444 + i * 8; + BigEndian::write_u32(&mut buf[base..base + 4], *cyl); + BigEndian::write_u32(&mut buf[base + 4..base + 8], *sectors); + } + BigEndian::write_u16(&mut buf[508..510], SUN_LABEL_MAGIC); + let mut csum: u16 = 0; + for w in buf.chunks_exact(2) { + csum ^= BigEndian::read_u16(w); + } + BigEndian::write_u16(&mut buf[510..512], csum); + SunDiskLabel::parse(&buf).expect("untagged label parses") + } + + /// The `sunos.chd` fixture's geometry: 2733 cylinders, 19 heads, 80 + /// sectors, slice 2 covering all 4154160 of them. + #[test] + fn an_untagged_backup_slice_is_found_by_geometry() { + let label = untagged_label( + 2733, + 19, + 80, + &[(0, 62320), (41, 197600), (0, 4154160), (171, 1174960)], + ); + assert!(!label.has_tags(), "SunOS 4.x writes no VTOC"); + assert!(label.is_whole_disk_slice(2)); + assert!( + !label.is_whole_disk_slice(0), + "slice 0 starts at 0 but is small" + ); + let browsable: Vec = label.browsable_slices().map(|(i, _)| i).collect(); + assert_eq!(browsable, vec![0, 1, 3], "the backup alias is excluded"); + } + + /// Without a VTOC there is no tag to read, so the slices must not claim one. + #[test] + fn untagged_slices_are_not_called_unassigned() { + let label = untagged_label(100, 2, 32, &[(0, 640), (10, 1024)]); + assert_eq!(label.slice_type_name(0), "untagged"); + } + /// Build a Sun-labeled disk image with `sfdisk` (non-sudo, on a file) with /// a known slice layout, and return its bytes + the parsed `fdisk -l` /// (start_sector, num_sectors) per line for cross-checking. diff --git a/src/rbformats/README.md b/src/rbformats/README.md index 35ec0f1f..1996c542 100644 --- a/src/rbformats/README.md +++ b/src/rbformats/README.md @@ -101,6 +101,32 @@ through the normal partition / filesystem detection. clear per-codec error — they await real sample images that use them (KenCode is undocumented and needs reverse-engineering). +- **`next_mo.rs` — NeXT magneto-optical media (`.od`).** The 256 MB Canon MO + cartridge of the NeXT Computer, as Previous stores it: the **raw media**, not + the drive's user data. Each 1296-byte physical sector is a 36 x 36 byte array + — rows 0..31 carry 32 data bytes each in columns 0..31, columns 32..35 hold + each row's cross-interleaved Reed-Solomon(36,32) code and rows 32..35 hold + each column's — so the user data is `raw[i*36 .. i*36+32]` for `i` in 0..32. + Two further offsets separate the file from a NeXT disk label: + + - **Origin.** The image begins at the drive's track 4096; the kernel's + logical block 0 is at track 4149, 848 sectors in. Everything before that is + erased media (`0xFF`). + - **Alternate groups.** The `od` *driver*, not the drive, does the bad-block + sparing, from the label's own `d_ag_size` / `d_ag_alts` / `d_ag_off`: the + data area past the front porch is cut into `d_ag_size`-sector groups and + the `d_ag_alts` sectors at `d_ag_off` into each are spares that logical + addressing skips. This one is a trap — read the image linearly and the + label, the primary superblock and the root directory are all still correct, + so the volume *lists*; only the cylinder groups past the first drift by 8 + sectors, and `fsck` reports tens of thousands of orphan inodes. + + Writing regenerates both ECC passes; the encoder is verified byte-for-byte + against real NeXTSTEP 0.8 media (473 of 473 ECC-bearing sectors in a random + sample; the rest were erased). Media written with the controller's ECC + disabled carries `0xFF` in the ECC bytes and is read back unchanged, since + reads never consult the code. + ## How to Add a New Output Format 1. Create `src/rbformats/myformat.rs` with a compression function: diff --git a/src/rbformats/export.rs b/src/rbformats/export.rs index 7994f77f..6836f3cf 100644 --- a/src/rbformats/export.rs +++ b/src/rbformats/export.rs @@ -831,6 +831,18 @@ pub fn export_whole_disk( None }; + // The other disk labels (Sun, NeXT, SGI, AHDI, X68k): the head is + // rewritten in place and the bodies copied to match. + let label = if format == ExportFormat::Raw + && apm.is_none() + && rdb.is_none() + && !partition_sizes.is_empty() + { + super::detect_raw_label(&mut reader) + } else { + None + }; + if apm.is_some() { // Open destination with Read+Write+Seek for the APM reconstruction. let mut file = std::fs::OpenOptions::new() @@ -890,6 +902,30 @@ pub fn export_whole_disk( dest_path.display(), total_written, )); + } else if let Some(kind) = label { + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dest_path) + .with_context(|| format!("failed to create {}", dest_path.display()))?; + total_written = super::reconstruct_raw_label_disk( + &mut reader, + source_data_size, + &mut file, + partition_sizes, + &mut progress_cb, + &cancel_check, + &mut log_cb, + )?; + file.flush()?; + log_cb(&format!( + "{} export complete: {} ({} data bytes, {kind} label rewritten)", + format.description(), + dest_path.display(), + total_written, + )); } else { // No APM override or non-APM source: stream bytes. let mut writer = BufWriter::new( diff --git a/src/rbformats/mod.rs b/src/rbformats/mod.rs index e17e282d..2b6324f8 100644 --- a/src/rbformats/mod.rs +++ b/src/rbformats/mod.rs @@ -185,6 +185,7 @@ pub mod lz4; pub mod lzhuf; pub mod moof; pub mod ndif; +pub mod next_mo; pub mod payload_slice; pub mod qcow2; #[cfg(test)] @@ -334,19 +335,17 @@ pub fn reconstruct_disk_from_backup( let is_x68k = metadata.partition_table_type == "X68k"; if is_rdb { - // Backup-folder restore for RDB-based Amiga disks is not yet wired - // up: backups save a parsed `rdb.json` sidecar but not the raw - // 512-byte RDSK/PART blocks, and re-serializing the parsed form - // without losing FSHD/LSEG driver chains and BADB lists needs a - // full RDB encoder we haven't written yet. For resize-aware - // export of an Amiga disk today, point the user at the - // direct-from-source path (Inspect tab -> Export raw/VHD), which - // routes through `reconstruct_raw_rdb_disk` and works end-to-end. + // Only a per-partition RDB backup reaches here, and one cannot be made + // any more: re-serializing `rdb.json` would lose the FSHD/LSEG driver + // chain and the BADB list, which needs an RDB encoder we have not + // written. A single-file-CHD backup keeps those blocks verbatim and + // restores through its own path, never this one. bail!( - "RDB-based Amiga disks cannot yet be restored from a backup folder. \ - To resize an Amiga disk, export directly from the source image \ - (Inspect tab -> Export to Raw or VHD with size overrides) — that \ - path is RDB-aware and patches the partition table for you." + "this RDB-based Amiga backup is a per-partition one and cannot be \ + restored: re-take it with CHD output, which keeps the RDSK/PART/FSHD \ + blocks verbatim. To resize an Amiga disk, export directly from the \ + source image (Inspect tab -> Export to Raw or VHD with size \ + overrides) — that path is RDB-aware and patches the table for you." ); } else if is_superfloppy { // Superfloppy: no partition table to write — data starts at offset 0 @@ -1424,6 +1423,9 @@ pub enum ImageFormat { /// MAME CD CHD — single-track MODE1, browsed via the cooked 2048-byte adapter. /// `logical_size` is `frames * 2048`. ChdCdCooked { path: PathBuf, logical_size: u64 }, + /// NeXT magneto-optical media (`.od`) — 1296-byte ECC-coded sectors with a + /// spare-sector map. See [`next_mo`] for both. + NextMo(next_mo::MoGeometry), } impl ImageFormat { @@ -1500,6 +1502,10 @@ impl ImageFormat { ImageFormat::ChdCdCooked { logical_size, .. } => { format!("MAME CD CHD ({} bytes cooked MODE1)", logical_size) } + ImageFormat::NextMo(geo) => format!( + "NeXT magneto-optical media ({} bytes decoded)", + geo.logical_sectors * next_mo::DATA_SECTOR as u64 + ), } } } @@ -1557,6 +1563,16 @@ pub fn detect_image_format_with_path(file: File, path: Option<&Path>) -> Result< } } + // 0a. NeXT magneto-optical media: 1296-byte ECC-coded sectors. The size + // test is the cheap gate — 1296 = 16 * 81, so an ordinary power-of-two + // image never divides by it — and a validating NeXT label in the + // decoded stream is the confirmation. + if file_size % next_mo::RAW_SECTOR as u64 == 0 { + if let Some(geo) = next_mo::detect(&mut file) { + return Ok(ImageFormat::NextMo(geo)); + } + } + // 1. Check first 8 bytes for WOZ magic (WOZ1/WOZ2 + high-bit check). // WOZ has a very strong 8-byte signature, so check it first. if file_size >= 12 { @@ -1998,6 +2014,11 @@ pub fn wrap_image_reader(file: File, format: ImageFormat) -> Result<(BoxReadSeek let reader = chd::CdCookedReader::open_path(&path)?; Ok((Box::new(reader), logical_size)) } + ImageFormat::NextMo(geo) => { + let reader = next_mo::NextMoReader::new(BufReader::new(file), geo); + let size = reader.len(); + Ok((Box::new(reader), size)) + } } } @@ -2055,6 +2076,155 @@ impl Seek for SectionReader { } } +/// The type name of a disk label `reconstruct_raw_label_disk` can rewrite: +/// Sun, NeXT, both SGI schemes, AHDI and X68k. RDB and APM have their own paths. +pub fn detect_raw_label(reader: &mut (impl Read + Seek)) -> Option<&'static str> { + let _ = reader.seek(SeekFrom::Start(0)); + let table = crate::partition::PartitionTable::detect(reader).ok()?; + let _ = reader.seek(SeekFrom::Start(0)); + let name = table.type_name(); + (crate::partition::restore_patch::can_patch(name) && name != "RDB").then_some(name) +} + +/// Export a disk-label image with size overrides: the head region is rewritten +/// by `partition::restore_patch` and the bodies land where the label now says. +/// The image keeps the source's size. Returns the bytes written. +pub fn reconstruct_raw_label_disk( + reader: &mut (impl Read + Seek), + source_data_size: u64, + writer: &mut (impl Read + Write + Seek), + partition_sizes: &[PartitionSizeOverride], + progress_cb: &mut impl FnMut(u64), + cancel_check: &impl Fn() -> bool, + log_cb: &mut impl FnMut(&str), +) -> Result { + use crate::fs::{in_place_resize_support, resize_filesystem_for, InPlaceResize}; + use crate::partition::{restore_patch, PartitionTable}; + + reader.seek(SeekFrom::Start(0))?; + let table = PartitionTable::detect(reader).context("parse the partition table")?; + let name = table.type_name(); + if !restore_patch::can_patch(name) || name == "RDB" { + bail!("{name} disks have no raw-export reconstruction"); + } + let parts = table.partitions(); + // Every partition takes part in the repack, override or not. + let mut overrides: Vec = Vec::with_capacity(parts.len()); + for p in &parts { + match partition_sizes.iter().find(|o| o.index == p.index) { + Some(o) => overrides.push(o.clone()), + None => overrides.push(PartitionSizeOverride::size_only( + p.index, + p.start_lba, + p.size_bytes, + p.size_bytes, + )), + } + } + for o in &overrides { + let Some(p) = parts.iter().find(|p| p.index == o.index) else { + continue; + }; + if o.export_size == p.size_bytes { + continue; + } + if let InPlaceResize::Unsupported(fs) = + in_place_resize_support(reader, p.byte_offset(), p.partition_type_string.as_deref()) + { + bail!( + "partition-{} holds {fs}, which cannot be resized in place", + p.index + ); + } + } + let head_len = parts + .iter() + .map(|p| p.byte_offset()) + .min() + .unwrap_or(512) + .max(512) + .min(source_data_size); + let mut head = vec![0u8; head_len as usize]; + reader.seek(SeekFrom::Start(0))?; + reader + .read_exact(&mut head) + .context("read the head region")?; + let patched = + restore_patch::patch_head_for_restore(name, &head, &overrides, source_data_size, log_cb)?; + + let mut buf = vec![0u8; CHUNK_SIZE]; + let mut written: u64 = 0; + for o in &patched.overrides { + if cancel_check() { + bail!("export cancelled"); + } + let Some(p) = parts.iter().find(|p| p.index == o.index) else { + continue; + }; + let dst = o.effective_start_lba() * 512; + let to_copy = p.size_bytes.min(o.export_size); + reader.seek(SeekFrom::Start(p.byte_offset()))?; + writer.seek(SeekFrom::Start(dst))?; + let mut left = to_copy; + while left > 0 { + if cancel_check() { + bail!("export cancelled"); + } + let want = (left as usize).min(buf.len()); + let n = reader + .read(&mut buf[..want]) + .context("read partition body")?; + if n == 0 { + break; + } + writer + .write_all(&buf[..n]) + .context("write partition body")?; + left -= n as u64; + written += n as u64; + progress_cb(written); + } + // A grown partition needs clean space for the resizer's tail structures. + let mut pad = o.export_size.saturating_sub(to_copy); + let zeros = vec![0u8; CHUNK_SIZE]; + while pad > 0 { + let want = (pad as usize).min(zeros.len()); + writer + .write_all(&zeros[..want]) + .context("zero-fill grow region")?; + pad -= want as u64; + } + } + // The head lands after the bodies, so a label inside slice 0 still lands. + writer.seek(SeekFrom::Start(0))?; + writer + .write_all(&patched.head) + .context("write the rewritten head")?; + if source_data_size > 0 { + writer.seek(SeekFrom::Start(source_data_size - 1))?; + writer.write_all(&[0u8]).context("size the image")?; + } + writer.flush()?; + for o in &patched.overrides { + let Some(p) = parts.iter().find(|p| p.index == o.index) else { + continue; + }; + let off = o.effective_start_lba() * 512; + if o.export_size != p.size_bytes { + resize_filesystem_for(writer, off, o.export_size, log_cb) + .with_context(|| format!("resize partition-{} filesystem", p.index))?; + } + patch_hidden_sectors_for(writer, off, o.effective_start_lba(), log_cb) + .with_context(|| format!("patch hidden sectors for partition-{}", p.index))?; + } + writer.flush()?; + log_cb(&format!( + "{name} label rewritten for {} partition(s); image kept at {source_data_size} bytes", + patched.overrides.len() + )); + Ok(source_data_size) +} + #[cfg(test)] mod tests { use super::*; @@ -2349,3 +2519,90 @@ mod mbr_gap_restore_tests { ); } } + +#[cfg(test)] +mod label_export_tests { + use super::{detect_raw_label, reconstruct_raw_label_disk}; + use crate::partition::provision::{self, Geometry, PartSpec}; + use crate::partition::type_catalog::TableKind; + use crate::partition::{PartitionSizeOverride, PartitionTable}; + use std::io::{Cursor, Read, Seek, SeekFrom}; + + const DISK: u64 = 64 * 1024 * 1024; + + fn disk_of(kind: TableKind) -> Vec { + let geometry = Geometry::default(); + let align = provision::default_align(kind, geometry); + let specs = vec![ + PartSpec { + size: Some(16 * 1024 * 1024), + ..Default::default() + }, + PartSpec { + size: None, + ..Default::default() + }, + ]; + let placed = provision::place(&specs, kind, DISK, align, geometry).unwrap(); + let mut file = tempfile::tempfile().unwrap(); + file.set_len(DISK).unwrap(); + provision::write_table(&mut file, kind, &placed, DISK, geometry).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).unwrap(); + bytes + } + + /// A raw export with a size override rewrites the label and keeps the size. + #[test] + fn raw_export_rewrites_a_label_and_keeps_the_drive_size() { + for kind in [TableKind::Atari, TableKind::Sun, TableKind::X68k] { + let src = disk_of(kind); + let mut reader = Cursor::new(src.clone()); + let name = detect_raw_label(&mut reader).expect("a patchable label"); + let parts = PartitionTable::detect(&mut reader).unwrap().partitions(); + let overrides = vec![PartitionSizeOverride::size_only( + parts[0].index, + parts[0].start_lba, + parts[0].size_bytes, + 4 * 1024 * 1024, + )]; + let mut out = Cursor::new(Vec::new()); + let written = reconstruct_raw_label_disk( + &mut reader, + DISK, + &mut out, + &overrides, + &mut |_| {}, + &|| false, + &mut |_| {}, + ) + .unwrap_or_else(|e| panic!("{name}: {e:#}")); + assert_eq!(written, DISK, "{name}"); + let image = out.into_inner(); + assert_eq!(image.len() as u64, DISK, "{name}: image length"); + let after = PartitionTable::detect(&mut Cursor::new(image)) + .unwrap() + .partitions(); + assert_eq!(after.len(), 2, "{name}"); + assert!(after[0].size_bytes < parts[0].size_bytes, "{name}: shrunk"); + // A raw export keeps an untouched partition where it was, like the MBR path. + assert_eq!(after[1].start_lba, parts[1].start_lba, "{name}: untouched"); + assert_eq!( + after[1].size_bytes, parts[1].size_bytes, + "{name}: untouched" + ); + } + } + + #[test] + fn an_mbr_disk_is_not_a_label() { + let mut mbr = vec![0u8; 4 * 1024 * 1024]; + mbr[510] = 0x55; + mbr[511] = 0xAA; + mbr[450] = 0x83; + mbr[454..458].copy_from_slice(&1u32.to_le_bytes()); + mbr[458..462].copy_from_slice(&4095u32.to_le_bytes()); + assert!(detect_raw_label(&mut Cursor::new(mbr)).is_none()); + } +} diff --git a/src/rbformats/next_mo.rs b/src/rbformats/next_mo.rs new file mode 100644 index 00000000..f70b2a6e --- /dev/null +++ b/src/rbformats/next_mo.rs @@ -0,0 +1,596 @@ +//! NeXT magneto-optical disk image (`.od`) — the 256 MB Canon MO cartridge the +//! NeXT Computer shipped with, as Previous and other emulators store it. +//! +//! Unlike every other image this crate reads, an `.od` is the **raw media**, +//! not the drive's user data: each physical sector is 1296 bytes holding 1024 +//! bytes of data plus a cross-interleaved Reed-Solomon(36,32) code. The sector +//! is a 36 x 36 byte array — rows 0..31 carry 32 data bytes each in columns +//! 0..31, columns 32..35 hold each row's ECC, and rows 32..35 hold each +//! column's. So the user data is `raw[i*36 .. i*36+32]` for `i` in 0..32, and +//! writing one back means recomputing both ECC passes. +//! +//! Two more things stand between the file and a NeXT disk label: +//! +//! - **Origin.** The image starts at the drive's track 4096, but the kernel's +//! logical block 0 is at track 4149 — 848 sectors in. Everything before that +//! is erased media (`0xFF`). +//! - **Alternate groups.** The `od` driver, not the drive, does the bad-block +//! sparing, using the label's own `d_ag_size` / `d_ag_alts` / `d_ag_off`: +//! the data area (past the front porch) is cut into `d_ag_size`-sector +//! groups, and the `d_ag_alts` sectors at `d_ag_off` into each group are +//! spares that logical addressing skips. Read the image linearly and every +//! cylinder group past the first drifts 8 sectors — the directory tree still +//! lists, which is what makes this worth spelling out. +//! +//! [`NextMoReader`] presents the decoded, de-spared logical disk, so the NeXT +//! label parser and the big-endian UFS reader see an ordinary image. + +use std::io::{self, Read, Seek, SeekFrom, Write}; + +use crate::partition::next; + +/// Bytes one sector occupies on the media: 1024 data + 272 ECC. +pub const RAW_SECTOR: usize = 1296; +/// Bytes of user data one sector carries. +pub const DATA_SECTOR: usize = 1024; +/// Side of the square the ECC is computed over. +const ROW: usize = 36; +/// Rows that carry data; the last four are the column ECC. +const DATA_ROWS: usize = 32; + +/// Sectors between the start of the image and the kernel's logical block 0. +/// Previous stores the media from track 4096; the driver's first sector is at +/// track 4149, 16 sectors per track. +pub const DEFAULT_ORIGIN_SECTOR: u64 = (4149 - 4096) * 16; + +/// How far into the image [`detect`] looks for the block-0 label copy. +const ORIGIN_SCAN_SECTORS: u64 = 4096; + +/// Physical sectors read in one go when serving a logical run. +const READ_RUN_SECTORS: usize = 256; + +// --- Reed-Solomon(36,32) over GF(2**8), generator (x-1)(x-2)(x-4)(x-8) ------ + +/// GF(2**8) modulus NeXT's code uses. +const GF_POLY: u16 = 0x11d; + +/// Coefficients of x^3..x^0 in the generator polynomial, so `x^4 == GEN(x)`. +const GEN: [u8; 4] = [0x0f, 0x36, 0x78, 0x40]; + +const fn gf_mul(a: u8, b: u8) -> u8 { + let mut acc: u16 = 0; + let mut x = a as u16; + let mut y = b as u16; + while y != 0 { + if y & 1 != 0 { + acc ^= x; + } + y >>= 1; + x <<= 1; + if x & 0x100 != 0 { + x ^= GF_POLY; + } + } + acc as u8 +} + +/// `T_REM[b]` is `b * x^4 mod g(x)`, its four GF coefficients packed big-endian. +const fn build_t_rem() -> [u32; 256] { + let mut t = [0u32; 256]; + let mut b = 0usize; + while b < 256 { + t[b] = ((gf_mul(b as u8, GEN[0]) as u32) << 24) + | ((gf_mul(b as u8, GEN[1]) as u32) << 16) + | ((gf_mul(b as u8, GEN[2]) as u32) << 8) + | (gf_mul(b as u8, GEN[3]) as u32); + b += 1; + } + t +} + +const T_REM: [u32; 256] = build_t_rem(); + +/// Remainder of the 32 code bytes at `off`, `step` apart — the four ECC bytes. +fn ecc_block(sector: &[u8], off: usize, step: usize) -> u32 { + let mut r = ((sector[off] as u32) << 24) + | ((sector[off + step] as u32) << 16) + | ((sector[off + 2 * step] as u32) << 8) + | (sector[off + 3 * step] as u32); + let mut p = off + 4 * step; + for i in 4..ROW { + r = T_REM[(r >> 24) as usize] ^ (r << 8); + if i < DATA_ROWS { + r ^= sector[p] as u32; + p += step; + } + } + r +} + +fn encode_string(sector: &mut [u8], off: usize, step: usize) { + let ecc = ecc_block(sector, off, step); + sector[off + 32 * step] = (ecc >> 24) as u8; + sector[off + 33 * step] = (ecc >> 16) as u8; + sector[off + 34 * step] = (ecc >> 8) as u8; + sector[off + 35 * step] = ecc as u8; +} + +/// Lift the 1024 user bytes out of one raw sector. +pub fn decode_sector(raw: &[u8], out: &mut [u8]) { + for i in 0..DATA_ROWS { + out[i * DATA_SECTOR / DATA_ROWS..(i + 1) * DATA_SECTOR / DATA_ROWS] + .copy_from_slice(&raw[i * ROW..i * ROW + DATA_ROWS]); + } +} + +/// Lay 1024 user bytes into a raw sector and stamp both ECC passes. +pub fn encode_sector(data: &[u8], raw: &mut [u8]) { + for b in raw.iter_mut() { + *b = 0; + } + for i in 0..DATA_ROWS { + raw[i * ROW..i * ROW + DATA_ROWS] + .copy_from_slice(&data[i * DATA_ROWS..(i + 1) * DATA_ROWS]); + } + // Columns first: the row pass then covers the column ECC rows too. + for i in 0..DATA_ROWS { + encode_string(raw, i, ROW); + } + for i in 0..ROW { + encode_string(raw, i * ROW, 1); + } +} + +// --- Logical geometry ------------------------------------------------------- + +/// Where the logical disk lives inside the physical media. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MoGeometry { + /// Physical sector holding logical block 0. + pub origin: u64, + /// `d_front` — logical sectors before the data area, mapped one to one. + pub front: u64, + /// `d_ag_size`; zero disables sparing entirely. + pub group_size: u64, + /// `d_ag_alts` — spare sectors per group. + pub group_alts: u64, + /// `d_ag_off` — where in a group the spares sit. + pub group_off: u64, + /// Logical sectors the disk presents. + pub logical_sectors: u64, +} + +impl MoGeometry { + /// Physical sector backing a logical one, skipping every spare. + pub fn physical_sector(&self, logical: u64) -> u64 { + let usable = self.group_size.saturating_sub(self.group_alts); + if self.group_alts == 0 || usable == 0 || logical < self.front { + return self.origin + logical; + } + let d = logical - self.front; + let group = d / usable; + let within = d % usable; + let placed = if within < self.group_off { + within + } else { + within + self.group_alts + }; + self.origin + self.front + group * self.group_size + placed + } + + /// Logical sectors from `logical` that map to consecutive physical ones. + fn contiguous_run(&self, logical: u64) -> u64 { + let usable = self.group_size.saturating_sub(self.group_alts); + if self.group_alts == 0 || usable == 0 { + return self.logical_sectors.saturating_sub(logical); + } + if logical < self.front { + return self.front - logical; + } + let within = (logical - self.front) % usable; + let next_break = if within < self.group_off { + self.group_off - within + } else { + usable - within + }; + next_break.max(1) + } + + fn from_label(label: &next::NextDiskLabel, origin: u64, phys_sectors: u64) -> Self { + let front = label.front_porch as u64; + let group_size = label.group_size as u64; + let group_alts = label.group_alts as u64; + let usable = group_size.saturating_sub(group_alts); + let by_label = if label.group_count == 0 || usable == 0 { + u64::MAX + } else { + front + label.group_count as u64 * usable + }; + let mut geo = MoGeometry { + origin, + front, + group_size, + group_alts, + group_off: label.group_off as u64, + logical_sectors: 0, + }; + // The media is the other bound: a label may describe more groups than + // the file holds, and the last physical sector must stay addressable. + // `physical_sector` is monotonic, so bisect rather than walk back. + let (mut lo, mut hi) = (0u64, phys_sectors.saturating_sub(origin)); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if geo.physical_sector(mid - 1) < phys_sectors { + lo = mid; + } else { + hi = mid - 1; + } + } + geo.logical_sectors = by_label.min(lo); + geo + } +} + +// --- Reader ----------------------------------------------------------------- + +/// Read/write view of the decoded logical disk inside a raw `.od` image. +pub struct NextMoReader { + inner: R, + geo: MoGeometry, + pos: u64, + raw: Vec, + data: Vec, +} + +impl NextMoReader { + /// Wrap `inner` with an already-resolved geometry. + pub fn new(inner: R, geo: MoGeometry) -> Self { + Self { + inner, + geo, + pos: 0, + raw: vec![0u8; READ_RUN_SECTORS * RAW_SECTOR], + data: vec![0u8; READ_RUN_SECTORS * DATA_SECTOR], + } + } + + /// Logical bytes the decoded disk presents. + pub fn len(&self) -> u64 { + self.geo.logical_sectors * DATA_SECTOR as u64 + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn geometry(&self) -> MoGeometry { + self.geo + } + + /// Decode `count` logical sectors from `logical` into `self.data`. + fn fill(&mut self, logical: u64, count: usize) -> io::Result<()> { + let first = self.geo.physical_sector(logical); + self.inner + .seek(SeekFrom::Start(first * RAW_SECTOR as u64))?; + let want = count * RAW_SECTOR; + self.inner.read_exact(&mut self.raw[..want])?; + for i in 0..count { + let (r, d) = (i * RAW_SECTOR, i * DATA_SECTOR); + decode_sector( + &self.raw[r..r + RAW_SECTOR], + &mut self.data[d..d + DATA_SECTOR], + ); + } + Ok(()) + } + + /// How many sectors from `logical` can be served in one physical read. + fn run_len(&self, logical: u64, wanted_bytes: usize) -> usize { + let by_map = self.geo.contiguous_run(logical); + let by_end = self.geo.logical_sectors.saturating_sub(logical); + let by_buf = READ_RUN_SECTORS as u64; + let by_req = (wanted_bytes / DATA_SECTOR + 1) as u64; + by_map.min(by_end).min(by_buf).min(by_req).max(1) as usize + } +} + +impl Read for NextMoReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let end = self.len(); + if self.pos >= end || buf.is_empty() { + return Ok(0); + } + let logical = self.pos / DATA_SECTOR as u64; + let intra = (self.pos % DATA_SECTOR as u64) as usize; + let count = self.run_len(logical, intra + buf.len()); + self.fill(logical, count)?; + let avail = (count * DATA_SECTOR - intra).min((end - self.pos) as usize); + let n = avail.min(buf.len()); + buf[..n].copy_from_slice(&self.data[intra..intra + n]); + self.pos += n as u64; + Ok(n) + } +} + +impl Seek for NextMoReader { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + let end = self.len() as i64; + let target = match pos { + SeekFrom::Start(n) => n as i64, + SeekFrom::End(n) => end + n, + SeekFrom::Current(n) => self.pos as i64 + n, + }; + if target < 0 { + return Err(crate::compat::io_other("seek before start of MO image")); + } + self.pos = target as u64; + Ok(self.pos) + } +} + +impl Write for NextMoReader { + fn write(&mut self, buf: &[u8]) -> io::Result { + let end = self.len(); + if self.pos >= end || buf.is_empty() { + return Ok(0); + } + let logical = self.pos / DATA_SECTOR as u64; + let intra = (self.pos % DATA_SECTOR as u64) as usize; + let n = buf.len().min(DATA_SECTOR - intra); + // Read-modify-write: the ECC covers the whole sector, so a partial + // write still has to re-encode all 1024 bytes. + self.fill(logical, 1)?; + self.data[intra..intra + n].copy_from_slice(&buf[..n]); + let mut raw = vec![0u8; RAW_SECTOR]; + encode_sector(&self.data[..DATA_SECTOR], &mut raw); + let phys = self.geo.physical_sector(logical); + self.inner.seek(SeekFrom::Start(phys * RAW_SECTOR as u64))?; + self.inner.write_all(&raw)?; + self.pos += n as u64; + Ok(n) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +// --- Detection -------------------------------------------------------------- + +/// Resolve the geometry of a raw NeXT MO image, or `None` when `inner` is not +/// one. A validating NeXT label in the decoded stream is the only signature +/// this format has, so detection insists on finding one. +pub fn detect(inner: &mut R) -> Option { + let size = inner.seek(SeekFrom::End(0)).ok()?; + if size == 0 || size % RAW_SECTOR as u64 != 0 { + return None; + } + let phys_sectors = size / RAW_SECTOR as u64; + let span = next::LABEL_SPAN.div_ceil(DATA_SECTOR) as u64; + if phys_sectors < span { + return None; + } + let window = ORIGIN_SCAN_SECTORS.min(phys_sectors - span) + span; + inner.seek(SeekFrom::Start(0)).ok()?; + let mut raw = vec![0u8; window as usize * RAW_SECTOR]; + inner.read_exact(&mut raw).ok()?; + let mut decoded = vec![0u8; window as usize * DATA_SECTOR]; + for i in 0..window as usize { + let (r, d) = (i * RAW_SECTOR, i * DATA_SECTOR); + decode_sector(&raw[r..r + RAW_SECTOR], &mut decoded[d..d + DATA_SECTOR]); + } + // The block-0 copy starts a sector, so scanning sector starts finds it. + // Only the leading erased region stands before it (848 sectors in Previous). + for sector in 0..(window - span + 1) { + let at = sector as usize * DATA_SECTOR; + let copy = &decoded[at..at + next::LABEL_SPAN]; + if !next::validates(copy) { + continue; + } + let label = next::NextDiskLabel::parse(copy, 0).ok()?; + return Some(MoGeometry::from_label(&label, sector, phys_sectors)); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use byteorder::{BigEndian, ByteOrder}; + + /// The four generator coefficients are what Previous's `t_rem` table holds; + /// three entries pin the whole table without transcribing 256 constants. + #[test] + fn t_rem_matches_the_reference_table() { + assert_eq!(T_REM[1], 0x0f36_7840); + assert_eq!(T_REM[2], 0x1e6c_f080); + assert_eq!(T_REM[4], 0x3cd8_fd1d); + assert_eq!(T_REM[0], 0); + } + + #[test] + fn encode_then_decode_round_trips() { + let data: Vec = (0..DATA_SECTOR).map(|i| (i * 7 + 3) as u8).collect(); + let mut raw = vec![0u8; RAW_SECTOR]; + encode_sector(&data, &mut raw); + let mut back = vec![0u8; DATA_SECTOR]; + decode_sector(&raw, &mut back); + assert_eq!(back, data); + } + + /// Re-encoding an unchanged sector must reproduce the ECC bytes byte for + /// byte, or a written-back sector would look corrupt to the drive. + #[test] + fn ecc_bytes_are_reproducible() { + let data: Vec = (0..DATA_SECTOR).map(|i| (i % 251) as u8).collect(); + let mut a = vec![0u8; RAW_SECTOR]; + let mut b = vec![0u8; RAW_SECTOR]; + encode_sector(&data, &mut a); + let mut mid = vec![0u8; DATA_SECTOR]; + decode_sector(&a, &mut mid); + encode_sector(&mid, &mut b); + assert_eq!(a, b); + } + + fn geo(front: u64, size: u64, alts: u64, off: u64, sectors: u64) -> MoGeometry { + MoGeometry { + origin: 848, + front, + group_size: size, + group_alts: alts, + group_off: off, + logical_sectors: sectors, + } + } + + /// The numbers are the `ns0.8.od` fixture's: spares land at image sectors + /// 1888, 3488, 5088 — 1600 apart, 784 into each group. + #[test] + fn sparing_skips_the_alternate_sectors() { + let g = geo(256, 1600, 8, 784, 100_000); + assert_eq!(g.physical_sector(0), 848, "label at the origin"); + assert_eq!(g.physical_sector(256), 1104, "data area follows the porch"); + assert_eq!( + g.physical_sector(1039), + 1887, + "last sector before the spares" + ); + assert_eq!(g.physical_sector(1040), 1896, "eight spares skipped"); + assert_eq!(g.physical_sector(1848), 2704, "second group starts"); + assert_eq!(g.physical_sector(2632), 3496, "second spare run skipped"); + } + + #[test] + fn a_label_with_no_groups_maps_straight_through() { + let g = geo(160, 0, 0, 0, 1000); + assert_eq!(g.physical_sector(0), 848); + assert_eq!(g.physical_sector(999), 1847); + } + + #[test] + fn contiguous_runs_stop_at_the_spares() { + let g = geo(256, 1600, 8, 784, 100_000); + assert_eq!(g.contiguous_run(0), 256); + assert_eq!(g.contiguous_run(256), 784); + assert_eq!(g.contiguous_run(1039), 1); + assert_eq!(g.contiguous_run(1040), 808); + } + + /// A round trip through the reader proves decode, sparing and the run + /// batching agree with the writer. + #[test] + fn reader_round_trips_through_the_sparing_map() { + let g = geo(4, 16, 2, 8, 40); + let phys = 848 + 64; + let mut media = vec![0u8; phys as usize * RAW_SECTOR]; + for logical in 0..g.logical_sectors { + let data: Vec = (0..DATA_SECTOR) + .map(|i| (logical as u8) ^ (i as u8)) + .collect(); + let p = g.physical_sector(logical) as usize; + encode_sector(&data, &mut media[p * RAW_SECTOR..(p + 1) * RAW_SECTOR]); + } + let mut r = NextMoReader::new(std::io::Cursor::new(media), g); + let mut got = Vec::new(); + r.read_to_end(&mut got).unwrap(); + assert_eq!(got.len(), g.logical_sectors as usize * DATA_SECTOR); + for logical in 0..g.logical_sectors as usize { + let want: Vec = (0..DATA_SECTOR) + .map(|i| (logical as u8) ^ (i as u8)) + .collect(); + assert_eq!( + &got[logical * DATA_SECTOR..(logical + 1) * DATA_SECTOR], + &want[..] + ); + } + } + + /// Build a synthetic MO image — erased lead-in, then an ECC-coded NeXT + /// label at the origin — and check `detect` reads the geometry back off it. + fn synthetic_mo(origin: u64, front: u16, groups: (u16, u16, u16)) -> Vec { + use crate::partition::next::{build_label, NextLabelSpec, NextPartitionSpec}; + let (size, alts, off) = groups; + let mut spec = NextLabelSpec { + front_porch: front, + ..Default::default() + }; + spec.partitions = vec![ + Some(NextPartitionSpec { + base: 0, + size: 512, + ..Default::default() + }), + None, + None, + None, + None, + None, + None, + None, + ]; + let mut label = build_label(&spec); + BigEndian::write_u16(&mut label[0x74..0x76], 1); + BigEndian::write_u16(&mut label[0x76..0x78], size); + BigEndian::write_u16(&mut label[0x78..0x7A], alts); + BigEndian::write_u16(&mut label[0x7A..0x7C], off); + crate::partition::next::NextDiskLabel::stamp_checksum( + &mut label, + crate::partition::next::NEXT_LABEL_V3, + ); + + // One copy at block 0, which is the one a sector-aligned scan sees. + let span = next::LABEL_SPAN.div_ceil(DATA_SECTOR); + let mut logical = vec![0u8; span * DATA_SECTOR]; + logical[..label.len()].copy_from_slice(&label); + + let phys = origin + 2048; + let mut media = vec![0xFFu8; phys as usize * RAW_SECTOR]; + for i in 0..span { + let p = (origin as usize + i) * RAW_SECTOR; + encode_sector( + &logical[i * DATA_SECTOR..(i + 1) * DATA_SECTOR], + &mut media[p..p + RAW_SECTOR], + ); + } + media + } + + #[test] + fn detect_reads_the_geometry_off_the_label() { + let media = synthetic_mo(848, 256, (1600, 8, 784)); + let geo = detect(&mut std::io::Cursor::new(media)).expect("a NeXT MO image"); + assert_eq!(geo.origin, DEFAULT_ORIGIN_SECTOR); + assert_eq!(geo.front, 256); + assert_eq!(geo.group_size, 1600); + assert_eq!(geo.group_alts, 8); + assert_eq!(geo.group_off, 784); + // One group of 1600 with 8 spares, plus the porch, bounded by the media. + assert_eq!(geo.logical_sectors, 256 + 1592); + } + + /// The size test is the cheap gate; 1296 = 16 * 81, so an ordinary image + /// never reaches the label scan at all. + #[test] + fn detect_declines_anything_without_a_label() { + let mut plain = std::io::Cursor::new(vec![0u8; 4096 * RAW_SECTOR]); + assert!(detect(&mut plain).is_none()); + let mut odd = std::io::Cursor::new(vec![0u8; 1024 * 512]); + assert!(detect(&mut odd).is_none()); + } + + #[test] + fn writing_a_sector_survives_a_reread() { + let g = geo(4, 16, 2, 8, 40); + let phys = 848 + 64; + let media = vec![0u8; phys as usize * RAW_SECTOR]; + let mut rw = NextMoReader::new(std::io::Cursor::new(media), g); + rw.seek(SeekFrom::Start(20 * DATA_SECTOR as u64 + 5)) + .unwrap(); + rw.write_all(b"NeXTSTEP").unwrap(); + rw.seek(SeekFrom::Start(20 * DATA_SECTOR as u64 + 5)) + .unwrap(); + let mut back = [0u8; 8]; + rw.read_exact(&mut back).unwrap(); + assert_eq!(&back, b"NeXTSTEP"); + } +} diff --git a/src/rbformats/vhd.rs b/src/rbformats/vhd.rs index 75876a01..ca994dfc 100644 --- a/src/rbformats/vhd.rs +++ b/src/rbformats/vhd.rs @@ -938,7 +938,13 @@ pub fn export_whole_disk_vhd( let (mut probe2, _) = open_decoded()?; super::detect_raw_rdb(&mut probe2).is_some() }; - if is_apm || is_rdb { + let label = if is_apm || is_rdb { + None + } else { + let (mut probe3, _) = open_decoded()?; + super::detect_raw_label(&mut probe3) + }; + if is_apm || is_rdb || label.is_some() { let (mut reader, source_data_size) = open_decoded()?; let mut file = std::fs::OpenOptions::new() .read(true) @@ -958,7 +964,7 @@ pub fn export_whole_disk_vhd( &cancel_check, &mut log_cb, )? - } else { + } else if is_rdb { super::reconstruct_raw_rdb_disk( &mut reader, source_data_size, @@ -969,6 +975,16 @@ pub fn export_whole_disk_vhd( &cancel_check, &mut log_cb, )? + } else { + super::reconstruct_raw_label_disk( + &mut reader, + source_data_size, + &mut file, + partition_sizes, + &mut progress_cb, + &cancel_check, + &mut log_cb, + )? }; let footer = build_vhd_footer(total_written); @@ -976,7 +992,13 @@ pub fn export_whole_disk_vhd( .context("failed to write VHD footer")?; file.flush()?; - let table_kind = if is_apm { "APM" } else { "RDB" }; + let table_kind = if is_apm { + "APM" + } else if is_rdb { + "RDB" + } else { + label.unwrap_or("label") + }; log_cb(&format!( "VHD export complete: {} ({} data bytes + 512 byte footer, {} reconstructed)", dest_path.display(), diff --git a/src/restore/mod.rs b/src/restore/mod.rs index 7a578834..45f780e3 100644 --- a/src/restore/mod.rs +++ b/src/restore/mod.rs @@ -15,6 +15,7 @@ use crate::clonezilla; use crate::clonezilla::metadata::ClonezillaImage; use crate::clonezilla::partclone::open_partclone_reader; use crate::fs::human68k::resize_human68k_in_place; +use crate::fs::ntfs::ensure_backup_boot_sector; use crate::fs::patch_hidden_sectors_for; use crate::fs::{ resize_btrfs_in_place, resize_exfat_in_place, resize_ext_in_place, resize_fat_in_place, @@ -179,6 +180,15 @@ fn restore_size_floor( } } +/// Tables whose head region (label copies, RDSK/PART chain, boot blocks) lives +/// in the LBAs a GPT clear would zero; their restore never leaves a GPT behind. +fn is_disk_label_scheme(table_type: &str) -> bool { + matches!( + table_type, + "Sun" | "NeXT" | "SGI" | "SGI-DkLabel" | "RDB" | "AHDI" | "X68k" + ) +} + /// Extra guidance for an overrun, when the alignment choice is the cause. /// /// `Original` keeps every partition at its absolute start, so shrinking only @@ -1039,7 +1049,7 @@ pub fn run_restore(config: RestoreConfig, progress: Arc>) // Step 6b: Clear any residual GPT structures from the target disk. // Only needed for MBR restores — GPT restores and superfloppies write these areas directly. - if !is_gpt && !is_superfloppy { + if !is_gpt && !is_superfloppy && !is_disk_label_scheme(&metadata.partition_table_type) { clear_gpt_structures(&mut target, config.target_size, &mut |msg| { log(&progress, LogLevel::Info, msg); })?; @@ -1178,6 +1188,9 @@ pub fn run_restore(config: RestoreConfig, progress: Arc>) resize_ntfs_in_place(inner_file, part_offset, new_sectors, &mut |msg| { log(&progress, LogLevel::Info, msg) })?; + ensure_backup_boot_sector(inner_file, part_offset, &mut |msg| { + log(&progress, LogLevel::Info, msg) + })?; } PartitionFsType::Exfat if needs_resize => { let new_sectors = export_size / 512; @@ -1387,7 +1400,10 @@ fn run_single_file_chd_restore_as_is( // The image overwrites LBA 0 onward, but a stale backup GPT at the END of // a larger target would survive and still announce a GPT disk. - if metadata.partition_table_type != "GPT" && metadata.partition_table_type != "None" { + if metadata.partition_table_type != "GPT" + && metadata.partition_table_type != "None" + && !is_disk_label_scheme(&metadata.partition_table_type) + { clear_gpt_structures(&mut target, config.target_size, &mut |msg| { log(&progress, LogLevel::Info, msg); })?; @@ -1415,6 +1431,47 @@ fn run_single_file_chd_restore_as_is( set_progress_bytes(&progress, written, logical_size); } target.flush().context("failed to flush target")?; + + // A packed FAT/NTFS/exFAT body sits shrunk inside its full extent in the + // CHD; grow it back to the extent, as the per-partition restore does. + let compacted: Vec<&crate::backup::metadata::PartitionMetadata> = metadata + .partitions + .iter() + .filter(|pm| pm.compacted && !pm.defragmented_clone) + .collect(); + if !compacted.is_empty() { + set_operation(&progress, "Finalizing filesystems..."); + let inner_file = target + .inner_mut() + .context("failed to access target file for filesystem fixups")?; + for pm in compacted { + let offset = pm.start_lba * 512; + let full_size = pm.imaged_size_bytes.max(pm.original_size_bytes); + let mut local_log = |m: &str| log(&progress, LogLevel::Info, m); + match detect_partition_fs_type(inner_file, offset) { + PartitionFsType::Fat => { + resize_fat_in_place( + inner_file, + offset, + (full_size / 512) as u32, + &mut local_log, + ) + .with_context(|| format!("grow partition-{} FAT", pm.index))?; + } + PartitionFsType::Ntfs => { + resize_ntfs_in_place(inner_file, offset, full_size / 512, &mut local_log) + .with_context(|| format!("grow partition-{} NTFS", pm.index))?; + ensure_backup_boot_sector(inner_file, offset, &mut local_log)?; + } + PartitionFsType::Exfat => { + resize_exfat_in_place(inner_file, offset, full_size / 512, &mut local_log) + .with_context(|| format!("grow partition-{} exFAT", pm.index))?; + } + _ => {} + } + } + target.flush().context("failed to flush target")?; + } target.sync_all().context("syncing the target")?; log( @@ -1485,7 +1542,7 @@ fn run_single_file_chd_restore_resize( } set_operation(&progress, "Calculating new partition layout..."); - let overrides = calculate_restore_layout( + let mut overrides = calculate_restore_layout( &adjusted, &config.alignment, &config.partition_sizes, @@ -1545,6 +1602,69 @@ fn run_single_file_chd_restore_resize( let mut chd_reader = ChdReader::open(&chd_path) .with_context(|| format!("failed to open {}", chd_path.display()))?; + // A disk label rides verbatim in the CHD's head region. Rewrite it for the + // new layout, and copy the bodies to wherever the label now says they go. + let patched_head = if crate::partition::restore_patch::can_patch(&metadata.partition_table_type) + { + for ov in &overrides { + let Some(pm) = metadata.partitions.iter().find(|p| p.index == ov.index) else { + continue; + }; + if ov.export_size == pm.imaged_size_bytes { + continue; + } + if let crate::fs::InPlaceResize::Unsupported(name) = crate::fs::in_place_resize_support( + &mut chd_reader, + pm.start_lba * 512, + pm.partition_type_string.as_deref(), + ) { + bail!( + "partition-{} holds {name}, which cannot be resized in place; \ + restore it at Original size", + pm.index + ); + } + } + let head_len = metadata + .partitions + .iter() + .map(|pm| pm.start_lba * 512) + .min() + .unwrap_or(512) + .max(512); + let mut head = vec![0u8; head_len as usize]; + chd_reader + .seek(SeekFrom::Start(0)) + .context("seek CHD to its head region")?; + chd_reader + .read_exact(&mut head) + .context("read the head region from the CHD")?; + let mut local_log = |m: &str| log(&progress, LogLevel::Info, m); + let patched = crate::partition::restore_patch::patch_head_for_restore( + &metadata.partition_table_type, + &head, + &overrides, + config.target_size, + &mut local_log, + )?; + overrides = patched.overrides; + for ov in &overrides { + log( + &progress, + LogLevel::Info, + format!( + "Partition {}: label places it at LBA {}, {} bytes", + ov.index, + ov.effective_start_lba(), + ov.export_size, + ), + ); + } + Some(patched.head) + } else { + None + }; + set_operation(&progress, "Opening target..."); let device_handle = if config.target_is_device { crate::os::open_target_for_writing(&config.target_path) @@ -1575,7 +1695,7 @@ fn run_single_file_chd_restore_resize( let is_superfloppy = metadata.partition_table_type == "None"; // Same as the per-partition restore: an MBR / APM layout must not leave a // stale GPT, primary or backup, on the target. - if !is_gpt && !is_superfloppy { + if !is_gpt && !is_superfloppy && !is_disk_label_scheme(&metadata.partition_table_type) { clear_gpt_structures(&mut target, config.target_size, &mut |msg| { log(&progress, LogLevel::Info, msg); })?; @@ -1589,6 +1709,9 @@ fn run_single_file_chd_restore_resize( let mut ebr_result: Option = None; if is_superfloppy { // No table to write. + } else if patched_head.is_some() { + // Written after the bodies, so it also covers a label that lives inside + // the first partition (a SunOS slice 0 at cylinder 0). } else if is_gpt { let gpt_path = config.backup_folder.join("gpt.json"); let gpt: Gpt = serde_json::from_reader( @@ -1842,6 +1965,15 @@ fn run_single_file_chd_restore_resize( target .flush() .context("flush target after partition writes")?; + if let Some(head) = &patched_head { + target + .seek(SeekFrom::Start(0)) + .context("seek target to the head region")?; + target + .write_all(head) + .context("write the rewritten head region")?; + target.flush().context("flush the head region")?; + } // Step 3: filesystem resize + hidden-sector patches per partition. set_operation(&progress, "Finalizing filesystems..."); @@ -1872,6 +2004,7 @@ fn run_single_file_chd_restore_resize( let mut local_log = |m: &str| log(&progress, LogLevel::Info, m); patch_hidden_sectors_for(inner_file, new_offset, new_start_lba, &mut local_log) .with_context(|| format!("patch hidden sectors for partition-{}", pm.index))?; + ensure_backup_boot_sector(inner_file, new_offset, &mut local_log)?; } } @@ -3169,6 +3302,174 @@ mod tests { ); } + /// A packed FAT body sits shrunk inside its extent in the CHD; the as-is + /// restore grows it back, so the disk comes back exactly as it was. + #[test] + fn single_file_chd_as_is_restore_grows_a_compacted_fat_partition() { + use crate::fs::filesystem::{CreateFileOptions, EditableFilesystem, Filesystem}; + const TOTAL_SECTORS: u32 = 16384; + const PART_SECTORS: u32 = 16383; + let total_bytes = TOTAL_SECTORS as u64 * 512; + let part_bytes = PART_SECTORS as u64 * 512; + + let tmp = tempfile::tempdir().unwrap(); + let source_path = tmp.path().join("source.img"); + let mut mbr = build_test_mbr(PART_SECTORS); + mbr[450] = 0x06; + let fat = crate::fs::fat::create_blank_fat(part_bytes, Some("GROW")).unwrap(); + assert!(fat.len() as u64 <= part_bytes); + let mut data = vec![0u8; total_bytes as usize]; + data[..512].copy_from_slice(&mbr); + data[512..512 + fat.len()].copy_from_slice(&fat); + std::fs::write(&source_path, &data).unwrap(); + { + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&source_path) + .unwrap(); + let mut fs = crate::fs::fat::FatFilesystem::open(file, 512).unwrap(); + let root = fs.root().unwrap(); + let payload: Vec = (0..300_000u32) + .map(|i| (i.wrapping_mul(7) % 251) as u8) + .collect(); + let mut src = &payload[..]; + fs.create_file( + &root, + "BLOB.BIN", + &mut src, + payload.len() as u64, + &CreateFileOptions::default(), + ) + .unwrap(); + EditableFilesystem::sync_metadata(&mut fs).unwrap(); + } + let data = std::fs::read(&source_path).unwrap(); + let original_total = u16::from_le_bytes([data[512 + 19], data[512 + 20]]); + assert_eq!(original_total as u64, PART_SECTORS as u64); + + let backup_folder = tmp.path().join("backup"); + std::fs::create_dir_all(&backup_folder).unwrap(); + let output_base = backup_folder.join("disk"); + let source_file = File::open(&source_path).unwrap(); + let mut br = BufReader::new(source_file.try_clone().unwrap()); + let table = PartitionTable::detect(&mut br).expect("detect MBR"); + let partitions = table.partitions(); + let mbr_bytes: [u8; 512] = data[..512].try_into().unwrap(); + let mut log_buf: Vec = Vec::new(); + let mut log_cb = |s: &str| log_buf.push(s.to_string()); + let chd_result = single_file_chd::run_via_staging( + SingleFileChdInputs { + keep_swap: true, + source_file: &source_file, + source_size: total_bytes, + source_partition_table_bytes: &mbr_bytes, + partition_table: &table, + partitions: &partitions, + partition_filter: None, + sector_by_sector: false, + chd_options: None, + is_dvd: false, + output_base: &output_base, + resize_targets: None, + hfsplus_clone_targets: None, + alignment_sectors: 0, + checksum_type: crate::backup::ChecksumType::Sha256, + }, + &mut |_| {}, + &|| false, + &mut log_cb, + &mut |_, _| {}, + &mut |_| {}, + None, + ) + .expect("backup"); + assert!( + log_buf.iter().any(|l| l.contains("packed-and-padded")), + "the FAT partition must go through the packed reader: {log_buf:?}" + ); + + let metadata = BackupMetadata { + version: 1, + created: "2026-09-11T00:00:00Z".to_string(), + source_device: source_path.display().to_string(), + source_size_bytes: total_bytes, + partition_table_type: table.type_name().to_string(), + checksum_type: "sha256".to_string(), + compression_type: "chd".to_string(), + split_size_mib: None, + sector_by_sector: false, + layout: BackupLayout::SingleFileChd, + container: Some(chd_result.container_filename.clone()), + container_logical_size: Some(chd_result.container_logical_size), + container_sha1: Some(chd_result.container_sha1.clone()), + size_policy: Some(crate::backup::metadata::SizePolicy::Original), + alignment: AlignmentMetadata { + detected_type: "None detected".to_string(), + first_partition_lba: 1, + alignment_sectors: 1, + heads: 0, + sectors_per_track: 0, + }, + partitions: chd_result + .partition_ranges + .iter() + .map(|r| PartitionMetadata { + index: r.partition_index, + type_name: "FAT16".to_string(), + partition_type_byte: 0x06, + start_lba: r.offset_in_disk / 512, + start_byte: None, + original_size_bytes: r.length, + imaged_size_bytes: r.length, + compressed_files: vec![], + checksum: r.checksum.clone(), + resized: false, + compacted: true, + is_logical: false, + partition_type_string: None, + minimum_size_bytes: None, + defragmented_min_size_bytes: None, + hfsplus_signature: None, + defragmented_clone: false, + }) + .collect(), + bad_sectors: vec![], + extended_container: None, + }; + std::fs::write( + backup_folder.join("metadata.json"), + serde_json::to_string_pretty(&metadata).unwrap(), + ) + .unwrap(); + + let target_path = tmp.path().join("restored.img"); + run_restore( + RestoreConfig { + backup_folder: backup_folder.clone(), + target_path: target_path.clone(), + target_is_device: false, + target_size: total_bytes, + alignment: RestoreAlignment::Original, + partition_sizes: vec![], + write_zeros_to_unused: false, + }, + Arc::new(Mutex::new(RestoreProgress::new())), + ) + .expect("restore"); + + let restored = std::fs::read(&target_path).unwrap(); + let restored_total = u16::from_le_bytes([restored[512 + 19], restored[512 + 20]]); + assert_eq!( + restored_total, original_total, + "the FAT must fill its partition again after the as-is restore" + ); + assert_eq!( + restored, data, + "restored disk must match the source byte for byte" + ); + } + /// Stage 5b round-trip: build a 4 MiB MBR-disk single-file CHD backup /// with one 0x83 (Linux) partition spanning sectors 1..=2047, then /// restore with `RestoreSizeChoice::Custom` that GROWS the partition diff --git a/tests/cli_suite/cli_bbcmicro_dsd.rs b/tests/cli_suite/cli_bbcmicro_dsd.rs index 20d1fe48..c8257c73 100644 --- a/tests/cli_suite/cli_bbcmicro_dsd.rs +++ b/tests/cli_suite/cli_bbcmicro_dsd.rs @@ -142,3 +142,39 @@ fn put_on_side_one_reinterleaves_and_leaves_side_two_intact() { run(&["get", &side2, "DATA", data_back.to_str().unwrap()]); assert_eq!(sha256_hex(&std::fs::read(&data_back).unwrap()), DATA_SHA); } + +/// A backup of a `.dsd` has never been restorable, so it is refused up front +/// in every format and leaves no folder behind. +#[test] +fn backup_of_a_dsd_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let img = fixture_to(tmp.path()); + let dest = tmp.path().join("bk"); + for format in ["zstd", "chd", "raw"] { + let out = Command::new(cli_bin()) + .args([ + "backup", + img.to_str().unwrap(), + dest.to_str().unwrap(), + "--name", + "job", + "--format", + format, + ]) + .output() + .expect("spawn rb-cli"); + assert!( + !out.status.success(), + "backup --format {format} of a .dsd must be refused" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("cannot be backed up"), + "{format}: stderr should say why: {stderr}" + ); + assert!( + !dest.join("job").exists(), + "{format}: a refused backup must not leave a folder behind" + ); + } +} diff --git a/tests/cli_suite/cli_x68000.rs b/tests/cli_suite/cli_x68000.rs index 98281a83..47007995 100644 --- a/tests/cli_suite/cli_x68000.rs +++ b/tests/cli_suite/cli_x68000.rs @@ -152,3 +152,58 @@ fn rb_cli_put_then_get_round_trips_a_new_file_on_flat() { let got = std::fs::read(&dst).unwrap(); assert_eq!(&got, payload); } + +/// A CHD is a whole disk: an X68k HDD backs up as one `chd.chd` that restores +/// byte-identical (IPL region included) and still shrinks on `--size minimum`. +#[test] +fn rb_cli_backup_chd_of_an_x68k_hdd_is_one_whole_disk_and_resizes_on_restore() { + let dir = tempfile::tempdir().unwrap(); + let img = dir.path().join("x.img"); + run(&["new", "hd", "x68k", img.to_str().unwrap(), "--size", "32M"]); + let dest = dir.path().join("bk"); + run(&[ + "backup", + img.to_str().unwrap(), + dest.to_str().unwrap(), + "--name", + "job", + "--format", + "chd", + ]); + let folder = dest.join("job"); + assert!(folder.join("job.chd").exists(), "one whole-disk CHD"); + assert!( + !folder.join("partition-0.chd").exists(), + "no per-partition CHD may be written" + ); + + let as_is = dir.path().join("as-is.img"); + run(&["restore", folder.to_str().unwrap(), as_is.to_str().unwrap()]); + assert_eq!( + std::fs::read(&img).unwrap(), + std::fs::read(&as_is).unwrap(), + "an as-is restore carries the IPL region back byte for byte" + ); + + let shrunk = dir.path().join("min.img"); + run(&[ + "restore", + folder.to_str().unwrap(), + shrunk.to_str().unwrap(), + "--size", + "minimum", + ]); + let out = run(&["inspect", shrunk.to_str().unwrap()]); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("Partition table: X68k") && stdout.contains("16.1 MiB"), + "--size minimum must rewrite the X68k table for the shrunk partition:\n{stdout}" + ); + let out = run(&["ls", &format!("{}@1", shrunk.display()), "/"]); + assert!( + String::from_utf8_lossy(&out.stdout) + .to_ascii_uppercase() + .contains("HELLO.TXT"), + "the shrunk Human68k partition must still list its files" + ); +} diff --git a/tests/split_backup_roundtrip.rs b/tests/split_backup_roundtrip.rs index 12a1f192..7e6ae76c 100644 --- a/tests/split_backup_roundtrip.rs +++ b/tests/split_backup_roundtrip.rs @@ -250,10 +250,21 @@ fn chd_rejects_split_size_up_front() { let meta: serde_json::Value = serde_json::from_reader(std::fs::File::open(folder.join("metadata.json")).unwrap()) .unwrap(); - let members = meta["partitions"][0]["compressed_files"] - .as_array() - .unwrap(); - assert_eq!(members.len(), 1, "a CHD backup is always one container"); + // A CHD backup is always one container: the whole-disk layout, with the + // body inside `.chd` and no per-partition member files at all. + assert_eq!( + meta["layout"], "single-file-chd", + "a CHD backup is always one container" + ); + assert_eq!(meta["container"], "ok.chd"); + assert!(folder.join("ok.chd").exists(), "the container must exist"); + assert!( + meta["partitions"][0]["compressed_files"] + .as_array() + .unwrap() + .is_empty(), + "the body lives inside the container, not in a partition-N file" + ); let target = work.join("restored-chd.img"); rusty_backup::restore::run_restore( diff --git a/tests/superfloppy_compression.rs b/tests/superfloppy_compression.rs index 9c780a42..f7cc2c73 100644 --- a/tests/superfloppy_compression.rs +++ b/tests/superfloppy_compression.rs @@ -172,6 +172,103 @@ fn superfloppy_honours_every_codec_and_restores_identically() { } } +/// A `.chd` holds a whole disk, so a partitionless volume backs up as one +/// container sized to the source, never a `partition-0.chd` of the packed body. +#[cfg(feature = "chd")] +#[test] +fn superfloppy_chd_is_one_whole_disk_container() { + let dir = tempfile::tempdir().unwrap(); + let work = dir.path().to_path_buf(); + let src = work.join("source.img"); + fat_superfloppy(&src); + let source_bytes = std::fs::read(&src).unwrap(); + + let backups = work.join("backups-chd"); + std::fs::create_dir_all(&backups).unwrap(); + run_backup( + backup_config(&src, &backups, "chd", CompressionType::Chd), + Arc::new(Mutex::new(BackupProgress::default())), + ) + .unwrap_or_else(|e| panic!("chd: backup failed: {e:?}")); + + let folder = backups.join("chd"); + let meta: serde_json::Value = + serde_json::from_reader(std::fs::File::open(folder.join("metadata.json")).unwrap()) + .unwrap(); + assert_eq!(meta["layout"], "single-file-chd"); + assert_eq!(meta["compression_type"], "chd"); + assert_eq!(meta["container"], "chd.chd"); + assert_eq!( + meta["container_logical_size"].as_u64().unwrap(), + source_bytes.len() as u64, + "the CHD must be the whole disk, not the packed volume" + ); + assert!( + folder.join("chd.chd").exists(), + "the single container is missing" + ); + assert!( + !folder.join("partition-0.chd").exists(), + "a per-partition CHD was written for a partitionless volume" + ); + + let target = work.join("restored-chd.img"); + rusty_backup::restore::run_restore( + RestoreConfig { + backup_folder: folder, + target_path: target.clone(), + target_is_device: false, + target_size: source_bytes.len() as u64, + alignment: RestoreAlignment::Original, + partition_sizes: Vec::new(), + write_zeros_to_unused: false, + }, + Arc::new(Mutex::new(RestoreProgress::default())), + ) + .unwrap_or_else(|e| panic!("chd: restore failed: {e:?}")); + assert_eq!( + std::fs::read(&target).unwrap(), + source_bytes, + "chd: restore must match the source byte for byte" + ); +} + +/// A blank NTFS superfloppy, backup boot sector in its last sector. +fn ntfs_superfloppy(path: &std::path::Path) -> Vec { + let size = 20 * 1024 * 1024u64; + let mut img = std::io::Cursor::new(vec![0u8; size as usize]); + rusty_backup::fs::ntfs_format::create_blank_ntfs(&mut img, size, 64, Some("NTFSSF")).unwrap(); + std::fs::write(path, img.get_ref()).unwrap(); + img.into_inner() +} + +/// The packed NTFS stream stops one sector before the backup boot sector, so +/// both layouts have to put it back or the restore fails fsck. +#[cfg(feature = "chd")] +#[test] +fn ntfs_superfloppy_comes_back_with_its_backup_boot_sector() { + let dir = tempfile::tempdir().unwrap(); + let work = dir.path().to_path_buf(); + let src = work.join("source.img"); + let source_bytes = ntfs_superfloppy(&src); + assert_eq!( + &source_bytes[..512], + &source_bytes[source_bytes.len() - 512..], + "the formatter puts the VBR copy in the last sector" + ); + + for (tag, compression) in [ + ("zstd", CompressionType::Zstd), + ("chd", CompressionType::Chd), + ] { + let (out, _, _) = round_trip(&work, &src, tag, compression); + assert_eq!( + out, source_bytes, + "{tag}: NTFS restore must match the source" + ); + } +} + /// The compressed member has to be genuinely smaller — otherwise the codec /// silently degraded to a raw copy and the whole feature is a no-op. #[test]