Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
42456f2
* screenshots refresh only on branches that change the UI
Hawkynt Oct 26, 2026
11bdfd2
+ ZIP method 95 (XZ): read and write
Hawkynt Oct 26, 2026
c01351b
* regenerate references
Hawkynt Oct 26, 2026
0edd488
# XAR: bzip2, lzma and xz members decoded and checked against the TOC
Hawkynt Oct 26, 2026
393d095
* regenerate references
Hawkynt Oct 26, 2026
3bcfeb8
# LHA: -lh2- and -lh3- decoded with their own decoders, writing them …
Hawkynt Oct 26, 2026
6493e5c
* regenerate references
Hawkynt Oct 26, 2026
e199a5f
# LHA: header levels 1/3, lh1, lzs, lz5, pm1, pm2 and lhx read as the…
Hawkynt Oct 26, 2026
fc95094
# PackIt and DiskDoubler: real on-disk formats, decode or refuse
Hawkynt Oct 26, 2026
2350d2a
* regenerate references
Hawkynt Oct 26, 2026
fd7e239
* FreeArc: real FreeArc 0.67 archives, read and write
Hawkynt Oct 26, 2026
dd3a154
* regenerate references
Hawkynt Oct 26, 2026
685d627
* NSIS: real installer format, solid and non-solid, read-only
Hawkynt Oct 26, 2026
7200477
* regenerate references
Hawkynt Oct 26, 2026
ffc73b3
+ PartitionedDisk write support, 4Kn GPT, BSD/Amiga/Sun/SGI labels, r…
Hawkynt Oct 26, 2026
2cb06be
* refresh the archive browser screenshot
github-actions[bot] Oct 26, 2026
ad124fa
* refresh the application screenshot
github-actions[bot] Oct 26, 2026
fc8ce06
* regenerate references
Hawkynt Oct 26, 2026
dfd763c
* refresh the application screenshot
github-actions[bot] Oct 26, 2026
c6d48b5
* refresh the archive browser screenshot
github-actions[bot] Oct 26, 2026
148a622
* refresh the archive browser screenshot
github-actions[bot] Oct 26, 2026
b171283
* refresh the archive browser screenshot
github-actions[bot] Oct 26, 2026
6afb631
* refresh the application screenshot
github-actions[bot] Oct 26, 2026
d69e707
* refresh the archive browser screenshot
github-actions[bot] Oct 26, 2026
f7ceb83
* refresh the archive browser screenshot
github-actions[bot] Oct 26, 2026
aa8748a
* record LHA decoder provenance and merge CI prerequisites
Hawkynt Oct 26, 2026
fd0f4d9
* record lhasa provenance and merge CI prerequisites
Hawkynt Oct 26, 2026
1c3d3f2
* record DiskDoubler provenance and merge CI prerequisites
Hawkynt Oct 26, 2026
05f41f3
* merge validated provenance prerequisites for FreeArc
Hawkynt Oct 26, 2026
32f1345
* merge validated provenance prerequisites for NSIS
Hawkynt Oct 26, 2026
6c11eee
* merge validated provenance prerequisites for partitioned disks
Hawkynt Oct 26, 2026
f344c9e
* isolate partitioned-disk editing from pending formats
Hawkynt Oct 27, 2026
0e587aa
* reconcile partitioned-disk editing with merged formats
Hawkynt Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,27 @@ Candidate formats from earlier stages are validated by parsing their headers and
|---|----------|--------|
| 1 | A signature of ≥ 0.9 confidence and ≥ 4 bytes inside sector 0 | That format (VHDX, QCOW2, VMDK, XFS, SquashFS, gzip, …) |
| 2 | ISO 9660 / UDF volume descriptors at 32 KiB | `Iso` / `Udf` — ahead of any partition table, so a hybrid ISO whose system area carries an MBR or GPT browses as the ISO |
| 3 | GPT header at LBA 1 whose CRC32 matches (UEFI 2.10 §5.3.2) | `PartitionedDisk` |
| 3 | GPT header at LBA 1 whose CRC32 matches (UEFI 2.10 §5.3.2), at 512- or 4096-byte logical blocks | `PartitionedDisk` |
| 4 | Sector 0 ends in 0x55AA: validated MBR table vs. volume boot record (FAT BPB, NTFS/exFAT OEM ID) | Table only → `PartitionedDisk`; boot record only → that filesystem; both → `PartitionedDisk` only if a partition holds a recognisable filesystem |
| 5 | Apple Partition Map | `PartitionedDisk` |
| 6 | A filesystem superblock signature of ≥ 0.75 confidence, up to the deepest one registered (ZFS at 128 KiB) | That filesystem |
| 6 | A checksummed SGI volume header, Sun label or Amiga Rigid Disk Block, or a stand-alone BSD disklabel (both magics, sane geometry) | `PartitionedDisk` |
| 7 | A filesystem superblock signature of ≥ 0.75 confidence, up to the deepest one registered (ZFS at 128 KiB) | That filesystem |

A valid MBR (UEFI 2.10 §5.2.1) has a boot indicator of 0x00 or 0x80 in all four entries, and each used entry starts at LBA ≥ 1 inside the image, is at least one sector long and does not overlap another used entry. A trailing 0x55AA alone does not make a partition table: every FAT, NTFS and exFAT boot sector has one. Bytes that prove nothing stay `Unknown`, so noise named `.img` is no longer handed to the FAT reader. A `.img` path that does not exist yet, or is empty, still resolves to FAT as the create target. The probe reads 40 KiB, and reads further, out to the deepest filesystem signature, only for stage 6. It never scans the image.
A valid MBR (UEFI 2.10 §5.2.1) has a boot indicator of 0x00 or 0x80 in all four entries, and each used entry starts at LBA ≥ 1 inside the image, is at least one sector long and does not overlap another used entry. A trailing 0x55AA alone does not make a partition table: every FAT, NTFS and exFAT boot sector has one. Bytes that prove nothing stay `Unknown`, so noise named `.img` is no longer handed to the FAT reader. A `.img` path that does not exist yet, or is empty, still resolves to FAT as the create target. The probe reads 40 KiB, and reads further, out to the deepest filesystem signature, only for stage 7. It never scans the image.

`PartitionedDisk` lists each partition as a `PartitionN_Type/` directory holding the filesystem detected inside it (`InnerFsDetector`, which also reads past 4 KiB to find ISO 9660, UDF, JFS, btrfs and ZFS). A partition it does not recognise becomes a single `PartitionN_Type.raw` entry. Partitions are read through a `PartitionWindowStream` and never copied out. `OpenEntry` hands back the window itself, or the inner filesystem's entry stream over it. Virtual-disk containers (VHD, VHDX, VMDK, QCOW2, VDI, Bochs) are detected by their own signatures and run the same partition-aware listing over their guest disk.

Tables: GPT at 512- or 4096-byte logical blocks (the block size is the one at which LBA 1 holds a CRC-valid header; a damaged primary falls back to the backup header), MBR with its EBR chain of logical partitions, APM, a BSD disklabel inside a BSD slice or standing alone, Amiga RDB, Sun and SGI labels. Writing:

| Operation | How | Refused when |
|-----------|-----|--------------|
| Add/remove `PartitionN_Type/path` | The partition's own filesystem editor runs in place on a `StagedWindowStream`: writes are staged and reach the disk only when every edit in the call succeeded | No recognised filesystem; its editor is only the generic re-create default; the filesystem refuses (full, unsupported case); it would grow past the partition |
| Add a root-level filesystem or `*.raw`/`*.img` image | New GPT/MBR entry after the last partition, 1 MiB-aligned where room allows, inside the usable LBA range | No free space; table full; not a partition image |
| Remove `PartitionN_Type` | `PartitionEditor.DeletePartition` (GPT, MBR, logical); the sectors stay | BSD/RDB/Sun/SGI/APM tables |
| `Create` | Partition images on a fresh GPT (`scheme=gpt`, default) or MBR, `sectorSize=512`/`4096`, partitions 1 MiB-aligned | A loose file; more than 128 (GPT) / 4 (MBR) images |

Every refusal leaves the image byte-identical. `sgdisk -v`, `sfdisk --verify`, `parted`, `fdisk -b 4096`, `fsck.vfat` and `e2fsck` check the results (`PartitionedDiskExternalTests`). Resizing a partition is not an archive operation; the partition editor window does it through `PartitionEditor.ResizePartition` and the registered filesystem resizers.

`FormatDetector.DetectByContent(Stream)` runs the same probe over a seekable stream, such as an entry inside a container, without a file on disk. `FormatDetector.DetectCached(path)` is `Detect` with a cache keyed on path, length and last-write time, for UI state that is re-evaluated on every repaint.

---
Expand Down
153 changes: 153 additions & 0 deletions Compression.Core/DiskImage/AmigaRdbParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using System.Buffers.Binary;
using System.Text;

namespace Compression.Core.DiskImage;

/// <summary>
/// Parses the Amiga Rigid Disk Block (RDB) partition scheme. Big-endian throughout.
/// </summary>
/// <remarks>
/// <para>
/// The <c>RDSK</c> block may sit in any of the first 16 blocks of the disk
/// (<c>RDB_LOCATION_LIMIT</c>). It links, by block number, a chain of <c>PART</c> blocks; each
/// carries the partition's drive name (a BCPL string) and a <c>DosEnvec</c> whose geometry —
/// low/high cylinder, surfaces, blocks per track, block size in longwords — places the
/// partition on the disk, plus the <c>DosType</c> naming its filesystem (<c>DOS\1</c> = FFS,
/// <c>PFS\3</c>, <c>LNX\0</c>, …). Every block carries a checksum: the 32-bit sum of its first
/// <c>SummedLongs</c> longwords is zero.
/// </para>
/// <para>
/// A partition's byte range is <c>LowCyl × Surfaces × BlocksPerTrack × SizeBlock × 4</c> for the
/// start and <c>(HighCyl − LowCyl + 1) × Surfaces × BlocksPerTrack × SizeBlock × 4</c> for the
/// length.
/// </para>
/// References:
/// <list type="bullet">
/// <item><description>AmigaOS NDK, <c>devices/hardblocks.h</c> — <c>struct RigidDiskBlock</c>, <c>struct PartitionBlock</c>, <c>RDB_LOCATION_LIMIT</c>.</description></item>
/// <item><description>AmigaOS NDK, <c>dos/filehandler.h</c> — <c>struct DosEnvec</c> field order.</description></item>
/// </list>
/// </remarks>
public static class AmigaRdbParser {

private const uint RdskId = 0x5244534B; // "RDSK"
private const uint PartId = 0x50415254; // "PART"
private const uint EndOfList = 0xFFFFFFFF;
private const int SearchBlockSize = 512;
private const int LocationLimit = 16;
private const int MaxPartitions = 128;

// RigidDiskBlock field offsets.
private const int RdbSummedLongs = 4;
private const int RdbBlockBytes = 16;
private const int RdbPartitionList = 28;

// PartitionBlock field offsets.
private const int PbNext = 16;
private const int PbDriveName = 36;
private const int PbEnvironment = 128;

// DosEnvec longword indices.
private const int DeTableSize = 0;
private const int DeSizeBlock = 1;
private const int DeSurfaces = 3;
private const int DeBlocksPerTrack = 5;
private const int DeLowCyl = 9;
private const int DeHighCyl = 10;
private const int DeDosType = 16;

/// <summary>Reports whether a checksummed <c>RDSK</c> block sits in the first 16 blocks.</summary>
/// <param name="disk">A seekable stream over the whole disk.</param>
public static bool IsRdb(Stream disk) => FindRdb(disk) >= 0;

/// <summary>Parses every partition the RDB's <c>PART</c> chain describes.</summary>
/// <param name="disk">A seekable stream over the whole disk.</param>
/// <exception cref="InvalidDataException">No valid RDB is present.</exception>
public static List<PartitionEntry> Parse(Stream disk) {
var rdbBlock = FindRdb(disk);
if (rdbBlock < 0) throw new InvalidDataException("No Amiga Rigid Disk Block (RDSK) in the first 16 blocks.");

var rdb = ReadBlock(disk, (long)rdbBlock * SearchBlockSize, SearchBlockSize);
var blockBytes = (int)BinaryPrimitives.ReadUInt32BigEndian(rdb.AsSpan(RdbBlockBytes));
if (blockBytes is < 256 or > 65536 || (blockBytes & (blockBytes - 1)) != 0) blockBytes = SearchBlockSize;

var result = new List<PartitionEntry>();
var seen = new HashSet<uint>();
var next = BinaryPrimitives.ReadUInt32BigEndian(rdb.AsSpan(RdbPartitionList));
while (next != EndOfList && result.Count < MaxPartitions && seen.Add(next)) {
var offset = (long)next * blockBytes;
if (offset + blockBytes > disk.Length) break;
var block = ReadBlock(disk, offset, blockBytes);
if (BinaryPrimitives.ReadUInt32BigEndian(block) != PartId || !ChecksumOk(block)) break;

uint Env(int i) => BinaryPrimitives.ReadUInt32BigEndian(block.AsSpan(PbEnvironment + i * 4));
var tableSize = Env(DeTableSize);
if (tableSize >= DeHighCyl) {
var bytesPerBlock = (long)Env(DeSizeBlock) * 4;
var cylinderBytes = (long)Env(DeSurfaces) * Env(DeBlocksPerTrack) * bytesPerBlock;
var low = Env(DeLowCyl);
var high = Env(DeHighCyl);
if (cylinderBytes > 0 && high >= low) {
var dosType = tableSize >= DeDosType ? Env(DeDosType) : 0;
result.Add(new PartitionEntry {
Index = result.Count,
StartOffset = low * cylinderBytes,
Size = (high - low + 1L) * cylinderBytes,
TypeName = DosTypeName(dosType),
TypeCode = $"0x{dosType:X8}",
Name = DriveName(block.AsSpan(PbDriveName, 32)),
Source = "RDB"
});
}
}

next = BinaryPrimitives.ReadUInt32BigEndian(block.AsSpan(PbNext));
}
return result;
}

private static int FindRdb(Stream disk) {
if (!disk.CanSeek) return -1;
for (var i = 0; i < LocationLimit; ++i) {
var offset = (long)i * SearchBlockSize;
if (offset + SearchBlockSize > disk.Length) break;
var block = ReadBlock(disk, offset, SearchBlockSize);
if (BinaryPrimitives.ReadUInt32BigEndian(block) == RdskId && ChecksumOk(block))
return i;
}
return -1;
}

/// <summary>The block's first <c>SummedLongs</c> longwords sum to zero (mod 2^32).</summary>
private static bool ChecksumOk(ReadOnlySpan<byte> block) {
var summed = BinaryPrimitives.ReadUInt32BigEndian(block[RdbSummedLongs..]);
if (summed < 4 || summed * 4L > block.Length) return false;
uint sum = 0;
for (var i = 0; i < summed; ++i)
sum += BinaryPrimitives.ReadUInt32BigEndian(block[(i * 4)..]);
return sum == 0;
}

/// <summary>A BCPL string: a length byte followed by that many characters.</summary>
private static string DriveName(ReadOnlySpan<byte> bstr) {
var length = Math.Min(bstr[0], (byte)31);
return Encoding.Latin1.GetString(bstr.Slice(1, length));
}

/// <summary>Renders a DosType as its four characters, the last as a digit (<c>DOS\1</c> → <c>DOS1</c>).</summary>
private static string DosTypeName(uint dosType) {
if (dosType == 0) return "Amiga";
Span<char> chars = stackalloc char[4];
for (var i = 0; i < 4; ++i) {
var b = (byte)(dosType >> (24 - i * 8));
chars[i] = b is >= 0x20 and < 0x7F ? (char)b : (char)('0' + b % 10);
}
return new string(chars);
}

private static byte[] ReadBlock(Stream disk, long offset, int size) {
var buffer = new byte[size];
disk.Position = offset;
_ = disk.ReadAtLeast(buffer, size, throwOnEndOfStream: false);
return buffer;
}
}
37 changes: 37 additions & 0 deletions Compression.Core/DiskImage/BsdDisklabelParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,43 @@ public static class BsdDisklabelParser {
/// <returns><c>true</c> if a disklabel is present.</returns>
public static bool IsDisklabel(Stream slice) => FindLabelOffset(slice) >= 0;

/// <summary>
/// Reports whether <paramref name="disk"/> carries a disklabel that stands on its own — no MBR
/// slice around it, as a "dangerously dedicated" FreeBSD disk or a NetBSD/OpenBSD disk on a
/// non-PC port writes it. With nothing else vouching for the bytes the label must prove
/// itself structurally: <c>d_magic</c> and <c>d_magic2</c> both present 132 bytes apart, a
/// sector size that is a power of two in [512, 8192], a partition count within
/// <c>MAXPARTITIONS</c>, and every used partition starting inside the disk.
/// </summary>
/// <remarks>
/// <c>d_checksum</c> is deliberately not required: the labels GNU parted 3.x writes
/// (<c>mklabel bsd</c>) do not satisfy the 4.4BSD <c>dkcksum</c> XOR rule, and neither the
/// Linux kernel nor libblkid checks it — the two magics are what both rely on.
/// </remarks>
/// <param name="disk">A seekable stream over the whole disk.</param>
public static bool IsStandaloneDisklabel(Stream disk) {
var labelOffset = FindLabelOffset(disk);
if (labelOffset < 0) return false;
var label = new byte[PartitionsOffset + MaxPartitions * PartitionRecordSize];
disk.Position = labelOffset;
var read = disk.ReadAtLeast(label, label.Length, throwOnEndOfStream: false);
if (read < PartitionsOffset) return false;
var span = label.AsSpan(0, read);
if (ReadU32(span, Magic2Offset) != DiskMagic) return false;
var secSize = ReadU32(span, 40);
if (secSize is < 512 or > 8192 || (secSize & (secSize - 1)) != 0) return false;
var npartitions = BinaryPrimitives.ReadUInt16LittleEndian(span[NPartitionsOffset..]);
if (npartitions is 0 or > MaxPartitions) return false;
if (PartitionsOffset + npartitions * PartitionRecordSize > span.Length) return false;
var diskSectors = (ulong)disk.Length / secSize;
for (var i = 0; i < npartitions; ++i) {
var size = ReadU32(span, PartitionsOffset + i * PartitionRecordSize);
var offset = ReadU32(span, PartitionsOffset + i * PartitionRecordSize + 4);
if (size != 0 && offset >= diskSectors) return false;
}
return true;
}

/// <summary>
/// Parses the BSD disklabel contained in <paramref name="slice"/> and returns
/// its filesystem partitions.
Expand Down
Loading
Loading