diff --git a/Compression.Tests/ComicBook/CbtTests.cs b/Compression.Tests/ComicBook/CbtTests.cs
new file mode 100644
index 000000000..8c687b177
--- /dev/null
+++ b/Compression.Tests/ComicBook/CbtTests.cs
@@ -0,0 +1,126 @@
+using System.Diagnostics;
+using Compression.Analysis.ExternalTools;
+using Compression.Registry;
+using Compression.Lib;
+
+namespace Compression.Tests.ComicBook;
+
+///
+/// .cbt comic book archives: an extension-only view of the tar container. Oracle: 7-Zip reads
+/// what we write and writes what we read.
+///
+[TestFixture]
+public class CbtTests {
+
+ private static readonly byte[] Page1 = "first comic page bytes"u8.ToArray();
+ private static readonly byte[] Page2 = [.. Enumerable.Range(0, 5000).Select(i => (byte)(i * 7 % 251))];
+ private static readonly byte[] Page3 = "third page, added later"u8.ToArray();
+
+ private string _tmp = null!;
+
+ [SetUp]
+ public void SetUp() {
+ this._tmp = Path.Combine(Path.GetTempPath(), "cwb_cbx_" + Guid.NewGuid().ToString("N")[..8]);
+ Directory.CreateDirectory(this._tmp);
+ }
+
+ [TearDown]
+ public void TearDown() {
+ try { Directory.Delete(this._tmp, true); } catch { /* best effort */ }
+ }
+
+ private static MemoryStream Create(IArchiveCreatable d) {
+ var ms = new MemoryStream();
+ d.Create(ms, [ArchiveInputInfo.InMemory("page01.png", Page1), ArchiveInputInfo.InMemory("page02.png", Page2)],
+ new FormatCreateOptions());
+ ms.Position = 0;
+ return ms;
+ }
+
+ private static IEnumerable Descriptors() {
+ yield return new FileFormat.Cbt.CbtFormatDescriptor();
+ }
+
+ [TestCaseSource(nameof(Descriptors)), Category("HappyPath")]
+ public void Given_Descriptor_When_Inspected_Then_ExtensionOnlyAndWritable(IFormatDescriptor d) {
+ Assert.That(d.MagicSignatures, Is.Empty, "byte-identical to the inner container: extension-only detection");
+ Assert.That(d.Extensions, Has.Count.EqualTo(1));
+ Assert.That(d.DefaultExtension, Is.EqualTo(d.Extensions[0]));
+ Assert.That(d.Capabilities.HasFlag(FormatCapabilities.CanCreate), Is.True);
+ Assert.That(d.Capabilities.HasFlag(FormatCapabilities.CanModify), Is.True);
+ }
+
+ [Test, Category("HappyPath")]
+ public void Given_Registry_When_ResolvedByExtension_Then_ComicDescriptorsWin() {
+ FormatRegistration.EnsureInitialized();
+ Assert.That(FormatRegistry.GetByExtension(".cbt")?.Id, Is.EqualTo("Cbt"));
+ }
+
+ [TestCaseSource(nameof(Descriptors)), Category("RoundTrip")]
+ public void Given_Archive_When_CreatedAddedRemoved_Then_SurvivorsByteIdentical(IFormatDescriptor d) {
+ using var ms = Create((IArchiveCreatable)d);
+ var ops = (IArchiveFormatOperations)d;
+ Assert.That(ops.List(ms, null).Select(e => e.Name), Is.EquivalentTo(new[] { "page01.png", "page02.png" }));
+
+ ms.Position = 0;
+ ((IArchiveModifiable)d).Add(ms, [ArchiveInputInfo.InMemory("page03.png", Page3)]);
+ ms.Position = 0;
+ ((IArchiveModifiable)d).Remove(ms, ["page01.png"]);
+ ms.Position = 0;
+ Assert.That(ops.List(ms, null).Select(e => e.Name), Is.EquivalentTo(new[] { "page02.png", "page03.png" }));
+ ms.Position = 0;
+ Assert.That(ops.ExtractEntryToMemory(ms, "page02.png", null), Is.EqualTo(Page2));
+ ms.Position = 0;
+ Assert.That(ops.ExtractEntryToMemory(ms, "page03.png", null), Is.EqualTo(Page3));
+ }
+
+ [TestCaseSource(nameof(Descriptors)), Category("EdgeCase")]
+ public void Given_Garbage_When_Listed_Then_ThrowsOrEmptyButNeverWrongBytes(IFormatDescriptor d) {
+ using var ms = new MemoryStream([.. Enumerable.Repeat((byte)0x5A, 700)]);
+ try {
+ var entries = ((IArchiveFormatOperations)d).List(ms, null);
+ Assert.That(entries.Where(e => e.OriginalSize > 0), Is.Empty);
+ } catch (Exception ex) when (ex is InvalidDataException or NotSupportedException or EndOfStreamException or ArgumentException or FormatException) {
+ Assert.Pass("rejected: " + ex.GetType().Name);
+ }
+ }
+
+ private static (int Exit, string Out) Run(string exe, string args) {
+ var psi = new ProcessStartInfo(exe, args) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
+ using var p = Process.Start(psi)!;
+ var stdout = p.StandardOutput.ReadToEndAsync();
+ var stderr = p.StandardError.ReadToEndAsync();
+ p.WaitForExit(60_000);
+ return (p.ExitCode, stdout.Result + stderr.Result);
+ }
+
+ [Test, Category("EndToEnd")]
+ public void Given_OurCbt_When_SevenZipExtracts_Then_PagesByteIdentical() {
+ var sz = ToolDiscovery.GetToolPath("7z") ?? ToolDiscovery.GetToolPath("7za");
+ if (sz == null) Assert.Ignore("7-Zip not found.");
+ var path = Path.Combine(this._tmp, "ours.cbt");
+ using (var ms = Create(new FileFormat.Cbt.CbtFormatDescriptor()))
+ File.WriteAllBytes(path, ms.ToArray());
+ var outDir = Path.Combine(this._tmp, "x");
+ var (exit, output) = Run(sz!, $"x -ttar \"{path}\" -o\"{outDir}\" -y");
+ Assert.That(exit, Is.Zero, output);
+ Assert.That(File.ReadAllBytes(Path.Combine(outDir, "page01.png")), Is.EqualTo(Page1));
+ Assert.That(File.ReadAllBytes(Path.Combine(outDir, "page02.png")), Is.EqualTo(Page2));
+ }
+
+ [Test, Category("EndToEnd")]
+ public void Given_SevenZipCbt_When_WeExtract_Then_PagesByteIdentical() {
+ var sz = ToolDiscovery.GetToolPath("7z") ?? ToolDiscovery.GetToolPath("7za");
+ if (sz == null) Assert.Ignore("7-Zip not found.");
+ var src = Path.Combine(this._tmp, "src");
+ Directory.CreateDirectory(src);
+ File.WriteAllBytes(Path.Combine(src, "page01.png"), Page1);
+ File.WriteAllBytes(Path.Combine(src, "page02.png"), Page2);
+ var path = Path.Combine(this._tmp, "theirs.cbt");
+ var (exit, output) = Run(sz!, $"a -ttar \"{path}\" \"{Path.Combine(src, "*")}\"");
+ Assert.That(exit, Is.Zero, output);
+ var d = new FileFormat.Cbt.CbtFormatDescriptor();
+ using var fs = File.OpenRead(path);
+ Assert.That(((IArchiveFormatOperations)d).ExtractEntryToMemory(fs, "page02.png", null), Is.EqualTo(Page2));
+ }
+}
diff --git a/Compression.Tests/FirmwareHex/FirmwareHexPayloadTests.cs b/Compression.Tests/FirmwareHex/FirmwareHexPayloadTests.cs
new file mode 100644
index 000000000..bbbc898af
--- /dev/null
+++ b/Compression.Tests/FirmwareHex/FirmwareHexPayloadTests.cs
@@ -0,0 +1,37 @@
+using Compression.Registry;
+using FileFormat.FirmwareHex;
+
+namespace Compression.Tests.FirmwareHex;
+
+[TestFixture]
+public sealed class FirmwareHexPayloadTests {
+ [TestCase(false)]
+ [TestCase(true)]
+ public void Create_RejectsMultiplePayloadsWithoutWritingOutput(bool emptyFirst) {
+ var descriptor = new IntelHexFormatDescriptor();
+ using var output = new MemoryStream();
+ var inputs = new[] {
+ ArchiveInputInfo.InMemory("first.bin", emptyFirst ? [] : [1, 2, 3]),
+ ArchiveInputInfo.InMemory("second.bin", [9, 8, 7]),
+ };
+
+ Assert.Throws(() =>
+ ((IArchiveCreatable)descriptor).Create(output, inputs, new FormatCreateOptions()));
+ Assert.That(output.Length, Is.Zero);
+ }
+
+ [Test]
+ public void Add_RejectsAnotherPayloadWithoutChangingTheArchive() {
+ var descriptor = new IntelHexFormatDescriptor();
+ using var archive = new MemoryStream();
+ ((IArchiveCreatable)descriptor).Create(archive,
+ [ArchiveInputInfo.InMemory("firmware.bin", [1, 2, 3])], new FormatCreateOptions());
+ var original = archive.ToArray();
+ archive.Position = 0;
+
+ Assert.Throws(() => ((IArchiveModifiable)descriptor).Add(archive,
+ [ArchiveInputInfo.InMemory("ADD.BIN", [9, 8, 7])]));
+ Assert.That(archive.ToArray(), Is.EqualTo(original));
+ }
+
+}
diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.Cbt/CbtFormatDescriptor.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.Cbt/CbtFormatDescriptor.cs
new file mode 100644
index 000000000..ff5a6f081
--- /dev/null
+++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.Cbt/CbtFormatDescriptor.cs
@@ -0,0 +1,50 @@
+#pragma warning disable CS1591
+using Compression.Registry;
+using FileFormat.Tar;
+
+namespace FileFormat.Cbt;
+
+///
+/// Comic book archive — a POSIX tar container of sequentially named page images, conventionally suffixed .cbt.
+/// Byte-identical to a tar archive, so it is identified by extension only and every operation is the tar one.
+///
+/// References:
+///
+/// - https://en.wikipedia.org/wiki/Comic_book_archive — the .cbt/.cbz/.cbr naming convention
+/// - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/pax.html — POSIX ustar/pax interchange format — the underlying container
+///
+///
+public sealed class CbtFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, IArchiveCreatable, IArchiveModifiable {
+
+ private static readonly TarFormatDescriptor Inner = new();
+
+ public string Id => "Cbt";
+ public string DisplayName => "CBT";
+ public FormatCategory Category => FormatCategory.Archive;
+ // R/W: tar's own native append/remove editors.
+ public FormatCapabilities Capabilities =>
+ FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanCreate |
+ FormatCapabilities.CanModify | FormatCapabilities.CanTest |
+ FormatCapabilities.SupportsMultipleEntries | FormatCapabilities.SupportsDirectories;
+ public string DefaultExtension => ".cbt";
+ public IReadOnlyList Extensions => [".cbt"];
+ public IReadOnlyList CompoundExtensions => [];
+ // No magic: a .cbt is a plain tar, so a content scan resolves to Tar.
+ public IReadOnlyList MagicSignatures => [];
+ public IReadOnlyList Methods => [new("tar", "tar")];
+ public string? TarCompressionFormatId => null;
+ public AlgorithmFamily Family => AlgorithmFamily.Archive;
+ public string Description => "Comic book tar archive";
+
+ public List List(Stream stream, string? password) => Inner.List(stream, password);
+
+ public void Extract(Stream stream, string outputDir, string? password, string[]? files)
+ => Inner.Extract(stream, outputDir, password, files);
+
+ public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options)
+ => Inner.Create(output, inputs, options);
+
+ public void Add(Stream archive, IReadOnlyList inputs) => ((IArchiveModifiable)Inner).Add(archive, inputs);
+
+ public void Remove(Stream archive, string[] entryNames) => ((IArchiveModifiable)Inner).Remove(archive, entryNames);
+}
diff --git a/Hawkynt.FileFormats.Archives/README.md b/Hawkynt.FileFormats.Archives/README.md
index 9357f828e..df41fd62a 100644
--- a/Hawkynt.FileFormats.Archives/README.md
+++ b/Hawkynt.FileFormats.Archives/README.md
@@ -112,6 +112,7 @@ Every State, Test, Maintenance, Compress/Decompress and Demux/Mux/Remux cell is
| [CAB](https://en.wikipedia.org/wiki/Cabinet_(file_format)) | `Cab` | `.cab` | R/W | ✅ | defrag · wipe | | [cabextract.org.uk](https://www.cabextract.org.uk/libmspack/) |
| [CB7](https://en.wikipedia.org/wiki/Comic_book_archive) | `Cb7` | `.cb7` | R/W | ✅ | defrag · wipe | 7z-backed comic book archive | [7-zip.org](https://www.7-zip.org/7z.html) |
| [CBR](https://en.wikipedia.org/wiki/Comic_book_archive) | `Cbr` | `.cbr` | R/W | ✅ | defrag · wipe | RAR-backed comic book archive | [rarlab.com](https://www.rarlab.com/technote.htm) |
+| [CBT](https://en.wikipedia.org/wiki/Comic_book_archive) | `Cbt` | `.cbt` | R/W | ✅ | — | tar-backed comic book archive; 7-Zip reads ours and we read 7-Zip's | [pubs.opengroup.org](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/pax.html) |
| [CBZ](https://en.wikipedia.org/wiki/Comic_book_archive) | `Cbz` | `.cbz` | R/W | ✅ | defrag · wipe | ZIP-backed comic book archive | [pkware.cachefly.net](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT) |
| [CHM](https://en.wikipedia.org/wiki/Microsoft_Compiled_HTML_Help) | `Chm` | `.chm` | R/W | ✅ | defrag · wipe | | [cabextract.org.uk](https://www.cabextract.org.uk/libmspack/) |
| [Compact Pro](https://en.wikipedia.org/wiki/Compact_Pro) | `CompactPro` | `.cpt` | R/W | ✅ | defrag · wipe | | [GitHub](https://github.com/MacPaw/XADMaster) |
@@ -544,7 +545,7 @@ Use it when a .NET process needs to enumerate, extract, test, create, edit or in
-Every public and protected member of all 2571 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Hawkynt.FileFormats.Archives/REFERENCE.md).
+Every public and protected member of all 2572 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Hawkynt.FileFormats.Archives/REFERENCE.md).
diff --git a/Hawkynt.FileFormats.Archives/REFERENCE.md b/Hawkynt.FileFormats.Archives/REFERENCE.md
index 464eb0ba6..a7dc11773 100644
--- a/Hawkynt.FileFormats.Archives/REFERENCE.md
+++ b/Hawkynt.FileFormats.Archives/REFERENCE.md
@@ -12604,6 +12604,37 @@ Implements `IArchiveCreatable`, `IArchiveDefragmentable`, `IArchiveFormatOperati
| `Remove` | `void Remove(Stream archive, string[] entryNames)` | Removes supported non-solid pages directly through the RAR5 block remover. Unsupported encryption, recovery/quick-open, solid dependency and RAR4 cases are rejected before the first byte move and therefore safely fall back to the verified rebuild without an O(total bytes) transaction snapshot. |
| `WipeUnusedSpace` | `long WipeUnusedSpace(Stream image, bool wipeClusterTips = true, bool wipeDeletedEntries = true)` | Zeros every dead byte in the archive: gaps not covered by a live extent in the RAR layout map (markers, block headers, packed data and ENDARC are live and preserved). Cluster-tip wiping is N/A (RAR packs blocks back to back). |
+### Namespace `FileFormat.Cbt`
+
+[`CbtFormatDescriptor`](#cbtformatdescriptor)
+
+#### `CbtFormatDescriptor`
+
+Comic book archive — a POSIX tar container of sequentially named page images, conventionally suffixed .cbt. Byte-identical to a tar archive, so it is identified by extension only and every operation is the tar one. References: `https://en.wikipedia.org/wiki/Comic_book_archive` — the .cbt/.cbz/.cbr naming convention`https://pubs.opengroup.org/onlinepubs/9799919799/utilities/pax.html` — POSIX ustar/pax interchange format — the underlying container
+
+Implements `IArchiveCreatable`, `IArchiveFormatOperations`, `IArchiveModifiable`, `IArchivePurgeable`, `IFormatDescriptor`.
+
+| Member | Signature | Summary |
+| --- | --- | --- |
+| `CbtFormatDescriptor` | `CbtFormatDescriptor()` | |
+| `Capabilities` | `FormatCapabilities Capabilities { get; }` | |
+| `Category` | `FormatCategory Category { get; }` | |
+| `CompoundExtensions` | `IReadOnlyList CompoundExtensions { get; }` | |
+| `DefaultExtension` | `string DefaultExtension { get; }` | |
+| `Description` | `string Description { get; }` | |
+| `DisplayName` | `string DisplayName { get; }` | |
+| `Extensions` | `IReadOnlyList Extensions { get; }` | |
+| `Family` | `AlgorithmFamily Family { get; }` | |
+| `Id` | `string Id { get; }` | |
+| `MagicSignatures` | `IReadOnlyList MagicSignatures { get; }` | |
+| `Methods` | `IReadOnlyList Methods { get; }` | |
+| `TarCompressionFormatId` | `string TarCompressionFormatId { get; }` | |
+| `Add` | `void Add(Stream archive, IReadOnlyList inputs)` | |
+| `Create` | `void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options)` | |
+| `Extract` | `void Extract(Stream stream, string outputDir, string password, string[] files)` | |
+| `List` | `List List(Stream stream, string password)` | |
+| `Remove` | `void Remove(Stream archive, string[] entryNames)` | |
+
### Namespace `FileFormat.Cbz`
[`CbzFormatDescriptor`](#cbzformatdescriptor)
diff --git a/Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.FirmwareHex/FirmwareHexWriter.cs b/Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.FirmwareHex/FirmwareHexWriter.cs
index 6c7d4b452..1c9e17266 100644
--- a/Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.FirmwareHex/FirmwareHexWriter.cs
+++ b/Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.FirmwareHex/FirmwareHexWriter.cs
@@ -33,6 +33,7 @@ public static class FirmwareHexWriter {
/// one the reader rendered. An input list with no payload describes an image
/// with no data, which both formats can write.
///
+ /// More than one non-metadata payload is supplied.
public static FirmwareImage ImageFrom(IReadOnlyList inputs, string sourceFormat) {
ArgumentNullException.ThrowIfNull(inputs);
@@ -53,7 +54,9 @@ public static FirmwareImage ImageFrom(IReadOnlyList inputs, st
}
// Any other single input is the payload, whatever it is called: a caller
// converting a .bin into a .hex should not have to rename it first.
- payload ??= input.ReadContent();
+ if (payload is not null)
+ throw new InvalidDataException($"{sourceFormat} represents one logical firmware payload; multiple payload inputs are ambiguous.");
+ payload = input.ReadContent();
}
List<(uint Address, byte[] Data)> segments;
diff --git a/docs/catalogue/reference-formats.csv b/docs/catalogue/reference-formats.csv
index d168a2e19..1aa10445a 100644
--- a/docs/catalogue/reference-formats.csv
+++ b/docs/catalogue/reference-formats.csv
@@ -6,7 +6,7 @@ Enterprise Archive (EAR),ear,archive,,,,,,,Y,1,https://jakarta.ee/specifications
Android Package (APK),apk,package,,,,,,fmt/2041,Y,2,https://developer.android.com/guide/components/fundamentals,Apk,Y,Y,Y,OK,,
Android App Bundle,aab;apks,package,,,,,,fmt/2042,,1,https://developer.android.com/guide/app-bundle/app-bundle-format,AndroidBundle,Y,Y,Y,OK,,
Android Archive (AAR library),aar,package,,,,,,fmt/2043,,1,https://developer.android.com/studio/projects/android-library#aar-contents,,,,,MISSING,,
-Comic Book Archive,cbz;cbr;cb7;cbt;cba,archive,,,,,,fmt/1462,,1,https://en.wikipedia.org/wiki/Comic_book_archive,Cbz;Cbr;Cb7,Y,Y,Y,PARTIAL,,.cbt (tar) and .cba (ACE) have no descriptor
+Comic Book Archive,cbz;cbr;cb7;cbt;cba,archive,,,,,,fmt/1462,,1,https://en.wikipedia.org/wiki/Comic_book_archive,Cbz;Cbr;Cb7;Cbt,Y,Y,Y,PARTIAL,,".cba (ACE) has no descriptor: the ACE reader/writer is not conformant (acefile and unar reject its output), so a CBA view would inherit wrong bytes"
Chrome extension (CRX),crx,package,,,,,,,Y,1,https://en.wikipedia.org/wiki/Google_Chrome#Extensions,Crx,Y,Y,,OK,,
MSIX / APPX,msix;appx;msixbundle;appxbundle;emsix;emsixbundle,package,,,,,,,Y,1,https://learn.microsoft.com/windows/msix/overview,Appx;Msix,Y,Y,Y,OK,,
7z,7z;s7z;001;exe,archive,RW,RW,R,R,R,fmt/484,RW,7,https://www.7-zip.org/7z.html,SevenZip,Y,Y,Y,PARTIAL,,"Deflate64, ARM64, RISC-V, Zstd/Brotli/LZ4 coders and external header references unsupported (SevenZipReader.cs)"