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)"