diff --git a/Compression.Core/README.md b/Compression.Core/README.md index e918da817..e5e126063 100644 --- a/Compression.Core/README.md +++ b/Compression.Core/README.md @@ -205,7 +205,7 @@ Use the concrete version you intend to consume; this document does not predict a -Every public and protected member of all 734 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Compression.Core/REFERENCE.md). +Every public and protected member of all 735 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Compression.Core/REFERENCE.md). diff --git a/Compression.Core/REFERENCE.md b/Compression.Core/REFERENCE.md index 86510a075..016c44360 100644 --- a/Compression.Core/REFERENCE.md +++ b/Compression.Core/REFERENCE.md @@ -9284,7 +9284,15 @@ Implements `IEquatable`. ### Namespace `FileFormat.Bzip2` -[`Bzip2BuildingBlock`](#bzip2buildingblock) · [`Bzip2FormatDescriptor`](#bzip2formatdescriptor) · [`Bzip2Stream`](#bzip2stream) +[`AlzBzip2`](#alzbzip2) · [`Bzip2BuildingBlock`](#bzip2buildingblock) · [`Bzip2FormatDescriptor`](#bzip2formatdescriptor) · [`Bzip2Stream`](#bzip2stream) + +#### `AlzBzip2` + +ALZip's bzip2 variant (ALZ compression method 1): no "BZh" stream header, each block introduced by the 32-bit marker "DLZ\x01" instead of the 48-bit block magic, no block CRC and no randomised bit (the block starts at its BWT origin pointer), and the stream ended by "DLZ\x02" without a combined CRC. The block payload is standard bzip2 (900k blocks). Layout as documented by alkegi/unalz (MIT) and confirmed against its CC0 test archives; integrity rests on the ALZ entry's CRC-32. + +| Member | Signature | Summary | +| --- | --- | --- | +| `Decompress` | `static byte[] Decompress(ReadOnlySpan data, long expectedSize)` | Decodes an ALZ bzip2 stream; stops at the end marker or once `expectedSize` bytes are out. | #### `Bzip2BuildingBlock` diff --git a/Compression.Core/Streams/Bzip2/AlzBzip2.cs b/Compression.Core/Streams/Bzip2/AlzBzip2.cs new file mode 100644 index 000000000..d039ee0d6 --- /dev/null +++ b/Compression.Core/Streams/Bzip2/AlzBzip2.cs @@ -0,0 +1,39 @@ +using Compression.Core.BitIO; + +namespace FileFormat.Bzip2; + +/// +/// ALZip's bzip2 variant (ALZ compression method 1): no "BZh" stream header, each block introduced by +/// the 32-bit marker "DLZ\x01" instead of the 48-bit block magic, no block CRC and no randomised bit +/// (the block starts at its BWT origin pointer), and the stream ended by "DLZ\x02" without a combined +/// CRC. The block payload is standard bzip2 (900k blocks). Layout as documented by alkegi/unalz +/// (MIT) and confirmed against its CC0 test archives; integrity rests on the ALZ entry's CRC-32. +/// +public static class AlzBzip2 { + private const uint BlockMarker = 0x444C5A01; // "DLZ\x01" read MSB first + private const uint EndMarker = 0x444C5A02; // "DLZ\x02" + + /// Decodes an ALZ bzip2 stream; stops at the end marker or once bytes are out. + public static byte[] Decompress(ReadOnlySpan data, long expectedSize) { + if (expectedSize > int.MaxValue) + throw new NotSupportedException("ALZ members larger than 2 GiB are not supported."); + using var input = new MemoryStream(data.ToArray(), writable: false); + var bits = new BitBuffer(input); + using var output = new MemoryStream((int)expectedSize); + try { + while (output.Length < expectedSize) { + var marker = (bits.ReadBits(16) << 16) | bits.ReadBits(16); + if (marker == EndMarker) + break; + if (marker != BlockMarker) + throw new InvalidDataException($"ALZ bzip2: expected a DLZ block marker, found 0x{marker:X8}."); + output.Write(Bzip2Decompressor.DecodeBlockBody(bits)); + } + } catch (EndOfStreamException e) { + throw new InvalidDataException("Truncated ALZ bzip2 stream.", e); + } + if (output.Length != expectedSize) + throw new InvalidDataException($"ALZ bzip2 produced {output.Length} bytes, the entry declares {expectedSize}."); + return output.ToArray(); + } +} diff --git a/Compression.Core/Streams/Bzip2/Bzip2Decompressor.cs b/Compression.Core/Streams/Bzip2/Bzip2Decompressor.cs index 40f187d3a..32ed7abfb 100644 --- a/Compression.Core/Streams/Bzip2/Bzip2Decompressor.cs +++ b/Compression.Core/Streams/Bzip2/Bzip2Decompressor.cs @@ -96,7 +96,7 @@ private bool ReadNextBlock() { if (randomized != 0) throw new InvalidDataException("Randomized bzip2 blocks are not supported."); - var blockData = this.DecodeBlockBody(); + var blockData = DecodeBlockBody(this._bits); // Verify block CRC var computedCrc = Crc32Bzip2(blockData); @@ -122,7 +122,7 @@ private bool ReadNextNsisBlock() { this._finished = true; return false; case NsisBlockMarker: - this._currentBlock = this.DecodeBlockBody(); + this._currentBlock = DecodeBlockBody(this._bits); this._currentBlockPos = 0; return true; case var other: @@ -130,12 +130,16 @@ private bool ReadNextNsisBlock() { } } - private byte[] DecodeBlockBody() { + /// + /// Decodes one block from its BWT origin pointer onward (what follows the block magic, CRC and + /// randomised bit in a standard stream; ALZip's variant omits those three). + /// + internal static byte[] DecodeBlockBody(BitBuffer bits) { // BWT original pointer (24 bits) - var bwtIndex = (int)this._bits.ReadBits(24); + var bwtIndex = (int)bits.ReadBits(24); // Read symbol bitmap - var symbolUsed = ReadSymbolBitmap(); + var symbolUsed = ReadSymbolBitmap(bits); // Count symbols in use var numSymbolsInUse = 0; @@ -146,16 +150,16 @@ private byte[] DecodeBlockBody() { var alphaSize = eobSymbol + 1; // Number of trees (3 bits) - var numTrees = (int)this._bits.ReadBits(3); + var numTrees = (int)bits.ReadBits(3); // Number of selectors (15 bits) - var numSelectors = (int)this._bits.ReadBits(15); + var numSelectors = (int)bits.ReadBits(15); // Read selectors (unary coded, MTF encoded) var selectorsMtf = new int[numSelectors]; for (var i = 0; i < numSelectors; ++i) { var v = 0; - while (this._bits.ReadBits(1) == 1) + while (bits.ReadBits(1) == 1) ++v; selectorsMtf[i] = v; } @@ -180,10 +184,10 @@ private byte[] DecodeBlockBody() { var huffTables = new CanonicalHuffman[numTrees]; for (var t = 0; t < numTrees; ++t) { var codeLens = new int[alphaSize]; - var currentLen = (int)this._bits.ReadBits(5); + var currentLen = (int)bits.ReadBits(5); for (var s = 0; s < alphaSize; ++s) { - while (this._bits.ReadBits(1) == 1) { - if (this._bits.ReadBits(1) == 0) + while (bits.ReadBits(1) == 1) { + if (bits.ReadBits(1) == 0) ++currentLen; else --currentLen; @@ -205,7 +209,7 @@ private byte[] DecodeBlockBody() { } var tableIdx = selectors[Math.Min(groupIndex, numSelectors - 1)]; - var sym = huffTables[tableIdx].DecodeSymbol(this._bits); + var sym = huffTables[tableIdx].DecodeSymbol(bits); ++symbolInGroup; if (sym == eobSymbol) @@ -236,15 +240,15 @@ private byte[] DecodeBlockBody() { return Bzip2Compressor.Rle1Decode(rle1Data); } - private bool[] ReadSymbolBitmap() { + private static bool[] ReadSymbolBitmap(BitBuffer bits) { var used = new bool[256]; - var inUse16 = this._bits.ReadBits(16); + var inUse16 = bits.ReadBits(16); for (var i = 0; i < 16; ++i) { if ((inUse16 & (1u << (15 - i))) != 0) { - var bits = this._bits.ReadBits(16); + var word = bits.ReadBits(16); for (var j = 0; j < 16; ++j) { - if ((bits & (1u << (15 - j))) != 0) + if ((word & (1u << (15 - j))) != 0) used[i * 16 + j] = true; } } diff --git a/Compression.Tests/AlZip/AlZipModifierTests.cs b/Compression.Tests/AlZip/AlZipModifierTests.cs deleted file mode 100644 index b7c7cd770..000000000 --- a/Compression.Tests/AlZip/AlZipModifierTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -#pragma warning disable CS1591 -using Compression.Registry; -using FileFormat.AlZip; - -namespace Compression.Tests.AlZip; - -[TestFixture] -public class AlZipModifierTests { - - [Test, Category("RoundTrip")] - public void AddFile_ReadsBack() { - var ms = BuildSeedAlz(); - AlZipModifier.AddFile(ms, "added.txt", "hello-alz"u8.ToArray()); - - ms.Position = 0; - using var r = new AlZipReader(ms, leaveOpen: true); - var byName = r.Entries.ToDictionary(e => e.FileName, e => r.Extract(e)); - Assert.That(byName["added.txt"], Is.EqualTo("hello-alz"u8.ToArray())); - Assert.That(byName["seed.txt"], Is.EqualTo("seed-content"u8.ToArray())); - } - - [Test, Category("RoundTrip")] - public void AddFile_LargeFile_RoundTrips() { - var ms = BuildSeedAlz(); - var data = new byte[5000]; - for (var i = 0; i < data.Length; i++) data[i] = (byte)((i * 13 + 1) & 0xFF); - AlZipModifier.AddFile(ms, "big.bin", data); - - ms.Position = 0; - using var r = new AlZipReader(ms, leaveOpen: true); - var big = r.Entries.First(e => e.FileName == "big.bin"); - Assert.That(r.Extract(big), Is.EqualTo(data)); - } - - [Test, Category("RoundTrip")] - public void RemoveFile_DropsEntry() { - var ms = BuildSeedAlz(); - AlZipModifier.AddFile(ms, "victim.txt", "delete-me"u8.ToArray()); - AlZipModifier.AddFile(ms, "keeper.txt", "keep-me"u8.ToArray()); - Assert.That(AlZipModifier.RemoveFile(ms, "victim.txt"), Is.True); - - ms.Position = 0; - using var r = new AlZipReader(ms, leaveOpen: true); - var names = r.Entries.Select(e => e.FileName).ToHashSet(); - Assert.That(names, Does.Not.Contain("victim.txt")); - Assert.That(names, Contains.Item("keeper.txt")); - Assert.That(names, Contains.Item("seed.txt")); - - var byName = r.Entries.ToDictionary(e => e.FileName, e => r.Extract(e)); - Assert.That(byName["keeper.txt"], Is.EqualTo("keep-me"u8.ToArray())); - Assert.That(byName["seed.txt"], Is.EqualTo("seed-content"u8.ToArray())); - } - - [Test, Category("RoundTrip")] - public void RemoveFile_NotFound_ReturnsFalse() { - var ms = BuildSeedAlz(); - Assert.That(AlZipModifier.RemoveFile(ms, "ghost.txt"), Is.False); - } - - [Test, Category("RoundTrip")] - public void Replace_ViaRemoveAdd_SwapsContent() { - var ms = BuildSeedAlz(); - AlZipModifier.AddFile(ms, "doc.txt", "version-one"u8.ToArray()); - AlZipModifier.RemoveFile(ms, "doc.txt"); - AlZipModifier.AddFile(ms, "doc.txt", "version-two-replacement"u8.ToArray()); - - ms.Position = 0; - using var r = new AlZipReader(ms, leaveOpen: true); - var doc = r.Entries.Single(e => e.FileName == "doc.txt"); - Assert.That(r.Extract(doc), Is.EqualTo("version-two-replacement"u8.ToArray())); - } - - [Test, Category("RoundTrip")] - public void Descriptor_AddViaInterface_UsesInPlacePath() { - var ms = BuildSeedAlz(); - var tmp = Path.GetTempFileName(); - try { - File.WriteAllBytes(tmp, "via-if"u8.ToArray()); - ((IArchiveModifiable)new AlZipFormatDescriptor()).Add(ms, - [new ArchiveInputInfo(tmp, "via-if.txt", false)]); - - ms.Position = 0; - using var r = new AlZipReader(ms, leaveOpen: true); - var entry = r.Entries.Single(e => e.FileName == "via-if.txt"); - Assert.That(r.Extract(entry), Is.EqualTo("via-if"u8.ToArray())); - } finally { File.Delete(tmp); } - } - - [Test, Category("RoundTrip")] - public void Descriptor_RemoveViaInterface_DropsEntry() { - var ms = BuildSeedAlz(); - AlZipModifier.AddFile(ms, "drop.txt", "x"u8.ToArray()); - ((IArchiveModifiable)new AlZipFormatDescriptor()).Remove(ms, ["drop.txt"]); - - ms.Position = 0; - using var r = new AlZipReader(ms, leaveOpen: true); - Assert.That(r.Entries.Select(e => e.FileName), Does.Not.Contain("drop.txt")); - } - - // ── Helpers ──────────────────────────────────────────────────────────── - - private static MemoryStream BuildSeedAlz() { - var ms = new MemoryStream(); - using (var w = new AlZipWriter(ms, leaveOpen: true)) - w.AddFile("seed.txt", "seed-content"u8.ToArray()); - ms.Position = 0; - var copy = new MemoryStream(); - ms.CopyTo(copy); - return copy; - } -} diff --git a/Compression.Tests/AlZip/AlZipReferenceTests.cs b/Compression.Tests/AlZip/AlZipReferenceTests.cs new file mode 100644 index 000000000..466e38df2 --- /dev/null +++ b/Compression.Tests/AlZip/AlZipReferenceTests.cs @@ -0,0 +1,122 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using FileFormat.AlZip; + +namespace Compression.Tests.AlZip; + +/// +/// ALZip against unalz (alkegi/unalz, MIT). Fixtures: unalz's CC0 test archives (written by ALZip; +/// stored, deflate, ALZ-bzip2, ZipCrypto, CP949 names, directories) and archives our writer produced; +/// unalz-listing.txt is unalz's extraction of each. Every member is also checked against the +/// CRC-32 ALZip stored for it. See ReferenceVectors/README.md. +/// +[TestFixture] +public class AlZipReferenceTests { + + private const string Password = "test1234"; + + private static byte[] Vector(string name) { + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream($"AlZipVectors.{name}") + ?? throw new FileNotFoundException($"Embedded ALZip vector '{name}' is missing from the test assembly."); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } + + private static List Listing(string vector) + => Encoding.UTF8.GetString(Vector("unalz-listing.txt")) + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.TrimEnd('\r').Split('\t')) + .Where(p => p[0] == vector) + .ToList(); + + internal static readonly DateTime FixedTime = new(2024, 5, 6, 7, 8, 10); + internal static readonly byte[] Text = Encoding.ASCII.GetBytes(string.Concat(Enumerable.Range(0, 300).Select(i => $"ALZip line {i % 17}\r\n"))); + internal static readonly byte[] Noise = [.. Enumerable.Range(0, 3000).Select(i => (byte)((i * 2654435761u) >> 24))]; + + internal static byte[] OursArchive(string name) { + var (store, password) = name switch { + "ours-deflate.alz" => (false, (string?)null), + "ours-store.alz" => (true, null), + "ours-encrypted.alz" => (false, Password), + _ => throw new ArgumentOutOfRangeException(nameof(name)), + }; + using var ms = new MemoryStream(); + using (var w = new AlZipWriter(ms, leaveOpen: true, password: password, store: store)) { + w.AddDirectory("docs", FixedTime); + w.AddFile("docs/text.txt", Text, FixedTime); + w.AddFile("noise.bin", Noise, FixedTime); + w.AddFile("empty.txt", [], FixedTime); + w.AddFile("한글/이름.txt", Text[..100], FixedTime); + } + return ms.ToArray(); + } + + [TestCase("bzip2.alz")] + [TestCase("bzip2_dlz.alz")] + [TestCase("deflate_pure.alz")] + [TestCase("deflate_empty.alz")] + [TestCase("dir_tree.alz")] + [TestCase("store_old.alz")] + [TestCase("store_empty.alz")] + [TestCase("enc_deflate.alz")] + [TestCase("enc_store.alz")] + [TestCase("zip20.alz")] + [TestCase("ours-deflate.alz")] + [TestCase("ours-store.alz")] + [TestCase("ours-encrypted.alz")] + [Category("ThirdPartyVector")] + public void Given_Archive_When_Extracted_Then_MatchesUnalz(string vector) { + var expected = Listing(vector).ToDictionary(p => p[1], p => (Size: long.Parse(p[2]), Sha: p[3])); + using var reader = new AlZipReader(new MemoryStream(Vector(vector))); + var files = reader.Entries.Where(e => !e.IsDirectory).ToList(); + Assert.That(files.Select(e => e.FileName), Is.EquivalentTo(expected.Keys)); + foreach (var e in files) { + var data = reader.Extract(e, Password); + Assert.That(data.LongLength, Is.EqualTo(expected[e.FileName].Size), e.FileName); + Assert.That(Convert.ToHexStringLower(SHA256.HashData(data)), Is.EqualTo(expected[e.FileName].Sha), e.FileName); + } + } + + [TestCase("ours-deflate.alz")] + [TestCase("ours-store.alz")] + [Category("ThirdPartyVector")] + public void Given_SameInputs_When_Written_Then_ByteIdenticalToWhatUnalzRead(string vector) + => Assert.That(OursArchive(vector), Is.EqualTo(Vector(vector))); + + [Test, Category("ThirdPartyVector")] + public void Given_Bzip2Archive_When_Listed_Then_MethodIsBzip2() { + using var reader = new AlZipReader(new MemoryStream(Vector("bzip2.alz"))); + Assert.That(reader.Entries.Where(e => !e.IsDirectory).Select(e => e.Method).Distinct(), Is.EqualTo(new[] { 1 })); + } + + [Test, Category("EdgeCase")] + public void Given_EncryptedMember_When_WrongOrNoPassword_Then_Refused() { + using var reader = new AlZipReader(new MemoryStream(Vector("enc_deflate.alz"))); + var e = reader.Entries.First(x => !x.IsDirectory && x.OriginalSize > 0); + Assert.Throws(() => reader.Extract(e)); + Assert.Throws(() => reader.Extract(e, "wrong-password")); + } + + [Test, Category("EdgeCase")] + public void Given_CorruptMember_When_Extracted_Then_CrcRefuses() { + var bytes = Vector("store_old.alz"); + using var probe = new AlZipReader(new MemoryStream(bytes)); + var e = probe.Entries.First(x => !x.IsDirectory && x.OriginalSize > 0); + bytes[(int)e.DataOffset] ^= 0x20; + using var reader = new AlZipReader(new MemoryStream(bytes)); + Assert.Throws(() => reader.Extract(reader.Entries.First(x => x.FileName == e.FileName))); + } + + [Test, Category("EdgeCase")] + public void Given_NotAlz_When_Read_Then_InvalidData() + => Assert.Throws(() => _ = new AlZipReader(new MemoryStream("PK\x03\x04not alz"u8.ToArray()))); + + [Test, Category("EdgeCase")] + public void Given_UnknownSignature_When_Read_Then_InvalidData() { + var bytes = Vector("deflate_pure.alz"); + bytes[8] = (byte)'X'; // first local header signature + Assert.Throws(() => _ = new AlZipReader(new MemoryStream(bytes))); + } +} diff --git a/Compression.Tests/AlZip/AlZipTests.cs b/Compression.Tests/AlZip/AlZipTests.cs index be0469bcf..b0c781e59 100644 --- a/Compression.Tests/AlZip/AlZipTests.cs +++ b/Compression.Tests/AlZip/AlZipTests.cs @@ -108,7 +108,7 @@ public void Directory_Entry() { using var r = new FileFormat.AlZip.AlZipReader(archive, leaveOpen: true); Assert.That(r.Entries, Has.Count.EqualTo(2)); Assert.That(r.Entries[0].IsDirectory, Is.True); - Assert.That(r.Entries[0].FileName, Is.EqualTo("mydir/")); + Assert.That(r.Entries[0].FileName, Is.EqualTo("mydir"), "ALZip stores directory names without a trailing separator (unalz dir_tree.alz)"); Assert.That(r.Entries[1].IsDirectory, Is.False); Assert.That(r.Extract(r.Entries[1]), Is.EqualTo("hello"u8.ToArray())); } diff --git a/Compression.Tests/AlZip/ReferenceVectors/.gitattributes b/Compression.Tests/AlZip/ReferenceVectors/.gitattributes new file mode 100644 index 000000000..68a0af7e8 --- /dev/null +++ b/Compression.Tests/AlZip/ReferenceVectors/.gitattributes @@ -0,0 +1,2 @@ +# Fixture bytes are the test: never let a checkout rewrite line endings. +*.alz -text diff --git a/Compression.Tests/AlZip/ReferenceVectors/README.md b/Compression.Tests/AlZip/ReferenceVectors/README.md new file mode 100644 index 000000000..2be75616b --- /dev/null +++ b/Compression.Tests/AlZip/ReferenceVectors/README.md @@ -0,0 +1,10 @@ +# ALZip reference vectors + +| File | Origin | +| --- | --- | +| `bzip2.alz`, `bzip2_dlz.alz`, `deflate_pure.alz`, `deflate_empty.alz`, `dir_tree.alz`, `store_old.alz`, `store_empty.alz`, `enc_deflate.alz`, `enc_store.alz`, `zip20.alz` | unalz's test archives (`tests/data/`, alkegi/unalz master, fetched 2026-10-23), written by ALZip and dedicated to the public domain under CC0 1.0 by their author. Encrypted ones use the password `test1234`. | +| `ours-deflate.alz`, `ours-store.alz`, `ours-encrypted.alz` | written by our `AlZipWriter` from `AlZipReferenceTests.OursArchive` (fixed timestamps; the encrypted one with password `test1234`, its 12-byte headers random). | +| `unalz-listing.txt` | unalz 0.2.1 (built from that revision with cargo, Windows) extracting every archive above with `-P test1234`: path, size and SHA-256 of each file. | + +ALZip stores a CRC-32 for every member, so the reader's own CRC check also ties each decoded member to +what ALZip wrote. diff --git a/Compression.Tests/AlZip/ReferenceVectors/bzip2.alz b/Compression.Tests/AlZip/ReferenceVectors/bzip2.alz new file mode 100644 index 000000000..9f005041e Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/bzip2.alz differ diff --git a/Compression.Tests/AlZip/ReferenceVectors/bzip2_dlz.alz b/Compression.Tests/AlZip/ReferenceVectors/bzip2_dlz.alz new file mode 100644 index 000000000..befeb7936 Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/bzip2_dlz.alz differ diff --git a/Compression.Tests/AlZip/ReferenceVectors/deflate_empty.alz b/Compression.Tests/AlZip/ReferenceVectors/deflate_empty.alz new file mode 100644 index 000000000..28a7a3325 Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/deflate_empty.alz differ diff --git a/Compression.Tests/AlZip/ReferenceVectors/deflate_pure.alz b/Compression.Tests/AlZip/ReferenceVectors/deflate_pure.alz new file mode 100644 index 000000000..60caf4d41 Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/deflate_pure.alz differ diff --git a/Compression.Tests/AlZip/ReferenceVectors/dir_tree.alz b/Compression.Tests/AlZip/ReferenceVectors/dir_tree.alz new file mode 100644 index 000000000..7e177c388 Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/dir_tree.alz differ diff --git a/Compression.Tests/AlZip/ReferenceVectors/enc_deflate.alz b/Compression.Tests/AlZip/ReferenceVectors/enc_deflate.alz new file mode 100644 index 000000000..45768e2ab Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/enc_deflate.alz differ diff --git a/Compression.Tests/AlZip/ReferenceVectors/enc_store.alz b/Compression.Tests/AlZip/ReferenceVectors/enc_store.alz new file mode 100644 index 000000000..d95b5fbd9 Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/enc_store.alz differ diff --git a/Compression.Tests/AlZip/ReferenceVectors/ours-deflate.alz b/Compression.Tests/AlZip/ReferenceVectors/ours-deflate.alz new file mode 100644 index 000000000..46d84f6db Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/ours-deflate.alz differ diff --git a/Compression.Tests/AlZip/ReferenceVectors/ours-encrypted.alz b/Compression.Tests/AlZip/ReferenceVectors/ours-encrypted.alz new file mode 100644 index 000000000..8bf1c2b10 Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/ours-encrypted.alz differ diff --git a/Compression.Tests/AlZip/ReferenceVectors/ours-store.alz b/Compression.Tests/AlZip/ReferenceVectors/ours-store.alz new file mode 100644 index 000000000..3cb5bcef3 Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/ours-store.alz differ diff --git a/Compression.Tests/AlZip/ReferenceVectors/store_empty.alz b/Compression.Tests/AlZip/ReferenceVectors/store_empty.alz new file mode 100644 index 000000000..270ea0600 Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/store_empty.alz differ diff --git a/Compression.Tests/AlZip/ReferenceVectors/store_old.alz b/Compression.Tests/AlZip/ReferenceVectors/store_old.alz new file mode 100644 index 000000000..fba3db400 Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/store_old.alz differ diff --git a/Compression.Tests/AlZip/ReferenceVectors/unalz-listing.txt b/Compression.Tests/AlZip/ReferenceVectors/unalz-listing.txt new file mode 100644 index 000000000..7e1110882 --- /dev/null +++ b/Compression.Tests/AlZip/ReferenceVectors/unalz-listing.txt @@ -0,0 +1,44 @@ +bzip2.alz a.txt 2340 fa80a13de5c796b12cd4689588599fc15d85540369114d3cafff16edfed42f27 +bzip2.alz b.txt 2160 388048314e46e0249d96324a220754c01c1c728821fbabb1bf88f7314bd2dea5 +bzip2_dlz.alz text.txt 3600 f347c6956986bb5e2616fd001f81f0fe7d4fcbf68456613b4ed6a2641d29b82a +deflate_empty.alz empty.txt 0 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +deflate_empty.alz precompressed.gz 95 07ec682eb8d41b54afe790caa15773059968ff0befb3b090ef96a57856980f98 +deflate_empty.alz prog.sys 2060 a94e58afa385e60d53d5920852dff86ba296f34b25b12a7e9c8b3a6de4d71132 +deflate_empty.alz random.bin 4096 c7caf0d1f590099d235bdd36c659a428e9f7780ccf4ee44128b1150884f45e33 +deflate_empty.alz text.txt 3150 b2a9316bdcae0fd397a8430e94a6294a4ea720c4faf9e1f77d4e4e30787ba6ac +deflate_pure.alz text.txt 9000 2c44089c1478988b4f2d75dcce66a08008c0ab65e71877a8064ec93154e8b380 +dir_tree.alz work/tree/sub/deeper/deepest/three.txt 16 03c29cb7bd343b7281e59b3b64cf552a733272381841232baa063358e3de7c12 +dir_tree.alz work/tree/sub/deeper/two.txt 8 04d0ef58d049b7a6620aa4ead8455d7c3613568c9d136a60e1afe898783dadae +dir_tree.alz work/tree/sub/one.txt 8 84d80ad6c9d14e2373568ddfc660a1bb4337355a2efaaaa0abbdce32091a2f64 +dir_tree.alz work/tree/top.txt 10 33685921186a8fed52c9db4ea189ae5c1c755f5f148c9af16485362b5cef0163 +enc_deflate.alz a.txt 3150 b2a9316bdcae0fd397a8430e94a6294a4ea720c4faf9e1f77d4e4e30787ba6ac +enc_deflate.alz b.bin 512 f97a9576c02fa95055ddca8bf384caf3fbcb229bb78d0d782a322a558712f2eb +enc_store.alz text.txt 3600 f347c6956986bb5e2616fd001f81f0fe7d4fcbf68456613b4ed6a2641d29b82a +ours-deflate.alz docs/text.txt 4320 18862dacf0b4eca9e545f41065d971e81f50ca64c00177c6a1b11d74c4abfd0e +ours-deflate.alz empty.txt 0 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +ours-deflate.alz noise.bin 3000 416b6d6783fba12823f4180fe7b9b7a238932d6eed210a91e66122650cce468d +ours-deflate.alz 한글/이름.txt 100 69e9ad63e9666bf5a0ce5bd7ff8726d7ac9c84e0e809a8ce5f21dccfab3989e4 +ours-encrypted.alz docs/text.txt 4320 18862dacf0b4eca9e545f41065d971e81f50ca64c00177c6a1b11d74c4abfd0e +ours-encrypted.alz empty.txt 0 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +ours-encrypted.alz noise.bin 3000 416b6d6783fba12823f4180fe7b9b7a238932d6eed210a91e66122650cce468d +ours-encrypted.alz 한글/이름.txt 100 69e9ad63e9666bf5a0ce5bd7ff8726d7ac9c84e0e809a8ce5f21dccfab3989e4 +ours-store.alz docs/text.txt 4320 18862dacf0b4eca9e545f41065d971e81f50ca64c00177c6a1b11d74c4abfd0e +ours-store.alz empty.txt 0 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +ours-store.alz noise.bin 3000 416b6d6783fba12823f4180fe7b9b7a238932d6eed210a91e66122650cce468d +ours-store.alz 한글/이름.txt 100 69e9ad63e9666bf5a0ce5bd7ff8726d7ac9c84e0e809a8ce5f21dccfab3989e4 +store_empty.alz empty.txt 0 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +store_empty.alz precompressed.gz 95 07ec682eb8d41b54afe790caa15773059968ff0befb3b090ef96a57856980f98 +store_empty.alz prog.sys 2060 a94e58afa385e60d53d5920852dff86ba296f34b25b12a7e9c8b3a6de4d71132 +store_empty.alz random.bin 4096 c7caf0d1f590099d235bdd36c659a428e9f7780ccf4ee44128b1150884f45e33 +store_empty.alz text.txt 3150 b2a9316bdcae0fd397a8430e94a6294a4ea720c4faf9e1f77d4e4e30787ba6ac +store_old.alz text.txt 3600 f347c6956986bb5e2616fd001f81f0fe7d4fcbf68456613b4ed6a2641d29b82a +zip20.alz binary.bin 1024 785b0751fc2c53dc14a4ce3d800e69ef9ce1009eb327ccf458afe09c242c26c9 +zip20.alz empty.txt 0 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +zip20.alz euckr_content.txt 71 77bc237fbcc49c3138cc308632aa91e57efeffa535a988adbfac2f48bf0d9472 +zip20.alz hello.txt 131 d18ec9260c5c6902d8fc74c07cc092f45a2c4e6074e4a5b3cdeaf760eb96e7ce +zip20.alz large.txt 131364 176933d71d6ef16903b2ee4f61947af12fa8c4c450d747e4569a9bcf9267da53 +zip20.alz repeated.txt 720 1cec0d613b135373d084c68685b7476fb7c0d7f5caa28f35946ea6d302ba9225 +zip20.alz subdir/inner.txt 28 347c0d7c9a1ff5d5e1710b05dc385dea8651892de493ae0f7eade2e93e8b0c0e +zip20.alz subdir/nested/deep.txt 20 66c77c742ffe88ea0fa1e085e32b20de9f0cfd763ebdb08d27df20e6a4bb1090 +zip20.alz 뷁테스트.txt 72 696f6644df6b920646bf3e92745923109d0cfc8ad470818dde56176179408090 +zip20.alz 한글파일.txt 56 0442367f9479d95a35b91a6b80ac5f7ffc199aa91f29855b6a012070dc97af08 diff --git a/Compression.Tests/AlZip/ReferenceVectors/zip20.alz b/Compression.Tests/AlZip/ReferenceVectors/zip20.alz new file mode 100644 index 000000000..b40b89b85 Binary files /dev/null and b/Compression.Tests/AlZip/ReferenceVectors/zip20.alz differ diff --git a/Compression.Tests/Compression.Tests.csproj b/Compression.Tests/Compression.Tests.csproj index 023dbbe39..f762df3b2 100644 --- a/Compression.Tests/Compression.Tests.csproj +++ b/Compression.Tests/Compression.Tests.csproj @@ -146,6 +146,10 @@ ZfsVectors.%(Filename)%(Extension) + + + AlZipVectors.%(Filename)%(Extension) + G722Vectors.%(Filename)%(Extension) 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.AlZip/AlZipCrypto.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipCrypto.cs new file mode 100644 index 000000000..65379e887 --- /dev/null +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipCrypto.cs @@ -0,0 +1,74 @@ +namespace FileFormat.AlZip; + +/// +/// ALZip's encryption: PKWARE's traditional ZIP cipher (APPNOTE 6.1), password bytes in CP949, with +/// a 12-byte header whose last decrypted byte checks the password against the CRC's top byte (or the +/// DOS time's second byte when the entry has a data descriptor). +/// +internal sealed class AlZipCrypto { + public const int HeaderLength = 12; + private static readonly uint[] CrcTable = BuildTable(); + private uint _k0 = 305419896, _k1 = 591751049, _k2 = 878082192; + + private static uint[] BuildTable() { + var t = new uint[256]; + for (var i = 0u; i < 256; ++i) { + var c = i; + for (var j = 0; j < 8; ++j) + c = (c & 1) != 0 ? (c >> 1) ^ 0xEDB88320u : c >> 1; + t[i] = c; + } + return t; + } + + public AlZipCrypto(ReadOnlySpan password) { + foreach (var b in password) + this.Update(b); + } + + private void Update(byte c) { + this._k0 = CrcTable[(this._k0 ^ c) & 0xFF] ^ (this._k0 >> 8); + this._k1 = (this._k1 + (this._k0 & 0xFF)) * 134775813 + 1; + this._k2 = CrcTable[(this._k2 ^ (this._k1 >> 24)) & 0xFF] ^ (this._k2 >> 8); + } + + private byte KeyStream() { + var t = (ushort)(this._k2 | 2); + return (byte)((t * (t ^ 1)) >> 8); + } + + /// Decrypts the 12-byte header and reports whether its check byte matches. + public bool CheckHeader(ReadOnlySpan header, byte expected) { + byte last = 0; + foreach (var b in header) { + last = (byte)(b ^ this.KeyStream()); + this.Update(last); + } + return last == expected; + } + + public void Decrypt(Span data) { + for (var i = 0; i < data.Length; ++i) { + var p = (byte)(data[i] ^ this.KeyStream()); + this.Update(p); + data[i] = p; + } + } + + public void Encrypt(Span data) { + for (var i = 0; i < data.Length; ++i) { + var p = data[i]; + data[i] = (byte)(p ^ this.KeyStream()); + this.Update(p); + } + } + + /// An encryption header for a fresh cipher state: 11 random bytes and the check byte. + public byte[] MakeHeader(byte check) { + var header = new byte[HeaderLength]; + System.Security.Cryptography.RandomNumberGenerator.Fill(header.AsSpan(0, HeaderLength - 1)); + header[^1] = check; + this.Encrypt(header); + return header; + } +} diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipEntry.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipEntry.cs index e71f1e9e0..ed822286d 100644 --- a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipEntry.cs +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipEntry.cs @@ -1,41 +1,41 @@ namespace FileFormat.AlZip; -/// -/// Represents a single file entry in an ALZip (.alz) archive. -/// +/// One local file header of an ALZip archive. public sealed class AlZipEntry { - /// File name (path within archive). public required string FileName { get; init; } - /// Uncompressed size in bytes. public long OriginalSize { get; init; } - /// Compressed size in bytes. public long CompressedSize { get; init; } - /// Whether this entry is a directory. - public bool IsDirectory { get; init; } - - /// Compression method: Store(0), Bzip2(1), Deflate(2). + /// 0 store, 1 ALZ bzip2, 2 deflate, 3 obfuscated deflate. public int Method { get; init; } - /// CRC-32 checksum of uncompressed data. public uint Crc32 { get; init; } - /// Last modification date. public DateTime? LastModified { get; init; } - /// File attributes byte. + /// DOS attribute bits (0x10 directory). public byte Attributes { get; init; } - /// Offset of the compressed data in the stream. + /// Descriptor byte: bit 0 encrypted, bit 3 data descriptor, high nibble the size-field width. + public byte Descriptor { get; init; } + + internal uint DosTime { get; init; } + + internal byte[]? EncryptionHeader { get; init; } + internal long DataOffset { get; init; } - /// Human-readable method name. - public string MethodName => Method switch { + public bool IsDirectory => (this.Attributes & 0x10) != 0; + + public bool IsEncrypted => (this.Descriptor & 0x01) != 0; + + public string MethodName => this.Method switch { 0 => "Store", 1 => "Bzip2", 2 => "Deflate", - _ => $"Unknown({Method})", + 3 => "Obfuscated deflate", + _ => $"Unknown({this.Method})", }; } diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipFormatDescriptor.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipFormatDescriptor.cs index 8fa3b0b60..0c9950f87 100644 --- a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipFormatDescriptor.cs +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipFormatDescriptor.cs @@ -5,176 +5,78 @@ namespace FileFormat.AlZip; /// -/// ALZ archive — the proprietary container of ESTsoft's ALZip (Korean shareware archiver). +/// ALZip (ESTsoft), the Korean archiver: stored, deflate and ALZ-bzip2 members, ZipCrypto encryption. /// /// References: /// -/// http://kippler.com/win/unalz/ — unalz — open-source ALZ extractor, the de-facto format reference -/// https://en.wikipedia.org/wiki/ALZip — application background -/// The format is proprietary (ESTsoft); no official specification was published +/// https://github.com/alkegi/unalz — unalz (MIT), its CC0 test archives, and the reader the layout follows +/// https://github.com/MacPaw/XADMaster — XADALZipParser (LGPL-2.1) /// /// -public sealed class AlZipFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, IArchiveCreatable, IArchiveModifiable, IArchiveDefragmentable, IArchiveLayoutMap { - - /// Rebuild-based defrag: extracts then re-creates the ALZ archive in listing order. - public void Defragment(Stream archive) - => this.Defragment(archive, new DefragOptions { Mode = DefragMode.ConsolidateAtStart }); - - /// Rebuild-based defrag: extracts then re-creates the ALZ archive per the requested mode. - public void Defragment(Stream archive, DefragOptions options) { - DefragRebuilder.Rebuild(archive, options, - readEntries: stream => { - var r = new AlZipReader(stream, leaveOpen: true); - return r.Entries.Where(e => !e.IsDirectory).Select(e => (e.FileName, r.Extract(e))); - }, - buildImage: files => { - using var ms = new MemoryStream(); - using (var w = new AlZipWriter(ms, leaveOpen: true)) { - foreach (var (n, d) in files) w.AddFile(n, d); - } - return ms.ToArray(); - }); - } +public sealed class AlZipFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, IArchiveCreatable, IArchiveModifiable, IArchiveLayoutMap { /// public IEnumerable EnumerateLayout(Stream archive) { archive.Position = 0; - var r = new AlZipReader(archive); - foreach (var e in r.Entries) { + var r = new AlZipReader(archive, leaveOpen: true); + foreach (var e in r.Entries) if (e.CompressedSize > 0) yield return new DefragBlockInfo(e.DataOffset, e.CompressedSize, DefragBlockKind.Used, FileName: e.FileName); - } } - /// - /// Gets the id. - /// public string Id => "AlZip"; - /// - /// Gets the display name. - /// public string DisplayName => "ALZip"; - /// - /// Gets the category. - /// public FormatCategory Category => FormatCategory.Archive; - /// - /// Gets the capabilities. - /// + // R/W: create writes stored/deflate (optionally encrypted) archives unar and unalz extract; edits + // use the verified extract -> edit -> re-create rebuild (default IArchiveModifiable). public FormatCapabilities Capabilities => FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanCreate | FormatCapabilities.CanModify | FormatCapabilities.CanTest | - FormatCapabilities.SupportsMultipleEntries | FormatCapabilities.SupportsDirectories; - - /// - /// Adds (or replaces by name) files inside an existing ALZ archive. Uses - /// — Add appends a new entry over the trailing - /// CLZ end marker; Remove walks the entry chain and shifts trailing bytes. - /// - public void Add(Stream archive, IReadOnlyList inputs) { - foreach (var (name, data) in FlatFiles(inputs)) { - AlZipModifier.RemoveFile(archive, name, wipeData: true); - AlZipModifier.AddFile(archive, name, data); - } - } - - /// Removes named entries; uses . - public void Remove(Stream archive, string[] entryNames) { - foreach (var name in entryNames) - AlZipModifier.RemoveFile(archive, name, wipeData: true); - } - /// - /// Gets the default extension. - /// + FormatCapabilities.SupportsMultipleEntries | FormatCapabilities.SupportsDirectories | FormatCapabilities.SupportsPassword; public string DefaultExtension => ".alz"; - /// - /// Gets the extensions. - /// public IReadOnlyList Extensions => [".alz"]; - /// - /// Gets the compound extensions. - /// public IReadOnlyList CompoundExtensions => []; - /// - /// Gets the magic signatures. - /// - public IReadOnlyList MagicSignatures => [new([(byte)'A', (byte)'L', (byte)'Z', 0x01], Confidence: 0.95)]; - /// - /// Gets the methods. - /// - public IReadOnlyList Methods => [new("alzip", "ALZip")]; - /// - /// Gets the tar compression format id. - /// + public IReadOnlyList MagicSignatures => [new("ALZ\x01"u8.ToArray(), Confidence: 0.95)]; + public IReadOnlyList Methods => [new("deflate", "Deflate"), new("store", "Store")]; public string? TarCompressionFormatId => null; - /// - /// Gets the family. - /// public AlgorithmFamily Family => AlgorithmFamily.Archive; - /// - /// Gets the description. - /// - public string Description => "Korean ALZip archive format"; + public string Description => "Korean ALZip archive (stored, deflate and ALZ-bzip2 members; ZipCrypto)"; - /// - /// Lists the entries in the supplied container. - /// public List List(Stream stream, string? password) { using var r = new AlZipReader(stream, leaveOpen: true); return r.Entries.Select((e, i) => new ArchiveEntryInfo(i, e.FileName, e.OriginalSize, e.CompressedSize, - e.MethodName, e.IsDirectory, false, e.LastModified)).ToList(); + e.IsDirectory ? "dir" : e.MethodName, e.IsDirectory, e.IsEncrypted, e.LastModified)).ToList(); } - /// - /// Decodes the supplied input. - /// public void Extract(Stream stream, string outputDir, string? password, string[]? files) { using var r = new AlZipReader(stream, leaveOpen: true); foreach (var e in r.Entries) { - if (e.IsDirectory) continue; if (files != null && !MatchesFilter(e.FileName, files)) continue; - WriteFile(outputDir, e.FileName, r.Extract(e)); - } - } - - /// - /// Opens a single entry as a bounded read-only stream. The reader produces - /// the decoded bytes per entry; the matched bytes are wrapped in a - /// sized - /// to their logical length. - /// - public Stream OpenEntry(Stream archive, string entryName, string? password) { - ArgumentNullException.ThrowIfNull(archive); - ArgumentNullException.ThrowIfNull(entryName); - if (archive.CanSeek) archive.Position = 0; - var r = new AlZipReader(archive, leaveOpen: true); - foreach (var e in r.Entries) { - if (e.IsDirectory) continue; - if (!string.Equals(e.FileName, entryName, StringComparison.OrdinalIgnoreCase)) continue; - var bytes = r.Extract(e); - return new Compression.Registry.Streaming.BoundedEntryStream( - new MemoryStream(bytes, writable: false), bytes.Length, leaveOpen: false); + if (e.IsDirectory) { + Directory.CreateDirectory(Path.Combine(outputDir, e.FileName)); + continue; + } + WriteFile(outputDir, e.FileName, r.Extract(e, password)); } - return new Compression.Registry.Streaming.BoundedEntryStream( - new MemoryStream(System.Array.Empty(), writable: false), 0, leaveOpen: false); } - /// Native in-memory single-entry extraction routed through the bounded . public byte[] ExtractEntryToMemory(Stream archive, string entryName, string? password) { - using var s = this.OpenEntry(archive, entryName, password); - using var memoryStream = new MemoryStream(); - s.CopyTo(memoryStream); - return memoryStream.ToArray(); + ArgumentNullException.ThrowIfNull(archive); + archive.Position = 0; + using var r = new AlZipReader(archive, leaveOpen: true); + var e = r.Entries.FirstOrDefault(x => !x.IsDirectory && string.Equals(x.FileName, entryName, StringComparison.OrdinalIgnoreCase)) + ?? throw new FileNotFoundException($"No entry '{entryName}' in the ALZip archive."); + return r.Extract(e, password); } - /// - /// Performs the create operation. - /// public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options) { - // leaveOpen: true — caller owns the stream (e.g. AtomicFileWriter flushes - // it to disk after we return; closing it here would break that contract). - using var w = new AlZipWriter(output, leaveOpen: true); - foreach (var (name, data) in FormatHelpers.FlatFiles(inputs)) - w.AddFile(name, data); + using var w = new AlZipWriter(output, leaveOpen: true, password: options.Password, store: options.MethodName == "store"); + foreach (var input in inputs) { + if (string.IsNullOrEmpty(input.ArchiveName)) continue; + var modified = input.Metadata?.LastWriteTimeUtc?.LocalDateTime + ?? (input.InMemoryContent == null && File.Exists(input.FullPath) ? File.GetLastWriteTime(input.FullPath) : null); + if (input.IsDirectory) w.AddDirectory(input.ArchiveName, modified); + else w.AddFile(input.ArchiveName, input.ReadContent(), modified); + } } } diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipModifier.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipModifier.cs deleted file mode 100644 index 0564db020..000000000 --- a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipModifier.cs +++ /dev/null @@ -1,275 +0,0 @@ -#pragma warning disable CS1591 -using System.Text; -using Compression.Core.Checksums; -using Compression.Core.Deflate; - -namespace FileFormat.AlZip; - -/// -/// Random-access in-place modifier for ALZ archives. Add appends a new entry -/// over the trailing CLZ end-of-archive marker, then rewrites the marker. -/// Remove walks the local-header chain, locates the named entry, and shifts -/// trailing bytes forward to compact (ALZ has no central directory, so -/// compaction is required). -/// -/// -/// ALZ entries are length-prefixed local headers (BLZ\x01) chained together -/// and terminated by a 4-byte CLZ\x02 marker. The format is similar to LHA -/// in that there is no global TOC — header walking is required for both -/// operations. -/// -public static class AlZipModifier { - - private const uint LocalSig = 0x015A4C42; // BLZ\x01 LE - private const uint EndSig = 0x025A4C43; // CLZ\x02 LE - private static readonly byte[] AlzMagic = [0x41, 0x4C, 0x5A, 0x01]; - - /// - /// Appends a file to the archive at the position of the existing CLZ end - /// marker, then writes a new end marker. Walks the entry chain once to - /// locate the marker. - /// - public static void AddFile(Stream alz, string name, byte[] data, DateTime? lastModified = null) { - ArgumentNullException.ThrowIfNull(alz); - ArgumentNullException.ThrowIfNull(name); - ArgumentNullException.ThrowIfNull(data); - - var endOffset = FindEndMarkerOffset(alz); - alz.Position = endOffset; - - WriteEntry(alz, name, data, lastModified ?? DateTime.Now); - WriteUInt32LE(alz, EndSig); - alz.SetLength(alz.Position); - } - - /// - /// Removes the named entry. Returns true if found. Walks the chain to - /// locate the entry, optionally wipes its bytes, then shifts trailing - /// bytes forward to compact. - /// - public static bool RemoveFile(Stream alz, string name, bool wipeData = true) { - ArgumentNullException.ThrowIfNull(alz); - ArgumentNullException.ThrowIfNull(name); - - var locator = LocateEntry(alz, name); - if (!locator.Found) return false; - - if (wipeData) - ZeroRange(alz, locator.HeaderOffset, locator.TotalEntrySize); - - var afterEntry = locator.HeaderOffset + locator.TotalEntrySize; - var bytesToShift = alz.Length - afterEntry; - if (bytesToShift > 0) { - var buf = new byte[64 * 1024]; - var src = afterEntry; - var dst = locator.HeaderOffset; - while (bytesToShift > 0) { - var chunk = (int)Math.Min(buf.Length, bytesToShift); - alz.Position = src; - var read = 0; - while (read < chunk) { - var n = alz.Read(buf, read, chunk - read); - if (n <= 0) break; - read += n; - } - alz.Position = dst; - alz.Write(buf, 0, read); - src += read; - dst += read; - bytesToShift -= read; - } - } - alz.SetLength(alz.Length - locator.TotalEntrySize); - return true; - } - - // ── Header walking ────────────────────────────────────────────────────── - - private readonly record struct EntryLocator(bool Found, long HeaderOffset, long TotalEntrySize); - - private static long FindEndMarkerOffset(Stream alz) { - SkipMagic(alz); - Span sig = stackalloc byte[4]; - while (alz.Position < alz.Length) { - var sigOffset = alz.Position; - if (!ReadFully(alz, sig)) return sigOffset; - var s = ReadUInt32LE(sig); - if (s == EndSig) return sigOffset; - if (s != LocalSig) return sigOffset; // treat as terminator - var entrySize = ReadEntryBodySize(alz); - alz.Position = sigOffset + 4 + entrySize; - } - return alz.Length; - } - - private static EntryLocator LocateEntry(Stream alz, string targetName) { - SkipMagic(alz); - Span sig = stackalloc byte[4]; - while (alz.Position < alz.Length) { - var entryStart = alz.Position; - if (!ReadFully(alz, sig)) break; - var s = ReadUInt32LE(sig); - if (s != LocalSig) break; - - var headerStart = alz.Position; - var (entryBodySize, name) = ReadHeaderForLocate(alz); - var totalEntrySize = 4 + entryBodySize; - - if (string.Equals(name, targetName, StringComparison.OrdinalIgnoreCase)) - return new EntryLocator(true, entryStart, totalEntrySize); - - alz.Position = headerStart + entryBodySize; - } - return new EntryLocator(false, 0, 0); - } - - private static void SkipMagic(Stream alz) { - alz.Position = 0; - Span magic = stackalloc byte[4]; - if (!ReadFully(alz, magic) || !magic.SequenceEqual(AlzMagic)) - throw new InvalidDataException("Not a valid ALZ archive."); - } - - /// - /// Reads an entry body starting at the current stream position (just past - /// the 4-byte LocalSig) and returns the total body size in bytes - /// (header bytes + filename bytes + compressed data). - /// - private static long ReadEntryBodySize(Stream alz) { - var headerStart = alz.Position; - var (bodySize, _) = ReadHeaderForLocate(alz); - alz.Position = headerStart; - return bodySize; - } - - /// - /// Reads the variable-width entry header at the current position and - /// returns (totalBodySize, filename). After this call the stream is - /// positioned at the start of compressed data. - /// - private static (long BodySize, string Name) ReadHeaderForLocate(Stream alz) { - var headerStart = alz.Position; - Span buf2 = stackalloc byte[2]; - if (!ReadFully(alz, buf2)) return (0, ""); - var filenameLen = (ushort)(buf2[0] | (buf2[1] << 8)); - - var attr = (byte)alz.ReadByte(); - Span buf4 = stackalloc byte[4]; - if (!ReadFully(alz, buf4)) return (0, ""); // dosTime - var descriptor = (byte)alz.ReadByte(); - var sizeWidth = (descriptor & 0xF0) switch { - 0x10 => 2, - 0x20 => 4, - 0x40 => 8, - _ => 4, - }; - alz.ReadByte(); // reserved - alz.ReadByte(); // method - if (!ReadFully(alz, buf4)) return (0, ""); // crc32 - - Span sizeBuf = stackalloc byte[8]; - if (!ReadFully(alz, sizeBuf[..sizeWidth])) return (0, ""); - var compressedSize = ReadSize(sizeBuf, sizeWidth); - if (!ReadFully(alz, sizeBuf[..sizeWidth])) return (0, ""); - // uncompressed not needed here - _ = ReadSize(sizeBuf, sizeWidth); - - var nameBuf = new byte[filenameLen]; - if (!ReadFully(alz, nameBuf)) return (0, ""); - var name = Encoding.UTF8.GetString(nameBuf).Replace('\\', '/'); - - var headerBytes = alz.Position - headerStart; - var bodySize = headerBytes + compressedSize; - - // Skip past compressed payload to set stream to next-entry boundary. - alz.Position = headerStart + bodySize; - _ = attr; // suppress unused - return (bodySize, name); - } - - // ── Writing one entry ────────────────────────────────────────────────── - - private static void WriteEntry(Stream alz, string name, byte[] data, DateTime lastModified) { - var nameBytes = Encoding.UTF8.GetBytes(name); - var crc = Crc32.Compute(data); - var deflated = data.Length > 0 ? DeflateCompressor.Compress(data) : []; - byte method; - byte[] payload; - if (deflated.Length > 0 && deflated.Length < data.Length) { - method = 2; - payload = deflated; - } else { - method = 0; - payload = data; - } - - WriteUInt32LE(alz, LocalSig); - WriteUInt16LE(alz, (ushort)nameBytes.Length); - alz.WriteByte(0x20); // attr: archive - WriteUInt32LE(alz, AlZipReader.DateTimeToDosTime(lastModified)); - alz.WriteByte(0x20); // descriptor: 4-byte sizes - alz.WriteByte(0); // reserved - alz.WriteByte(method); - WriteUInt32LE(alz, crc); - WriteUInt32LE(alz, (uint)payload.Length); - WriteUInt32LE(alz, (uint)data.Length); - alz.Write(nameBytes); - alz.Write(payload); - } - - // ── Helpers ───────────────────────────────────────────────────────────── - - private static long ReadSize(ReadOnlySpan buf, int width) => width switch { - 2 => buf[0] | (buf[1] << 8), - 4 => buf[0] | ((long)buf[1] << 8) | ((long)buf[2] << 16) | ((long)buf[3] << 24), - 8 => buf[0] | ((long)buf[1] << 8) | ((long)buf[2] << 16) | ((long)buf[3] << 24) | - ((long)buf[4] << 32) | ((long)buf[5] << 40) | ((long)buf[6] << 48) | ((long)buf[7] << 56), - _ => throw new InvalidDataException($"Invalid ALZ size width {width}"), - }; - - private static uint ReadUInt32LE(ReadOnlySpan b) => - (uint)(b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)); - - private static void WriteUInt16LE(Stream s, ushort v) { - Span b = stackalloc byte[2]; - b[0] = (byte)v; b[1] = (byte)(v >> 8); - s.Write(b); - } - - private static void WriteUInt32LE(Stream s, uint v) { - Span b = stackalloc byte[4]; - b[0] = (byte)v; b[1] = (byte)(v >> 8); b[2] = (byte)(v >> 16); b[3] = (byte)(v >> 24); - s.Write(b); - } - - private static bool ReadFully(Stream s, Span buf) { - var read = 0; - while (read < buf.Length) { - var n = s.Read(buf[read..]); - if (n <= 0) return false; - read += n; - } - return true; - } - - private static bool ReadFully(Stream s, byte[] buf) { - var read = 0; - while (read < buf.Length) { - var n = s.Read(buf, read, buf.Length - read); - if (n <= 0) return false; - read += n; - } - return true; - } - - private static void ZeroRange(Stream s, long offset, long length) { - var buf = new byte[(int)Math.Min(length, 8192)]; - s.Position = offset; - var remaining = length; - while (remaining > 0) { - var chunk = (int)Math.Min(buf.Length, remaining); - s.Write(buf, 0, chunk); - remaining -= chunk; - } - } -} diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipReader.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipReader.cs index 44a572143..8669b8637 100644 --- a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipReader.cs +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipReader.cs @@ -1,228 +1,205 @@ +// Layout per XADMaster's XADALZipParser.m (LGPL-2.1-or-later) and alkegi/unalz (MIT); see +// THIRD-PARTY-NOTICE.unalz.txt. + +using System.Buffers.Binary; +using System.Text; using Compression.Core.Deflate; +using FileFormat.Bzip2; namespace FileFormat.AlZip; /// -/// Reads ALZip (.alz) archive files. +/// Reads single-volume ALZip archives: "ALZ\x01" + 4 bytes, local file headers ("BLZ\x01"), then the +/// central-directory and end records ("CLZ\x01", "CLZ\x02"). Members are stored, deflated or +/// ALZ-bzip2'd, optionally ZipCrypto-encrypted. Names are CP949. /// -/// -/// ALZip is a Korean archive format by ESTsoft. The format uses a simple -/// container with per-file deflate or bzip2 compression. -/// -/// Archive layout: -/// [ALZ\x01] — 4-byte magic -/// [BLZ\x01][header][data]... — repeated file entries -/// [CLZ\x02] — end-of-archive marker -/// public sealed class AlZipReader : IDisposable { + internal static ReadOnlySpan Magic => "ALZ\x01"u8; - /// Archive magic: "ALZ\x01". - internal static ReadOnlySpan Magic => [0x41, 0x4C, 0x5A, 0x01]; - - /// Local file header signature: BLZ\x01 as uint32 LE = 0x015A4C42. - private const uint LocalSig = 0x015A4C42; - - /// End-of-archive signature: CLZ\x02 as uint32 LE = 0x025A4C43. - private const uint EndSig = 0x025A4C43; + internal const uint LocalSig = 0x015A4C42; // "BLZ\x01" + internal const uint CentralSig = 0x015A4C43; // "CLZ\x01" + internal const uint EndSig = 0x025A4C43; // "CLZ\x02" + internal const uint CommentSig = 0x015A4C45; // "ELZ\x01" + internal const uint SplitSig = 0x035A4C43; // "CLZ\x03" private readonly Stream _stream; private readonly bool _leaveOpen; private readonly List _entries = []; - /// - /// Creates a new ALZip reader over the given stream. - /// - public AlZipReader(Stream stream, bool leaveOpen = false) { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - _leaveOpen = leaveOpen; - _ReadArchive(); - } - - /// File entries in the archive. - public IReadOnlyList Entries => _entries; + public IReadOnlyList Entries => this._entries; - /// - /// Extracts the raw (decompressed) data for the given entry. - /// - public byte[] Extract(AlZipEntry entry) { - ArgumentNullException.ThrowIfNull(entry); - - if (entry.IsDirectory) - return []; + /// Whether the archive is one volume of a split set (only whole entries of it are readable). + public bool IsSplit { get; private set; } - _stream.Position = entry.DataOffset; - var compressedData = new byte[entry.CompressedSize]; - _ReadExactly(compressedData); + internal static Encoding Cp949 { + get { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding(949); + } + } - return entry.Method switch { - 0 => compressedData, // Store - 2 => DeflateDecompressor.Decompress(compressedData), // Deflate - _ => throw new InvalidDataException($"Unsupported ALZ compression method: {entry.Method}"), - }; + public AlZipReader(Stream stream, bool leaveOpen = false) { + this._stream = stream ?? throw new ArgumentNullException(nameof(stream)); + this._leaveOpen = leaveOpen; + this.ReadArchive(); } - /// public void Dispose() { - if (!_leaveOpen) - _stream.Dispose(); + if (!this._leaveOpen) + this._stream.Dispose(); } - private void _ReadArchive() { - Span magic = stackalloc byte[4]; - _ReadExactly(magic); + private uint ReadUInt32() { + Span b = stackalloc byte[4]; + this._stream.ReadExactly(b); + return BinaryPrimitives.ReadUInt32LittleEndian(b); + } - if (!magic.SequenceEqual(Magic)) - throw new InvalidDataException("Not a valid ALZ file: invalid magic."); + private void ReadArchive() { + this._stream.Position = 0; + Span head = stackalloc byte[8]; + if (this._stream.ReadAtLeast(head, 8, throwOnEndOfStream: false) < 8 || !head[..4].SequenceEqual(Magic)) + throw new InvalidDataException("Not an ALZip archive (missing ALZ\\x01 header)."); Span sigBuf = stackalloc byte[4]; while (true) { - if (_stream.Read(sigBuf) < 4) + if (this._stream.ReadAtLeast(sigBuf, 4, throwOnEndOfStream: false) < 4) { + this.IsSplit |= this._entries.Count > 0; // ended without its end record break; - - var sig = ReadUInt32LE(sigBuf); - if (sig == EndSig) + } + var sig = BinaryPrimitives.ReadUInt32LittleEndian(sigBuf); + if (sig == LocalSig) { + this.ReadLocalHeader(); + continue; + } + if (sig is CentralSig or EndSig or CommentSig) + break; // central records carry nothing the local headers do not; comments sit at the end + if (sig == SplitSig) { + this.IsSplit = true; break; - - if (sig != LocalSig) - throw new InvalidDataException($"Unexpected ALZ signature: 0x{sig:X8}"); - - _ReadEntry(); + } + throw new InvalidDataException($"Unexpected ALZip signature 0x{sig:X8}."); } } - private void _ReadEntry() { - // Filename length (2 bytes LE) - Span buf2 = stackalloc byte[2]; - _ReadExactly(buf2); - var filenameLen = (ushort)(buf2[0] | (buf2[1] << 8)); - - // File attribute (1 byte) - var attr = (byte)_ReadByte(); - var isDir = (attr & 0x10) != 0; - - // DOS timestamp (4 bytes LE) - Span timeBuf = stackalloc byte[4]; - _ReadExactly(timeBuf); - var dosTime = ReadUInt32LE(timeBuf); - var lastModified = DosTimeToDateTime(dosTime); - - // File descriptor (1 byte) — upper nibble determines size field width - var descriptor = (byte)_ReadByte(); - var sizeWidth = (descriptor & 0xF0) switch { - 0x10 => 2, - 0x20 => 4, - 0x40 => 8, - _ => 4, + private void ReadLocalHeader() { + Span h = stackalloc byte[9]; + this._stream.ReadExactly(h); + var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(h); + var attributes = h[2]; + var dosTime = BinaryPrimitives.ReadUInt32LittleEndian(h[3..]); + var descriptor = h[7]; + var width = (descriptor & 0xF0) switch { + 0x00 => 0, + 0x10 => 1, + 0x20 => 2, + 0x40 => 4, + 0x80 => 8, + var w => throw new InvalidDataException($"Invalid ALZip size-field width 0x{w:X2}."), }; - - // Unknown/reserved (1 byte) - _ReadByte(); - - // Compression method (1 byte) - var method = (byte)_ReadByte(); - - // CRC-32 (4 bytes LE) - Span crcBuf = stackalloc byte[4]; - _ReadExactly(crcBuf); - var crc32 = ReadUInt32LE(crcBuf); - - // Compressed size - var compressedSize = _ReadSizeField(sizeWidth); - - // Uncompressed size - var uncompressedSize = _ReadSizeField(sizeWidth); - - // Filename - var filenameBuf = new byte[filenameLen]; - _ReadExactly(filenameBuf); - var filename = System.Text.Encoding.UTF8.GetString(filenameBuf); - filename = filename.Replace('\\', '/'); - - var dataOffset = _stream.Position; - - _entries.Add(new AlZipEntry { - FileName = filename, - OriginalSize = uncompressedSize, - CompressedSize = compressedSize, - IsDirectory = isDir, + int method = 0; + uint crc = 0; + long packed = 0, size = 0; + if (width > 0) { + Span m = stackalloc byte[6]; + this._stream.ReadExactly(m); + method = m[0]; + crc = BinaryPrimitives.ReadUInt32LittleEndian(m[2..]); + packed = this.ReadSized(width); + size = this.ReadSized(width); + } + if (nameLength == 0 || nameLength > 4096) + throw new InvalidDataException("Invalid ALZip file-name length."); + var name = new byte[nameLength]; + this._stream.ReadExactly(name); + byte[]? encryptionHeader = null; + if ((descriptor & 0x01) != 0) { + encryptionHeader = new byte[AlZipCrypto.HeaderLength]; + this._stream.ReadExactly(encryptionHeader); + } + var dataOffset = this._stream.Position; + if (packed < 0 || dataOffset + packed > this._stream.Length) { + // a member continued in the next volume of a split set + this.IsSplit = true; + this._stream.Position = this._stream.Length; + } else + this._stream.Position = dataOffset + packed; + + this._entries.Add(new AlZipEntry { + FileName = Cp949.GetString(name).Replace('\\', '/'), + Attributes = attributes, + DosTime = dosTime, + LastModified = DosTimeToDateTime(dosTime), + Descriptor = descriptor, Method = method, - Crc32 = crc32, - LastModified = lastModified, - Attributes = attr, + Crc32 = crc, + CompressedSize = packed, + OriginalSize = size, + EncryptionHeader = encryptionHeader, DataOffset = dataOffset, }); + } - // Skip past compressed data - if (compressedSize > 0 && _stream.CanSeek) - _stream.Position += compressedSize; - else if (compressedSize > 0) - _SkipBytes(compressedSize); + private long ReadSized(int width) { + Span b = stackalloc byte[8]; + b.Clear(); + this._stream.ReadExactly(b[..width]); + var v = BinaryPrimitives.ReadUInt64LittleEndian(b); + return v > long.MaxValue ? -1 : (long)v; } - private long _ReadSizeField(int width) { - Span buf = stackalloc byte[8]; - _ReadExactly(buf[..width]); - return width switch { - 2 => buf[0] | (buf[1] << 8), - 4 => ReadUInt32LE(buf), - 8 => (long)ReadUInt64LE(buf), - _ => throw new InvalidDataException($"Invalid ALZ size field width: {width}"), - }; + /// Decompresses (and if needed decrypts) a member, verifying its CRC-32. + public byte[] Extract(AlZipEntry entry, string? password = null) { + ArgumentNullException.ThrowIfNull(entry); + if (entry.IsDirectory || entry.OriginalSize == 0 && entry.CompressedSize == 0) + return []; + if (entry.DataOffset + entry.CompressedSize > this._stream.Length) + throw new NotSupportedException($"ALZip member '{entry.FileName}' continues in another volume; split archives are not supported."); + var packed = new byte[entry.CompressedSize]; + this._stream.Position = entry.DataOffset; + this._stream.ReadExactly(packed); + + if (entry.IsEncrypted) { + if (string.IsNullOrEmpty(password)) + throw new InvalidOperationException($"ALZip member '{entry.FileName}' is encrypted; a password is required."); + var crypto = new AlZipCrypto(Cp949.GetBytes(password)); + var check = (entry.Descriptor & 0x08) != 0 ? (byte)(entry.DosTime >> 8) : (byte)(entry.Crc32 >> 24); + if (!crypto.CheckHeader(entry.EncryptionHeader, check)) + throw new InvalidDataException($"ALZip member '{entry.FileName}': wrong password."); + crypto.Decrypt(packed); + } + + byte[] data; + try { + data = entry.Method switch { + 0 => packed, + 1 => AlzBzip2.Decompress(packed, entry.OriginalSize), + 2 => DeflateDecompressor.Decompress(packed), + 3 => throw new NotSupportedException($"ALZip member '{entry.FileName}' uses obfuscated deflate (method 3), which is not supported."), + _ => throw new NotSupportedException($"ALZip member '{entry.FileName}' uses unknown method {entry.Method}."), + }; + } catch (Exception e) when (e is not (NotSupportedException or InvalidDataException)) { + throw new InvalidDataException($"ALZip member '{entry.FileName}': corrupt data.", e); + } + if (data.LongLength != entry.OriginalSize) + throw new InvalidDataException($"ALZip member '{entry.FileName}': size mismatch."); + if (Compression.Core.Checksums.Crc32.Compute(data) != entry.Crc32) + throw new InvalidDataException($"ALZip member '{entry.FileName}': CRC mismatch."); + return data; } internal static DateTime? DosTimeToDateTime(uint dosTime) { if (dosTime == 0) return null; try { - var time = (ushort)(dosTime & 0xFFFF); - var date = (ushort)(dosTime >> 16); - var year = ((date >> 9) & 0x7F) + 1980; - var month = (date >> 5) & 0x0F; - var day = date & 0x1F; - var hour = (time >> 11) & 0x1F; - var minute = (time >> 5) & 0x3F; - var second = (time & 0x1F) * 2; - if (month < 1 || month > 12 || day < 1 || day > 31) return null; - return new DateTime(year, month, day, hour, minute, second, DateTimeKind.Local); - } catch { + return new DateTime((int)((dosTime >> 25) & 0x7F) + 1980, (int)((dosTime >> 21) & 0x0F), (int)((dosTime >> 16) & 0x1F), + (int)((dosTime >> 11) & 0x1F), (int)((dosTime >> 5) & 0x3F), (int)(dosTime & 0x1F) * 2, DateTimeKind.Local); + } catch (ArgumentOutOfRangeException) { return null; } } internal static uint DateTimeToDosTime(DateTime dt) { - var date = (ushort)(((dt.Year - 1980) << 9) | (dt.Month << 5) | dt.Day); - var time = (ushort)((dt.Hour << 11) | (dt.Minute << 5) | (dt.Second / 2)); - return (uint)((date << 16) | time); - } - - internal static uint ReadUInt32LE(ReadOnlySpan b) => - (uint)(b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)); - - private static ulong ReadUInt64LE(ReadOnlySpan b) => - (ulong)b[0] | ((ulong)b[1] << 8) | ((ulong)b[2] << 16) | ((ulong)b[3] << 24) | - ((ulong)b[4] << 32) | ((ulong)b[5] << 40) | ((ulong)b[6] << 48) | ((ulong)b[7] << 56); - - private int _ReadByte() { - var b = _stream.ReadByte(); - if (b < 0) throw new InvalidDataException("Unexpected end of ALZ stream."); - return b; - } - - private void _ReadExactly(Span buffer) { - var offset = 0; - while (offset < buffer.Length) { - var read = _stream.Read(buffer[offset..]); - if (read == 0) throw new InvalidDataException("Unexpected end of ALZ stream."); - offset += read; - } - } - - private void _SkipBytes(long count) { - var buf = new byte[Math.Min(count, 8192)]; - while (count > 0) { - var toRead = (int)Math.Min(count, buf.Length); - var read = _stream.Read(buf, 0, toRead); - if (read == 0) throw new InvalidDataException("Unexpected end of ALZ stream."); - count -= read; - } + if (dt.Year < 1980) dt = new DateTime(1980, 1, 1); + return (uint)(((dt.Year - 1980) << 25) | (dt.Month << 21) | (dt.Day << 16) | (dt.Hour << 11) | (dt.Minute << 5) | (dt.Second / 2)); } } diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipWriter.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipWriter.cs index dd270db55..3b0b35706 100644 --- a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipWriter.cs +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/AlZipWriter.cs @@ -1,124 +1,109 @@ +using System.Buffers.Binary; using Compression.Core.Checksums; using Compression.Core.Deflate; namespace FileFormat.AlZip; /// -/// Creates ALZip (.alz) archive files. +/// Writes single-volume ALZip archives as ALZip 8 lays them out: the "ALZ\x01" header with version +/// 0x0A, one "BLZ\x01" local header per member (stored or deflated, size fields as narrow as the +/// sizes allow, CP949 names, optional ZipCrypto), then the "CLZ\x01" and "CLZ\x02" end records. /// public sealed class AlZipWriter : IDisposable { - - private const uint LocalSig = 0x015A4C42; - private const uint EndSig = 0x025A4C43; - private readonly Stream _stream; private readonly bool _leaveOpen; + private readonly string? _password; + private readonly bool _store; + private bool _finished; + + public AlZipWriter(Stream stream, bool leaveOpen = false, string? password = null, bool store = false) { + this._stream = stream ?? throw new ArgumentNullException(nameof(stream)); + this._leaveOpen = leaveOpen; + this._password = string.IsNullOrEmpty(password) ? null : password; + this._store = store; + this._stream.Write("ALZ\x01\x0A\x00\x00\x00"u8); + } - /// - /// Creates a new ALZip writer over the given stream. - /// - public AlZipWriter(Stream stream, bool leaveOpen = false) { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - _leaveOpen = leaveOpen; - - // Write archive magic - _stream.Write(AlZipReader.Magic); + private static int Width(long a, long b) { + var max = Math.Max(a, b); + return max <= byte.MaxValue ? 1 : max <= ushort.MaxValue ? 2 : max <= uint.MaxValue ? 4 : 8; } - /// - /// Adds a file to the archive with deflate compression. - /// - public void AddFile(string fileName, byte[] data) { + public void AddFile(string fileName, byte[] data, DateTime? modified = null) { ArgumentNullException.ThrowIfNull(fileName); ArgumentNullException.ThrowIfNull(data); - - var nameBytes = System.Text.Encoding.UTF8.GetBytes(fileName); var crc = Crc32.Compute(data); - var compressed = data.Length > 0 - ? DeflateCompressor.Compress(data) - : []; - - // Use store if deflate doesn't help - var method = (byte)2; // Deflate - var compData = compressed; - if (compressed.Length >= data.Length) { - method = 0; // Store - compData = data; + byte method = 0; + var payload = data; + if (!this._store && data.Length > 0) { + var deflated = DeflateCompressor.Compress(data); + if (deflated.Length < data.Length) { + method = 2; + payload = deflated; + } } - - WriteUInt32LE(LocalSig); - WriteUInt16LE((ushort)nameBytes.Length); - _stream.WriteByte(0x20); // Archive attribute - WriteUInt32LE(AlZipReader.DateTimeToDosTime(DateTime.Now)); - - // File descriptor: 0x20 = 4-byte sizes - _stream.WriteByte(0x20); - - // Reserved - _stream.WriteByte(0); - - // Method - _stream.WriteByte(method); - - // CRC-32 - WriteUInt32LE(crc); - - // Compressed size (4 bytes) - WriteUInt32LE((uint)compData.Length); - - // Uncompressed size (4 bytes) - WriteUInt32LE((uint)data.Length); - - // Filename - _stream.Write(nameBytes); - - // Data - _stream.Write(compData); + var dosTime = AlZipReader.DateTimeToDosTime(modified ?? DateTime.Now); + byte[]? header = null; + if (this._password != null) { + var crypto = new AlZipCrypto(AlZipReader.Cp949.GetBytes(this._password)); + header = crypto.MakeHeader((byte)(crc >> 24)); + payload = (byte[])payload.Clone(); + crypto.Encrypt(payload); + } + this.WriteHeader(fileName, 0x20, dosTime, method, crc, payload.Length, data.Length, header); + this._stream.Write(payload); } - /// - /// Adds a directory entry to the archive. - /// - public void AddDirectory(string dirName) { + public void AddDirectory(string dirName, DateTime? modified = null) { ArgumentNullException.ThrowIfNull(dirName); - - var nameBytes = System.Text.Encoding.UTF8.GetBytes(dirName); - - WriteUInt32LE(LocalSig); - WriteUInt16LE((ushort)nameBytes.Length); - _stream.WriteByte(0x10); // Directory attribute - WriteUInt32LE(AlZipReader.DateTimeToDosTime(DateTime.Now)); - _stream.WriteByte(0x20); // 4-byte sizes - _stream.WriteByte(0); // Reserved - _stream.WriteByte(0); // Store - WriteUInt32LE(0); // CRC - WriteUInt32LE(0); // Compressed size - WriteUInt32LE(0); // Uncompressed size - _stream.Write(nameBytes); + this.WriteHeader(dirName.TrimEnd('/', '\\'), 0x10, AlZipReader.DateTimeToDosTime(modified ?? DateTime.Now), null, 0, 0, 0, null); } - /// - public void Dispose() { - // Write end-of-archive marker - WriteUInt32LE(EndSig); - - if (!_leaveOpen) - _stream.Dispose(); + private void WriteHeader(string name, byte attributes, uint dosTime, byte? method, uint crc, long packed, long size, byte[]? encryptionHeader) { + var nameBytes = AlZipReader.Cp949.GetBytes(name.Replace('/', '\\')); + if (nameBytes.Length is 0 or > 4096) + throw new ArgumentException($"Invalid ALZip member name '{name}'."); + var width = method == null ? 0 : Width(packed, size); + var widthBits = width switch { 0 => 0x00, 1 => 0x10, 2 => 0x20, 4 => 0x40, _ => 0x80 }; + var descriptor = (byte)(widthBits | (encryptionHeader != null ? 0x01 : 0)); + var buf = new byte[4 + 9 + (width > 0 ? 6 + 2 * width : 0)]; + BinaryPrimitives.WriteUInt32LittleEndian(buf, AlZipReader.LocalSig); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), (ushort)nameBytes.Length); + buf[6] = attributes; + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(7), dosTime); + buf[11] = descriptor; + buf[12] = 0; + if (width > 0) { + buf[13] = method!.Value; + buf[14] = 0; + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(15), crc); + Span v = stackalloc byte[8]; + BinaryPrimitives.WriteUInt64LittleEndian(v, (ulong)packed); + v[..width].CopyTo(buf.AsSpan(19)); + BinaryPrimitives.WriteUInt64LittleEndian(v, (ulong)size); + v[..width].CopyTo(buf.AsSpan(19 + width)); + } + this._stream.Write(buf); + this._stream.Write(nameBytes); + if (encryptionHeader != null) + this._stream.Write(encryptionHeader); } - private void WriteUInt16LE(ushort value) { - Span buf = stackalloc byte[2]; - buf[0] = (byte)value; - buf[1] = (byte)(value >> 8); - _stream.Write(buf); + /// Writes the end records; called by . + public void Finish() { + if (this._finished) + return; + this._finished = true; + Span tail = stackalloc byte[16]; + tail.Clear(); + BinaryPrimitives.WriteUInt32LittleEndian(tail, AlZipReader.CentralSig); + BinaryPrimitives.WriteUInt32LittleEndian(tail[12..], AlZipReader.EndSig); + this._stream.Write(tail); } - private void WriteUInt32LE(uint value) { - Span buf = stackalloc byte[4]; - buf[0] = (byte)value; - buf[1] = (byte)(value >> 8); - buf[2] = (byte)(value >> 16); - buf[3] = (byte)(value >> 24); - _stream.Write(buf); + public void Dispose() { + this.Finish(); + if (!this._leaveOpen) + this._stream.Dispose(); } } diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/THIRD-PARTY-NOTICE.unalz.txt b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/THIRD-PARTY-NOTICE.unalz.txt new file mode 100644 index 000000000..a27d86173 --- /dev/null +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.AlZip/THIRD-PARTY-NOTICE.unalz.txt @@ -0,0 +1,28 @@ +The ALZip layout (AlZipReader.cs), the ALZ bzip2 variant (Compression.Core/Streams/Bzip2/AlzBzip2.cs) +and the ALZip ZipCrypto handling (AlZipCrypto.cs) follow alkegi/unalz, cross-checked with XADMaster's +XADALZipParser.m (LGPL-2.1-or-later, Copyright (c) 2017-present MacPaw Way Ltd.): + + Upstream: https://github.com/alkegi/unalz (master, fetched 2026-10-23) + Licence: MIT + +MIT License + +Copyright (c) 2026 Sungjoon Moon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Hawkynt.FileFormats.Archives/README.md b/Hawkynt.FileFormats.Archives/README.md index 9357f828e..cec476985 100644 --- a/Hawkynt.FileFormats.Archives/README.md +++ b/Hawkynt.FileFormats.Archives/README.md @@ -103,7 +103,7 @@ Every State, Test, Maintenance, Compress/Decompress and Demux/Mux/Remux cell is | --- | --- | --- | :---: | :---: | --- | --- | --- | | [ACE](https://en.wikipedia.org/wiki/ACE_(compressed_file_format)) | `Ace` | `.ace` | R/W | ✅ | defrag · wipe | | [GitHub](https://github.com/droe/acefile) | | [afio](https://en.wikipedia.org/wiki/Afio) | `Afio` | `.afio` | WORM | ✅ | — | Writes stored members only; the per-file gzip extension is read but not written | [GitHub](https://github.com/kholtman/afio) | -| [ALZip](https://en.wikipedia.org/wiki/ALZip) | `AlZip` | `.alz` | R/W | ✅ | defrag · wipe | | [kippler.com](http://www.kippler.com/win/unalz/) | +| [ALZip](https://en.wikipedia.org/wiki/ALZip) | `AlZip` | `.alz` | R/W | ✅ | wipe | Reads stored, deflate and ALZ-bzip2 members, ZipCrypto, CP949 names; writes stored/deflate (optionally encrypted) archives unalz extracts; split archives are refused | [GitHub](https://github.com/alkegi/unalz) | | AMPK (Amiga Pack) | `Ampk` | `.ampk` | R/W | ✅ | defrag · wipe | | [Archive Team](http://fileformats.archiveteam.org/wiki/AmiPack) | | [AR](https://en.wikipedia.org/wiki/Ar_(Unix)) | `Ar` | `.a` `.ar` `.deb` | R/W | ✅ | defrag · wipe | | [freebsd.org](https://www.freebsd.org/cgi/man.cgi?query=ar&sektion=5) | | [ARC](https://en.wikipedia.org/wiki/ARC_(file_format)) | `Arc` | `.arc` | R/W | ✅ | defrag · wipe | | [GitHub](https://github.com/hyc/arc) | @@ -544,7 +544,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 2570 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..c910e3984 100644 --- a/Hawkynt.FileFormats.Archives/REFERENCE.md +++ b/Hawkynt.FileFormats.Archives/REFERENCE.md @@ -8607,91 +8607,81 @@ Implements `IDisposable`. ### Namespace `FileFormat.AlZip` -[`AlZipEntry`](#alzipentry) · [`AlZipFormatDescriptor`](#alzipformatdescriptor) · [`AlZipModifier`](#alzipmodifier) · [`AlZipReader`](#alzipreader) · [`AlZipWriter`](#alzipwriter) +[`AlZipEntry`](#alzipentry) · [`AlZipFormatDescriptor`](#alzipformatdescriptor) · [`AlZipReader`](#alzipreader) · [`AlZipWriter`](#alzipwriter) #### `AlZipEntry` -Represents a single file entry in an ALZip (.alz) archive. +One local file header of an ALZip archive. | Member | Signature | Summary | | --- | --- | --- | | `AlZipEntry` | `AlZipEntry()` | | -| `Attributes` | `byte Attributes { get; init; }` | File attributes byte. | -| `CompressedSize` | `long CompressedSize { get; init; }` | Compressed size in bytes. | -| `Crc32` | `uint Crc32 { get; init; }` | CRC-32 checksum of uncompressed data. | -| `FileName` | `string FileName { get; init; }` | File name (path within archive). | -| `IsDirectory` | `bool IsDirectory { get; init; }` | Whether this entry is a directory. | -| `LastModified` | `DateTime? LastModified { get; init; }` | Last modification date. | -| `MethodName` | `string MethodName { get; }` | Human-readable method name. | -| `Method` | `int Method { get; init; }` | Compression method: Store(0), Bzip2(1), Deflate(2). | -| `OriginalSize` | `long OriginalSize { get; init; }` | Uncompressed size in bytes. | +| `Attributes` | `byte Attributes { get; init; }` | DOS attribute bits (0x10 directory). | +| `CompressedSize` | `long CompressedSize { get; init; }` | | +| `Crc32` | `uint Crc32 { get; init; }` | | +| `Descriptor` | `byte Descriptor { get; init; }` | Descriptor byte: bit 0 encrypted, bit 3 data descriptor, high nibble the size-field width. | +| `FileName` | `string FileName { get; init; }` | | +| `IsDirectory` | `bool IsDirectory { get; }` | | +| `IsEncrypted` | `bool IsEncrypted { get; }` | | +| `LastModified` | `DateTime? LastModified { get; init; }` | | +| `MethodName` | `string MethodName { get; }` | | +| `Method` | `int Method { get; init; }` | 0 store, 1 ALZ bzip2, 2 deflate, 3 obfuscated deflate. | +| `OriginalSize` | `long OriginalSize { get; init; }` | | #### `AlZipFormatDescriptor` -ALZ archive — the proprietary container of ESTsoft's ALZip (Korean shareware archiver). References: `http://kippler.com/win/unalz/` — unalz — open-source ALZ extractor, the de-facto format reference`https://en.wikipedia.org/wiki/ALZip` — application backgroundThe format is proprietary (ESTsoft); no official specification was published +ALZip (ESTsoft), the Korean archiver: stored, deflate and ALZ-bzip2 members, ZipCrypto encryption. References: `https://github.com/alkegi/unalz` — unalz (MIT), its CC0 test archives, and the reader the layout follows`https://github.com/MacPaw/XADMaster` — XADALZipParser (LGPL-2.1) -Implements `IArchiveCreatable`, `IArchiveDefragmentable`, `IArchiveFormatOperations`, `IArchiveLayoutMap`, `IArchiveModifiable`, `IArchivePurgeable`, `IFormatDescriptor`, `IWipeEmpty`. +Implements `IArchiveCreatable`, `IArchiveFormatOperations`, `IArchiveLayoutMap`, `IArchiveModifiable`, `IArchivePurgeable`, `IFormatDescriptor`, `IWipeEmpty`. | Member | Signature | Summary | | --- | --- | --- | | `AlZipFormatDescriptor` | `AlZipFormatDescriptor()` | | -| `Capabilities` | `FormatCapabilities Capabilities { get; }` | Gets the capabilities. | -| `Category` | `FormatCategory Category { get; }` | Gets the category. | -| `CompoundExtensions` | `IReadOnlyList CompoundExtensions { get; }` | Gets the compound extensions. | -| `DefaultExtension` | `string DefaultExtension { get; }` | Gets the default extension. | -| `Description` | `string Description { get; }` | Gets the description. | -| `DisplayName` | `string DisplayName { get; }` | Gets the display name. | -| `Extensions` | `IReadOnlyList Extensions { get; }` | Gets the extensions. | -| `Family` | `AlgorithmFamily Family { get; }` | Gets the family. | -| `Id` | `string Id { get; }` | Gets the id. | -| `MagicSignatures` | `IReadOnlyList MagicSignatures { get; }` | Gets the magic signatures. | -| `Methods` | `IReadOnlyList Methods { get; }` | Gets the methods. | -| `TarCompressionFormatId` | `string TarCompressionFormatId { get; }` | Gets the tar compression format id. | -| `Add` | `void Add(Stream archive, IReadOnlyList inputs)` | Adds (or replaces by name) files inside an existing ALZ archive. Uses `AlZipModifier` — Add appends a new entry over the trailing CLZ end marker; Remove walks the entry chain and shifts trailing bytes. | -| `Create` | `void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options)` | Performs the create operation. | -| `Defragment` | `void Defragment(Stream archive)` | Rebuild-based defrag: extracts then re-creates the ALZ archive in listing order. | -| `Defragment` | `void Defragment(Stream archive, DefragOptions options)` | Rebuild-based defrag: extracts then re-creates the ALZ archive per the requested mode. | +| `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; }` | | +| `Create` | `void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options)` | | | `EnumerateLayout` | `IEnumerable EnumerateLayout(Stream archive)` | | -| `ExtractEntryToMemory` | `byte[] ExtractEntryToMemory(Stream archive, string entryName, string password)` | Native in-memory single-entry extraction routed through the bounded `OpenEntry`. | -| `Extract` | `void Extract(Stream stream, string outputDir, string password, string[] files)` | Decodes the supplied input. | -| `List` | `List List(Stream stream, string password)` | Lists the entries in the supplied container. | -| `OpenEntry` | `Stream OpenEntry(Stream archive, string entryName, string password)` | Opens a single entry as a bounded read-only stream. The reader produces the decoded bytes per entry; the matched bytes are wrapped in a `BoundedEntryStream` sized to their logical length. | -| `Remove` | `void Remove(Stream archive, string[] entryNames)` | Removes named entries; uses `AlZipModifier`. | - -#### `AlZipModifier` - -Random-access in-place modifier for ALZ archives. Add appends a new entry over the trailing CLZ end-of-archive marker, then rewrites the marker. Remove walks the local-header chain, locates the named entry, and shifts trailing bytes forward to compact (ALZ has no central directory, so compaction is required). - -| Member | Signature | Summary | -| --- | --- | --- | -| `AddFile` | `static void AddFile(Stream alz, string name, byte[] data, DateTime? lastModified = null)` | Appends a file to the archive at the position of the existing CLZ end marker, then writes a new end marker. Walks the entry chain once to locate the marker. | -| `RemoveFile` | `static bool RemoveFile(Stream alz, string name, bool wipeData = true)` | Removes the named entry. Returns true if found. Walks the chain to locate the entry, optionally wipes its bytes, then shifts trailing bytes forward to compact. | +| `ExtractEntryToMemory` | `byte[] ExtractEntryToMemory(Stream archive, string entryName, string password)` | | +| `Extract` | `void Extract(Stream stream, string outputDir, string password, string[] files)` | | +| `List` | `List List(Stream stream, string password)` | | #### `AlZipReader` -Reads ALZip (.alz) archive files. +Reads single-volume ALZip archives: "ALZ\x01" + 4 bytes, local file headers ("BLZ\x01"), then the central-directory and end records ("CLZ\x01", "CLZ\x02"). Members are stored, deflated or ALZ-bzip2'd, optionally ZipCrypto-encrypted. Names are CP949. Implements `IDisposable`. | Member | Signature | Summary | | --- | --- | --- | -| `AlZipReader` | `AlZipReader(Stream stream, bool leaveOpen = false)` | Creates a new ALZip reader over the given stream. | -| `Entries` | `IReadOnlyList Entries { get; }` | File entries in the archive. | +| `AlZipReader` | `AlZipReader(Stream stream, bool leaveOpen = false)` | | +| `Entries` | `IReadOnlyList Entries { get; }` | | +| `IsSplit` | `bool IsSplit { get; }` | Whether the archive is one volume of a split set (only whole entries of it are readable). | | `Dispose` | `void Dispose()` | | -| `Extract` | `byte[] Extract(AlZipEntry entry)` | Extracts the raw (decompressed) data for the given entry. | +| `Extract` | `byte[] Extract(AlZipEntry entry, string password = null)` | Decompresses (and if needed decrypts) a member, verifying its CRC-32. | #### `AlZipWriter` -Creates ALZip (.alz) archive files. +Writes single-volume ALZip archives as ALZip 8 lays them out: the "ALZ\x01" header with version 0x0A, one "BLZ\x01" local header per member (stored or deflated, size fields as narrow as the sizes allow, CP949 names, optional ZipCrypto), then the "CLZ\x01" and "CLZ\x02" end records. Implements `IDisposable`. | Member | Signature | Summary | | --- | --- | --- | -| `AlZipWriter` | `AlZipWriter(Stream stream, bool leaveOpen = false)` | Creates a new ALZip writer over the given stream. | -| `AddDirectory` | `void AddDirectory(string dirName)` | Adds a directory entry to the archive. | -| `AddFile` | `void AddFile(string fileName, byte[] data)` | Adds a file to the archive with deflate compression. | +| `AlZipWriter` | `AlZipWriter(Stream stream, bool leaveOpen = false, string password = null, bool store = false)` | | +| `AddDirectory` | `void AddDirectory(string dirName, DateTime? modified = null)` | | +| `AddFile` | `void AddFile(string fileName, byte[] data, DateTime? modified = null)` | | | `Dispose` | `void Dispose()` | | +| `Finish` | `void Finish()` | Writes the end records; called by `Dispose`. | ### Namespace `FileFormat.Alac` 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..75eaaa750 100644 --- a/docs/catalogue/reference-formats.csv +++ b/docs/catalogue/reference-formats.csv @@ -41,7 +41,7 @@ PAK (NoGate ARC extension),pak,archive,,,R,,,,Y,2,,,,,,MISSING,, zoo,zoo,archive,,,R,,R,x-fmt/269,Y,4,https://en.wikipedia.org/wiki/Zoo_(file_format),Zoo,Y,Y,Y,OK,, LBR (CP/M library),lbr;lqr;lzr,archive,,,R,,R,,Y,3,https://en.wikipedia.org/wiki/LBR_(file_format),Lbr,Y,Y,Y,OK,, LBR (Commodore library),lbr,archive,,,,,,,Y,1,,,,,,MISSING,, -ALZip,alz,archive,,,R,,R,,RW,3,https://en.wikipedia.org/wiki/ALZip,AlZip,Y,Y,Y,PARTIAL,,store and deflate only (AlZipReader.cs) +ALZip,alz,archive,,,R,,R,,RW,3,https://en.wikipedia.org/wiki/ALZip,AlZip,Y,Y,Y,PARTIAL,,"split (multi-volume) archives and obfuscated deflate (method 3) refused; writer emits stored/deflate, not ALZ bzip2 (AlZipReader.cs)" EGG (ALZip),egg,archive,,,R,,,,,1,https://en.wikipedia.org/wiki/ALZip,Egg,Y,Y,,PARTIAL,,"store and deflate only; bzip2/LZMA/AZO, encryption and split archives unsupported (EggReader.cs)" StuffIt (classic + 5),sit;sea,archive,,,R,,R,fmt/1459;fmt/1460;fmt/639,RW,4,https://en.wikipedia.org/wiki/StuffIt,StuffIt,Y,Y,Y,PARTIAL,,store and RLE only; LZW/Huffman/LZAH/Arsenic/DES methods throw (StuffItReader.cs) StuffIt X,sitx,archive,,,R,,R,fmt/399,RW,4,https://en.wikipedia.org/wiki/StuffIt,StuffItX,Y,Y,,PARTIAL,,stored and deflate only; Brimstone/Cyanide/Darkhorse/Iron throw (StuffItXReader.cs)