diff --git a/Compression.Tests/Compression.Tests.csproj b/Compression.Tests/Compression.Tests.csproj index 023dbbe39..f57efc8ac 100644 --- a/Compression.Tests/Compression.Tests.csproj +++ b/Compression.Tests/Compression.Tests.csproj @@ -146,6 +146,10 @@ ZfsVectors.%(Filename)%(Extension) + + + StuffItVectors.%(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/Compression.Tests/StuffIt/ReferenceVectors/LICENSE.md b/Compression.Tests/StuffIt/ReferenceVectors/LICENSE.md new file mode 100644 index 000000000..1ca5b10a6 --- /dev/null +++ b/Compression.Tests/StuffIt/ReferenceVectors/LICENSE.md @@ -0,0 +1,27 @@ +By design, the files within the archives have been created from scratch and are +minimally novel in the hope that they will be ineligible for copyright. + +While I don't hold copyright to the self-extractor stubs present in the +self-extracting test archives, they may be redistributed freely. + +I hereby release anything in these archives that I _do_ hold copyright to into +the public domain using the Creative Commons +[CC0](http://creativecommons.org/publicdomain/zero/1.0/) public domain +dedication. + +

+ + CC0 + +
+ To the extent possible under law, + + Stephan Sokolow + has waived all copyright and related or neighboring rights to + StuffIt Test Files. +This work is published from: + + Canada. +

diff --git a/Compression.Tests/StuffIt/ReferenceVectors/README.md b/Compression.Tests/StuffIt/ReferenceVectors/README.md new file mode 100644 index 000000000..ff66555e3 --- /dev/null +++ b/Compression.Tests/StuffIt/ReferenceVectors/README.md @@ -0,0 +1,16 @@ +# StuffIt reference archives + +Taken unmodified from [ssokolow/stuffit-test-files](https://github.com/ssokolow/stuffit-test-files) +at revision `b523c64337867aee5cc07ad3a6bbfc8e06e4b3c0` (`build/`), released under CC0 (see +`LICENSE.md` beside this file). They were made with registered StuffIt installs, never by +CompressionWorkbench. The archived files are that repository's `sources/` (`testfile.txt`, +`testfile.jpg`, `testfile.png`, `testfile.PICT`, `Test Text`, `Test Image`); the Mac-made archives +carry resource forks, whose expected content comes from the AppleDouble `._*` files there. The +tests hold the SHA-256 of every fork. + +| File | Made by | Layout | Methods | +| --- | --- | --- | --- | +| `testfile.stuffit45_dlx.mac9.sit` | StuffIt Deluxe 4.5, Mac OS 9 | classic `SIT!` | 13 (LZ+Huffman), 0 | +| `testfile.stuffit651_dlx.mac9.sit` | StuffIt Deluxe 6.5.1, Mac OS 9 | StuffIt 5 (entry version 1) | 15 (Arsenic), 0 | +| `testfile.stuffit7_dlx.mac9.sit` | StuffIt Deluxe 7.0, Mac OS 9 | StuffIt 5 (entry version 1) | 15 (Arsenic), 0 | +| `testfile.stuffit7.win.sit` | StuffIt 7 for Windows | StuffIt 5 (entry version 3), folder `sources/` | 15 (Arsenic) | diff --git a/Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit45_dlx.mac9.sit b/Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit45_dlx.mac9.sit new file mode 100644 index 000000000..d114982d5 Binary files /dev/null and b/Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit45_dlx.mac9.sit differ diff --git a/Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit651_dlx.mac9.sit b/Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit651_dlx.mac9.sit new file mode 100644 index 000000000..0ed763b34 Binary files /dev/null and b/Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit651_dlx.mac9.sit differ diff --git a/Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit7.win.sit b/Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit7.win.sit new file mode 100644 index 000000000..e6b604ccd Binary files /dev/null and b/Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit7.win.sit differ diff --git a/Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit7_dlx.mac9.sit b/Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit7_dlx.mac9.sit new file mode 100644 index 000000000..4606f4c8f Binary files /dev/null and b/Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit7_dlx.mac9.sit differ diff --git a/Compression.Tests/StuffIt/StuffItCorpusTests.cs b/Compression.Tests/StuffIt/StuffItCorpusTests.cs new file mode 100644 index 000000000..abaf9323e --- /dev/null +++ b/Compression.Tests/StuffIt/StuffItCorpusTests.cs @@ -0,0 +1,224 @@ +using System.Reflection; +using System.Security.Cryptography; +using FileFormat.StuffIt; + +namespace Compression.Tests.StuffIt; + +/// +/// Archives made by registered StuffIt installs (ssokolow/stuffit-test-files, CC0; see +/// StuffIt/ReferenceVectors/README.md): every fork must decode to the source file it was made from, +/// and The Unarchiver's unar (the reference open-source reader) must agree with us. +/// +[TestFixture] +public class StuffItCorpusTests { + + /// SHA-256 and size of each source file's data and resource fork (from the corpus' sources/ and their AppleDouble files). + private static readonly Dictionary Sources = new() { + ["Test Image"] = ("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, "4b8175653903645616d9e07627957ae0dba4c7ac3b3e9aa6afc8e07144dcfbb0", 9134), + ["Test Text"] = ("9734aef6d3788ba985e78f7b3785dc4817e770be92a4e5e57e64a92cc9c2fc25", 11, "5f0c7e77ac2430be40532730665ea27f0cf1088ac049e0c06851d62085b87315", 332), + ["testfile.PICT"] = ("318d71cd4d027c6bec6917af3ddc3b7df0ec8b07031045a9cdd9052b94c7782e", 2694, "011604ad448ef4451081d04bd395c2a974cab637877fb64b45e62ebe39bc452e", 44549), + ["testfile.txt"] = ("b645efee0ed710034959eae942277a750d08687c30bcf0e9ec6ea7641527462f", 12, "f788dcd5313a531a27fc62a9b4c951a6653ef11b49f2262ee0796f72c5564b0a", 332), + ["testfile.jpg"] = ("e514232511df1a4f4221a75c27523518c3c62a2fe6470fa56e430364428eecd1", 220, "", 0), + ["testfile.png"] = ("fdda20984cc1591419ec4583e24e72e4dba39d0b96608253f853a2dfb238ad1a", 87, "", 0), + }; + + private static string Sha(byte[] b) => Convert.ToHexStringLower(SHA256.HashData(b)); + + // classic SIT! 4.5 = method 13; StuffIt 5 format from 6.5.1/7.0 Mac = method 15 (+ stored), 7.0 Windows = folder + method 15. + [TestCase("testfile.stuffit45_dlx.mac9.sit", false, 13)] + [TestCase("testfile.stuffit651_dlx.mac9.sit", true, 15)] + [TestCase("testfile.stuffit7_dlx.mac9.sit", true, 15)] + [TestCase("testfile.stuffit7.win.sit", true, 15)] + [Category("ThemVsUs")] + public void GivenArchiveMadeByStuffIt_WhenEveryForkIsExtracted_ThenItMatchesTheSourceFile(string vector, bool sit5, int method) { + using var reader = new StuffItReader(new MemoryStream(LoadVector(vector))); + Assert.That(reader.IsStuffIt5, Is.EqualTo(sit5)); + + var files = reader.Entries.Where(e => !e.IsDirectory).ToList(); + Assert.That(files, Is.Not.Empty); + Assert.That(files.Any(e => e.DataMethod == method || e.ResourceMethod == method), $"no fork uses method {method}"); + foreach (var e in files) { + var leaf = e.FileName[(e.FileName.LastIndexOf('/') + 1)..]; + Assert.That(Sources.ContainsKey(leaf), $"unexpected entry {e.FileName}"); + var (data, dataSize, rsrc, rsrcSize) = Sources[leaf]; + // StuffIt for Windows converts the Mac text file's CR line ending to LF (unar agrees). + if (vector.Contains(".win.", StringComparison.Ordinal) && leaf == "testfile.txt") + data = "b2f51cd17b3cbe77f091f887d91110164a2cb5a5a9ebe828c44d655c83dca8eb"; + + var d = reader.Extract(e); + Assert.That(d.Length, Is.EqualTo(dataSize), e.FileName); + Assert.That(Sha(d), Is.EqualTo(data), e.FileName + " data fork"); + + // The Windows build stores no resource forks. + if (e.HasResourceFork) { + var r = reader.ExtractResourceFork(e); + Assert.That(r.Length, Is.EqualTo(rsrcSize), e.FileName); + Assert.That(Sha(r), Is.EqualTo(rsrc), e.FileName + " resource fork"); + } + } + } + + [Test, Category("ThemVsUs")] + public void GivenWindowsStuffIt7Archive_WhenListed_ThenTheFolderAndItsFilesKeepTheirPaths() { + var d = new StuffItFormatDescriptor(); + var names = d.List(new MemoryStream(LoadVector("testfile.stuffit7.win.sit")), null).Select(e => (e.Name, e.IsDirectory)).ToList(); + + Assert.That(names, Does.Contain(("sources", true))); + Assert.That(names, Does.Contain(("sources/testfile.txt", false))); + Assert.That(names, Does.Contain(("sources/testfile.jpg", false))); + Assert.That(names, Does.Contain(("sources/testfile.png", false))); + } + + [Test, Category("ThemVsUs")] + public void GivenMacArchive_WhenListedThroughTheDescriptor_ThenResourceForksAppearAsRsrcEntries() { + var d = new StuffItFormatDescriptor(); + var entries = d.List(new MemoryStream(LoadVector("testfile.stuffit45_dlx.mac9.sit")), null); + + Assert.That(entries.Select(e => e.Name), Does.Contain("testfile.PICT.rsrc")); + Assert.That(entries.Single(e => e.Name == "Test Image.rsrc").OriginalSize, Is.EqualTo(9134)); + Assert.That(entries.Select(e => e.Name), Does.Not.Contain("Test Image")); // empty data fork, resource only + Assert.That(entries.Single(e => e.Name == "testfile.PICT").Method, Is.EqualTo("LZ+Huffman")); + } + + [Test, Category("Exception")] + public void GivenDamagedMethod13Fork_WhenExtracted_ThenTheCrcRefusesIt() { + var bytes = LoadVector("testfile.stuffit45_dlx.mac9.sit"); + using (var probe = new StuffItReader(new MemoryStream(bytes))) { + var pict = probe.Entries.Single(e => e.FileName == "testfile.PICT"); + bytes[pict.DataForkOffset + pict.CompressedDataSize / 2] ^= 0x20; + } + + using var reader = new StuffItReader(new MemoryStream(bytes)); + var e = reader.Entries.Single(x => x.FileName == "testfile.PICT"); + Assert.That(() => reader.Extract(e), Throws.InstanceOf()); + } + + [Test, Category("Exception")] + public void GivenDamagedArsenicFork_WhenExtracted_ThenItIsRefused() { + var bytes = LoadVector("testfile.stuffit7_dlx.mac9.sit"); + using (var probe = new StuffItReader(new MemoryStream(bytes))) { + var pict = probe.Entries.Single(e => e.FileName == "testfile.PICT"); + bytes[pict.DataForkOffset + pict.CompressedDataSize / 2] ^= 0x20; + } + + using var reader = new StuffItReader(new MemoryStream(bytes)); + var e = reader.Entries.Single(x => x.FileName == "testfile.PICT"); + Assert.That(() => reader.Extract(e), Throws.InstanceOf()); + } + + [Test, Category("Exception")] + public void GivenDamagedClassicEntryHeader_WhenOpened_ThenTheHeaderCrcRefusesIt() { + var bytes = LoadVector("testfile.stuffit45_dlx.mac9.sit"); + bytes[22 + 5] ^= 0x01; // inside the first entry's name + + Assert.That(() => new StuffItReader(new MemoryStream(bytes)), Throws.InstanceOf()); + } + + [Test, Category("ExternalInterop")] + public void GivenEveryReferenceArchive_WhenUnarExtractsIt_ThenItsDataForksMatchOurs() { + if (OperatingSystem.IsWindows() && !FsInteropToolbox.WslAvailable) Assert.Ignore("WSL is not available."); + if (!FsInteropToolbox.WslHasTool("unar")) Assert.Ignore("unar (The Unarchiver) is not installed."); + var dir = Path.Combine(Path.GetTempPath(), $"cwb_sit_unar_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try { + foreach (var vector in new[] { "testfile.stuffit45_dlx.mac9.sit", "testfile.stuffit651_dlx.mac9.sit", "testfile.stuffit7.win.sit" }) { + var path = Path.Combine(dir, vector); + File.WriteAllBytes(path, LoadVector(vector)); + var outDir = Path.Combine(dir, vector + ".out"); + var r = FsInteropToolbox.RunWsl($"unar -q -f -k hidden -D -o '{FsInteropToolbox.WinToWsl(outDir)}' '{FsInteropToolbox.WinToWsl(path)}'"); + Assert.That(r.ExitCode, Is.EqualTo(0), $"unar failed on {vector}:\n{r.StdOut}\n{r.StdErr}"); + + using var reader = new StuffItReader(File.OpenRead(path)); + foreach (var e in reader.Entries.Where(x => !x.IsDirectory && x.DataForkSize > 0)) { + var theirs = Path.Combine(outDir, e.FileName.Replace('/', Path.DirectorySeparatorChar)); + Assert.That(File.Exists(theirs), $"unar produced no {e.FileName} from {vector}"); + Assert.That(reader.Extract(e), Is.EqualTo(File.ReadAllBytes(theirs)), $"{vector}: {e.FileName}"); + } + } + } finally { + try { Directory.Delete(dir, true); } catch { /* best effort */ } + } + } + + /// + /// Any further real archives (raw or MacBinary-wrapped .sit) in the directory named by + /// CWB_STUFFIT_SAMPLES are decoded and compared with unar, fork by fork. Used for samples + /// whose licence keeps them out of the repository (e.g. the dexvert StuffIt set, which carries + /// methods 2 and 3). + /// + [Test, Category("ExternalInterop")] + public void GivenLocalSampleArchives_WhenDecoded_ThenEveryDataForkMatchesUnar() { + var samples = Environment.GetEnvironmentVariable("CWB_STUFFIT_SAMPLES"); + if (string.IsNullOrEmpty(samples) || !Directory.Exists(samples)) Assert.Ignore("CWB_STUFFIT_SAMPLES is not set."); + if (OperatingSystem.IsWindows() && !FsInteropToolbox.WslAvailable) Assert.Ignore("WSL is not available."); + if (!FsInteropToolbox.WslHasTool("unar")) Assert.Ignore("unar (The Unarchiver) is not installed."); + + var methods = new SortedSet(); + var compared = 0; + var tmp = Path.Combine(Path.GetTempPath(), $"cwb_sit_local_{Guid.NewGuid():N}"); + try { + foreach (var file in Directory.EnumerateFiles(samples)) { + var bytes = File.ReadAllBytes(file); + var offset = bytes.Length > 132 && bytes.AsSpan(128, 4).SequenceEqual("SIT!"u8) ? 128 : 0; + if (!bytes.AsSpan(offset).StartsWith("SIT!"u8) && !bytes.AsSpan(offset).StartsWith("StuffIt (c)1997-"u8)) continue; + var raw = Path.Combine(tmp, Path.GetFileName(file) + ".raw.sit"); + Directory.CreateDirectory(tmp); + File.WriteAllBytes(raw, bytes[offset..]); + var outDir = Path.Combine(tmp, Path.GetFileName(file) + ".out"); + var r = FsInteropToolbox.RunWsl($"unar -q -f -k hidden -D -o '{FsInteropToolbox.WinToWsl(outDir)}' '{FsInteropToolbox.WinToWsl(raw)}'"); + Assert.That(r.ExitCode, Is.EqualTo(0), $"unar failed on {file}:\n{r.StdErr}"); + + using var reader = new StuffItReader(new MemoryStream(bytes, offset, bytes.Length - offset)); + foreach (var e in reader.Entries.Where(x => !x.IsDirectory && x.DataForkSize > 0 && !x.IsEncrypted)) { + var theirs = Path.Combine(outDir, e.FileName.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(theirs)) continue; // unar renames names the host cannot hold + Assert.That(reader.Extract(e), Is.EqualTo(File.ReadAllBytes(theirs)), $"{file}: {e.FileName} (method {e.DataMethod})"); + methods.Add(e.DataMethod); + ++compared; + } + } + } finally { + try { Directory.Delete(tmp, true); } catch { /* best effort */ } + } + Assert.That(compared, Is.GreaterThan(0), "no StuffIt archive found in CWB_STUFFIT_SAMPLES"); + TestContext.Out.WriteLine($"compared {compared} data forks; methods {string.Join(',', methods)}"); + } + + [Test, Category("ExternalInterop")] + public void GivenArchiveFromOurWriter_WhenUnarExtractsIt_ThenStoredAndRleForksMatch() { + if (OperatingSystem.IsWindows() && !FsInteropToolbox.WslAvailable) Assert.Ignore("WSL is not available."); + if (!FsInteropToolbox.WslHasTool("unar")) Assert.Ignore("unar (The Unarchiver) is not installed."); + var dir = Path.Combine(Path.GetTempPath(), $"cwb_sit_w_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try { + var runs = Enumerable.Range(0, 5000).Select(i => (byte)(i / 300 % 3 == 0 ? 0x90 : i / 100)).ToArray(); // long runs incl. 0x90 -> RLE + var noise = Enumerable.Range(0, 3000).Select(i => (byte)(i * 131 + (i >> 3))).ToArray(); // incompressible -> stored + var path = Path.Combine(dir, "ours.sit"); + using (var f = File.Create(path)) + using (var w = new StuffItWriter(f, leaveOpen: true)) { + w.AddFile("runs.bin", runs); + w.AddFile("noise.bin", noise); + w.AddFile("empty.txt", []); + } + using (var r = new StuffItReader(File.OpenRead(path))) + Assert.That(r.Entries.Single(e => e.FileName == "runs.bin").DataMethod, Is.EqualTo(1)); + + var outDir = Path.Combine(dir, "out"); + var x = FsInteropToolbox.RunWsl($"unar -q -f -k hidden -D -o '{FsInteropToolbox.WinToWsl(outDir)}' '{FsInteropToolbox.WinToWsl(path)}'"); + Assert.That(x.ExitCode, Is.EqualTo(0), $"unar rejected our archive:\n{x.StdOut}\n{x.StdErr}"); + Assert.That(File.ReadAllBytes(Path.Combine(outDir, "runs.bin")), Is.EqualTo(runs)); + Assert.That(File.ReadAllBytes(Path.Combine(outDir, "noise.bin")), Is.EqualTo(noise)); + Assert.That(File.ReadAllBytes(Path.Combine(outDir, "empty.txt")), Is.Empty); + } finally { + try { Directory.Delete(dir, true); } catch { /* best effort */ } + } + } + + private static byte[] LoadVector(string name) { + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream($"StuffItVectors.{name}") + ?? throw new InvalidOperationException($"missing embedded vector {name}"); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } +} diff --git a/Compression.Tests/StuffIt/StuffItTests.cs b/Compression.Tests/StuffIt/StuffItTests.cs index 0af0a9117..f1f4f101e 100644 --- a/Compression.Tests/StuffIt/StuffItTests.cs +++ b/Compression.Tests/StuffIt/StuffItTests.cs @@ -19,11 +19,12 @@ private static byte[] BuildSitArchive( uint dataForkOriginalSize, byte[] resourceForkCompressed, uint resourceForkOriginalSize, - uint macModDate = 0) { + uint macModDate = 0, + byte[]? dataForkOriginal = null) { var nameBytes = Encoding.ASCII.GetBytes(fileName); - if (nameBytes.Length > 63) - Array.Resize(ref nameBytes, 63); + if (nameBytes.Length > 31) + Array.Resize(ref nameBytes, 31); // ── Entry header (112 bytes) ────────────────────────────────────────────── var entryHeader = new byte[112]; @@ -45,11 +46,11 @@ private static byte[] BuildSitArchive( BinaryPrimitives.WriteUInt32BigEndian(entryHeader.AsSpan(92, 4), (uint)resourceForkCompressed.Length); BinaryPrimitives.WriteUInt32BigEndian(entryHeader.AsSpan(96, 4), (uint)dataForkCompressed.Length); - // CRC-16 = 0 (no checksum stored — reader skips verification when 0) - // [100..102] resource fork CRC-16 = 0 - // [102..104] data fork CRC-16 = 0 - // [104..110] reserved - // [110..112] header CRC-16 = 0 (not verified by reader) + // Fork CRCs and the header CRC are CRC-16/ARC, as StuffIt and every reader use them. + var dataOriginal = dataForkOriginal ?? (dataMethod == 0 ? dataForkCompressed : []); + BinaryPrimitives.WriteUInt16BigEndian(entryHeader.AsSpan(100, 2), Compression.Core.Checksums.Crc16.Compute(resourceForkCompressed)); + BinaryPrimitives.WriteUInt16BigEndian(entryHeader.AsSpan(102, 2), Compression.Core.Checksums.Crc16.Compute(dataOriginal)); + BinaryPrimitives.WriteUInt16BigEndian(entryHeader.AsSpan(110, 2), Compression.Core.Checksums.Crc16.Compute(entryHeader.AsSpan(0, 110))); // ── Archive header (22 bytes) ───────────────────────────────────────────── var archiveHeader = new byte[22]; @@ -241,9 +242,11 @@ private static byte[] RleEncode(byte[] data) { ++runEnd; var runLength = runEnd - i; output.Add(value); - if (runLength >= 2) { + if (runLength >= 3) { output.Add(0x90); - output.Add((byte)(runLength - 1)); + output.Add((byte)runLength); // RLE90: the run is n long including the byte before the marker + } else if (runLength == 2) { + output.Add(value); } i += runLength; } @@ -258,7 +261,7 @@ public void Rle_Extract_RepetitiveData() { Array.Fill(original, (byte)'A'); var compressed = RleEncode(original); - var archive = BuildSitArchive("rle.txt", 1, compressed, (uint)original.Length, [], 0); + var archive = BuildSitArchive("rle.txt", 1, compressed, (uint)original.Length, [], 0, dataForkOriginal: original); using var ms = new MemoryStream(archive); using var reader = new StuffItReader(ms, leaveOpen: true); @@ -275,7 +278,7 @@ public void Rle_Extract_LiteralEscapeMarker() { byte[] original = [0x90, 0x41, 0x90]; byte[] compressed = [0x90, 0x00, 0x41, 0x90, 0x00]; - var archive = BuildSitArchive("esc.bin", 1, compressed, (uint)original.Length, [], 0); + var archive = BuildSitArchive("esc.bin", 1, compressed, (uint)original.Length, [], 0, dataForkOriginal: original); using var ms = new MemoryStream(archive); using var reader = new StuffItReader(ms, leaveOpen: true); @@ -291,7 +294,7 @@ public void Rle_Extract_MixedRunsAndLiterals() { byte[] original = [0x41, 0x41, 0x41, 0x42, 0x43, 0x43]; var compressed = RleEncode(original); - var archive = BuildSitArchive("mixed.bin", 1, compressed, (uint)original.Length, [], 0); + var archive = BuildSitArchive("mixed.bin", 1, compressed, (uint)original.Length, [], 0, dataForkOriginal: original); using var ms = new MemoryStream(archive); using var reader = new StuffItReader(ms, leaveOpen: true); @@ -406,8 +409,8 @@ public void MultiEntry_ExtractEachEntry() { [Category("ErrorHandling")] [Test] public void Extract_UnsupportedMethod_Throws() { - // Method 2 = LZC — not supported. - var archive = BuildSitArchive("f.bin", 2, [0xAB, 0xCD], 100, [], 0); + // Method 8 = MW — not supported. + var archive = BuildSitArchive("f.bin", 8, [0xAB, 0xCD], 100, [], 0); using var ms = new MemoryStream(archive); using var reader = new StuffItReader(ms, leaveOpen: true); @@ -443,7 +446,7 @@ public void Rle_TruncatedAfterMarker_Throws() { [Category("HappyPath")] [Test] - public void Entry_IsDirectory_AlwaysFalse() { + public void Entry_IsDirectory_FalseForAFile() { var archive = BuildStoredArchive("myfile.txt", [0x41]); using var ms = new MemoryStream(archive); @@ -544,7 +547,7 @@ private static void WriteEntry( uint macModDate) { var nameBytes = Encoding.ASCII.GetBytes(fileName); - if (nameBytes.Length > 63) Array.Resize(ref nameBytes, 63); + if (nameBytes.Length > 31) Array.Resize(ref nameBytes, 31); var hdr = new byte[112]; hdr[0] = 0; // resource method = Store @@ -560,6 +563,9 @@ private static void WriteEntry( BinaryPrimitives.WriteUInt32BigEndian(hdr.AsSpan(88, 4), (uint)dataForkOrigSize); BinaryPrimitives.WriteUInt32BigEndian(hdr.AsSpan(92, 4), (uint)resourceFork.Length); // rsrc compressed BinaryPrimitives.WriteUInt32BigEndian(hdr.AsSpan(96, 4), (uint)dataFork.Length); // data compressed + BinaryPrimitives.WriteUInt16BigEndian(hdr.AsSpan(100, 2), Compression.Core.Checksums.Crc16.Compute(resourceFork)); + BinaryPrimitives.WriteUInt16BigEndian(hdr.AsSpan(102, 2), Compression.Core.Checksums.Crc16.Compute(dataFork)); + BinaryPrimitives.WriteUInt16BigEndian(hdr.AsSpan(110, 2), Compression.Core.Checksums.Crc16.Compute(hdr.AsSpan(0, 110))); ms.Write(hdr); ms.Write(resourceFork); diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffIt13Decoder.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffIt13Decoder.cs new file mode 100644 index 000000000..b85019a54 --- /dev/null +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffIt13Decoder.cs @@ -0,0 +1,117 @@ +namespace FileFormat.StuffIt; + +/// +/// StuffIt method 13 ("LZ+Huffman", the default of StuffIt 3-5): LZSS over a 64 KiB window with +/// two literal/length codes (one used after a literal, the other after a match) and a distance +/// code, all read least-significant bit first. The first byte selects either one of five +/// built-in code sets or codes transmitted with a 37-symbol meta code. +/// +/// Converted from XADMaster's XADStuffIt13Handle (LGPL-2.1-or-later); tables copied verbatim. +internal static class StuffIt13Decoder { + private const int WindowSize = 65536; + + public static byte[] Decode(byte[] input, int outputLength) { + var output = new byte[outputLength]; + if (outputLength == 0) return output; + var reader = new StuffItBitReader(input); + + var selector = reader.ReadByte(); + var set = selector >> 4; + StuffItPrefixCode first, second, offsets; + if (set == 0) { + var meta = new StuffItPrefixCode(); + for (var i = 0; i < MetaCodes.Length; ++i) meta.AddLowBitFirst(i, (uint)MetaCodes[i], MetaCodeLengths[i]); + first = ReadCode(reader, meta, 321); + second = (selector & 0x08) != 0 ? first : ReadCode(reader, meta, 321); + offsets = ReadCode(reader, meta, (selector & 0x07) + 10); + } else if (set < 6) { + first = StuffItPrefixCode.FromLengths(FirstCodeLengths[set - 1]); + second = StuffItPrefixCode.FromLengths(SecondCodeLengths[set - 1]); + offsets = StuffItPrefixCode.FromLengths(OffsetCodeLengths[set - 1]); + } else { + throw new InvalidDataException($"StuffIt method 13 code set {set} is invalid."); + } + + var current = first; + var o = 0; + while (o < outputLength) { + var symbol = current.DecodeLe(reader); + if (symbol < 0x100) { + output[o++] = (byte)symbol; + current = first; + continue; + } + current = second; + int length; + if (symbol < 0x13E) length = symbol - 0x100 + 3; + else if (symbol == 0x13E) length = reader.ReadBitsLe(10) + 65; + else if (symbol == 0x13F) length = reader.ReadBitsLe(15) + 65; + else break; // 0x140: end of stream + + var bitLength = offsets.DecodeLe(reader); + var distance = bitLength switch { + 0 => 1, + 1 => 2, + _ => (1 << (bitLength - 1)) + reader.ReadBitsLe(bitLength - 1) + 1, + }; + if (distance > WindowSize) + throw new InvalidDataException("StuffIt method 13 match reaches past the 64 KiB window."); + // The window starts zero-filled, so a match reaching before the first byte copies zeros. + for (var k = 0; k < length && o < outputLength; ++k, ++o) output[o] = o >= distance ? output[o - distance] : (byte)0; + } + if (o != outputLength) throw new InvalidDataException("StuffIt method 13 data ended before the recorded size."); + return output; + } + + private static StuffItPrefixCode ReadCode(StuffItBitReader reader, StuffItPrefixCode meta, int count) { + var lengths = new int[count]; + var length = 0; + for (var i = 0; i < count; ++i) { + var value = meta.DecodeLe(reader); + switch (value) { + case 31: length = -1; break; + case 32: ++length; break; + case 33: --length; break; + case 34: + if (reader.ReadBitLe() != 0) Set(lengths, i++, length); + break; + case 35: + for (var n = reader.ReadBitsLe(3) + 2; n > 0; --n) Set(lengths, i++, length); + break; + case 36: + for (var n = reader.ReadBitsLe(6) + 10; n > 0; --n) Set(lengths, i++, length); + break; + default: + length = value + 1; + break; + } + Set(lengths, i, length); + } + return StuffItPrefixCode.FromLengths(lengths); + } + + private static void Set(int[] lengths, int index, int length) { + if ((uint)index >= (uint)lengths.Length) + throw new InvalidDataException("StuffIt method 13 code-length run overflows the table."); + lengths[index] = length; + } + + // ── tables (verbatim from XADStuffIt13Handle.m) ────────────────────── + + private static readonly int[] MetaCodes = [ + 0x5d8, 0x058, 0x040, 0x0c0, 0x000, 0x078, 0x02b, 0x014, + 0x00c, 0x01c, 0x01b, 0x00b, 0x010, 0x020, 0x038, 0x018, + 0x0d8, 0xbd8, 0x180, 0x680, 0x380, 0xf80, 0x780, 0x480, + 0x080, 0x280, 0x3d8, 0xfd8, 0x7d8, 0x9d8, 0x1d8, 0x004, + 0x001, 0x002, 0x007, 0x003, 0x008, + ]; + + private static readonly int[] MetaCodeLengths = [ + 11, 8, 8, 8, 8, 7, 6, 5, 5, 5, 5, 6, 5, 6, 7, 7, 9, 12, 10, 11, 11, 12, + 12, 11, 11, 11, 12, 12, 12, 12, 12, 5, 2, 2, 3, 4, 5, + ]; + + private static readonly int[][] FirstCodeLengths = [StuffIt13Tables.First1, StuffIt13Tables.First2, StuffIt13Tables.First3, StuffIt13Tables.First4, StuffIt13Tables.First5]; + private static readonly int[][] SecondCodeLengths = [StuffIt13Tables.Second1, StuffIt13Tables.Second2, StuffIt13Tables.Second3, StuffIt13Tables.Second4, StuffIt13Tables.Second5]; + private static readonly int[][] OffsetCodeLengths = [StuffIt13Tables.Offset1, StuffIt13Tables.Offset2, StuffIt13Tables.Offset3, StuffIt13Tables.Offset4, StuffIt13Tables.Offset5]; +} diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffIt13Tables.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffIt13Tables.cs new file mode 100644 index 000000000..5920890c9 --- /dev/null +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffIt13Tables.cs @@ -0,0 +1,264 @@ +namespace FileFormat.StuffIt; + +/// Built-in code-length tables of StuffIt method 13 (copied verbatim from XADMaster's XADStuffIt13Handle.m). +internal static class StuffIt13Tables { + public static readonly int[] First1 = [ + 4, 5, 7, 8, 8, 9, 9, 9, 9, 7, 9, 9, 9, 8, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 10, 9, 9, 10, 10, 9, 10, 9, 9, + 5, 9, 9, 9, 9, 10, 9, 9, 9, 9, 9, 9, 9, 9, 7, 9, + 9, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + 9, 8, 9, 9, 8, 8, 9, 9, 9, 9, 9, 9, 9, 7, 8, 9, + 7, 9, 9, 7, 7, 9, 9, 9, 9, 10, 9, 10, 10, 10, 9, 9, + 9, 5, 9, 8, 7, 5, 9, 8, 8, 7, 9, 9, 8, 8, 5, 5, + 7, 10, 5, 8, 5, 8, 9, 9, 9, 9, 9, 10, 9, 9, 10, 9, + 9, 10, 10, 10, 10, 10, 10, 10, 9, 10, 10, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 10, 10, 10, 10, 9, 9, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 9, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 9, 9, 10, 10, + 9, 10, 10, 10, 10, 10, 10, 10, 9, 10, 10, 10, 9, 10, 9, 5, + 6, 5, 5, 8, 9, 9, 9, 9, 9, 9, 10, 10, 10, 9, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 9, 10, 9, 9, 9, 10, 9, 10, 9, 10, 9, 10, 9, + 10, 10, 10, 9, 10, 9, 10, 10, 9, 9, 9, 6, 9, 9, 10, 9, + 5, + ]; + + public static readonly int[] Second1 = [ + 4, 5, 6, 6, 7, 7, 6, 7, 7, 7, 6, 8, 7, 8, 8, 8, + 8, 9, 6, 9, 8, 9, 8, 9, 9, 9, 8, 10, 5, 9, 7, 9, + 6, 9, 8, 10, 9, 10, 8, 8, 9, 9, 7, 9, 8, 9, 8, 9, + 8, 8, 6, 9, 9, 8, 8, 9, 9, 10, 8, 9, 9, 10, 8, 10, + 8, 8, 8, 8, 8, 9, 7, 10, 6, 9, 9, 11, 7, 8, 8, 9, + 8, 10, 7, 8, 6, 9, 10, 9, 9, 10, 8, 11, 9, 11, 9, 10, + 9, 8, 9, 8, 8, 8, 8, 10, 9, 9, 10, 10, 8, 9, 8, 8, + 8, 11, 9, 8, 8, 9, 9, 10, 8, 11, 10, 10, 8, 10, 9, 10, + 8, 9, 9, 11, 9, 11, 9, 10, 10, 11, 10, 12, 9, 12, 10, 11, + 10, 11, 9, 10, 10, 11, 10, 11, 10, 11, 10, 11, 10, 10, 10, 9, + 9, 9, 8, 7, 6, 8, 11, 11, 9, 12, 10, 12, 9, 11, 11, 11, + 10, 12, 11, 11, 10, 12, 10, 11, 10, 10, 10, 11, 10, 11, 11, 11, + 9, 12, 10, 12, 11, 12, 10, 11, 10, 12, 11, 12, 11, 12, 11, 12, + 10, 12, 11, 12, 11, 11, 10, 12, 10, 11, 10, 12, 10, 12, 10, 12, + 10, 11, 11, 11, 10, 11, 11, 11, 10, 12, 11, 12, 10, 10, 11, 11, + 9, 12, 11, 12, 10, 11, 10, 12, 10, 11, 10, 12, 10, 11, 10, 7, + 5, 4, 6, 6, 7, 7, 7, 8, 8, 7, 7, 6, 8, 6, 7, 7, + 9, 8, 9, 9, 10, 11, 11, 11, 12, 11, 10, 11, 12, 11, 12, 11, + 12, 12, 12, 12, 11, 12, 12, 11, 12, 11, 12, 11, 13, 11, 12, 10, + 13, 10, 14, 14, 13, 14, 15, 14, 16, 15, 15, 18, 18, 18, 9, 18, + 8, + ]; + + public static readonly int[] Offset1 = [ + 5, 6, 3, 3, 3, 3, 3, 3, 3, 4, 6, + ]; + + public static readonly int[] First2 = [ + 4, 7, 7, 8, 7, 8, 8, 8, 8, 7, 8, 7, 8, 7, 9, 8, + 8, 8, 9, 9, 9, 9, 10, 10, 9, 10, 10, 10, 10, 10, 9, 9, + 5, 9, 8, 9, 9, 11, 10, 9, 8, 9, 9, 9, 8, 9, 7, 8, + 8, 8, 9, 9, 9, 9, 9, 10, 9, 9, 9, 10, 9, 9, 10, 9, + 8, 8, 7, 7, 7, 8, 8, 9, 8, 8, 9, 9, 8, 8, 7, 8, + 7, 10, 8, 7, 7, 9, 9, 9, 9, 10, 10, 11, 11, 11, 10, 9, + 8, 6, 8, 7, 7, 5, 7, 7, 7, 6, 9, 8, 6, 7, 6, 6, + 7, 9, 6, 6, 6, 7, 8, 8, 8, 8, 9, 10, 9, 10, 9, 9, + 8, 9, 10, 10, 9, 10, 10, 9, 9, 10, 10, 10, 10, 10, 10, 10, + 9, 10, 10, 11, 10, 10, 10, 10, 10, 10, 10, 11, 10, 11, 10, 10, + 9, 11, 10, 10, 10, 10, 10, 10, 9, 9, 10, 11, 10, 11, 10, 11, + 10, 12, 10, 11, 10, 12, 11, 12, 10, 12, 10, 11, 10, 11, 11, 11, + 9, 10, 11, 11, 11, 12, 12, 10, 10, 10, 11, 11, 10, 11, 10, 10, + 9, 11, 10, 11, 10, 11, 11, 11, 10, 11, 11, 12, 11, 11, 10, 10, + 10, 11, 10, 10, 11, 11, 12, 10, 10, 11, 11, 12, 11, 11, 10, 11, + 9, 12, 10, 11, 11, 11, 10, 11, 10, 11, 10, 11, 9, 10, 9, 7, + 3, 5, 6, 6, 7, 7, 8, 8, 8, 9, 9, 9, 11, 10, 10, 10, + 12, 13, 11, 12, 12, 11, 13, 12, 12, 11, 12, 12, 13, 12, 14, 13, + 14, 13, 15, 13, 14, 15, 15, 14, 13, 15, 15, 14, 15, 14, 15, 15, + 14, 15, 13, 13, 14, 15, 15, 14, 14, 16, 16, 15, 15, 15, 12, 15, + 10, + ]; + + public static readonly int[] Second2 = [ + 5, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 8, 7, 8, 7, 7, + 7, 8, 8, 8, 8, 9, 8, 9, 8, 9, 9, 9, 7, 9, 8, 8, + 6, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 8, + 8, 8, 8, 9, 8, 9, 8, 9, 9, 10, 8, 10, 8, 9, 9, 8, + 8, 8, 7, 8, 8, 9, 8, 9, 7, 9, 8, 10, 8, 9, 8, 9, + 8, 9, 8, 8, 8, 9, 9, 9, 9, 10, 9, 11, 9, 10, 9, 10, + 8, 8, 8, 9, 8, 8, 8, 9, 9, 8, 9, 10, 8, 9, 8, 8, + 8, 11, 8, 7, 8, 9, 9, 9, 9, 10, 9, 10, 9, 10, 9, 8, + 8, 9, 9, 10, 9, 10, 9, 10, 8, 10, 9, 10, 9, 11, 10, 11, + 9, 11, 10, 10, 10, 11, 9, 11, 9, 10, 9, 11, 9, 11, 10, 10, + 9, 10, 9, 9, 8, 10, 9, 11, 9, 9, 9, 11, 10, 11, 9, 11, + 9, 11, 9, 11, 10, 11, 10, 11, 10, 11, 9, 10, 10, 11, 10, 10, + 8, 10, 9, 10, 10, 11, 9, 11, 9, 10, 10, 11, 9, 10, 10, 9, + 9, 10, 9, 10, 9, 10, 9, 10, 9, 11, 9, 11, 10, 10, 9, 10, + 9, 11, 9, 11, 9, 11, 9, 10, 9, 11, 9, 11, 9, 11, 9, 10, + 8, 11, 9, 10, 9, 10, 9, 10, 8, 10, 8, 9, 8, 9, 8, 7, + 4, 4, 5, 6, 6, 6, 7, 7, 7, 7, 8, 8, 8, 7, 8, 8, + 9, 9, 10, 10, 10, 10, 10, 10, 11, 11, 10, 10, 12, 11, 11, 12, + 12, 11, 12, 12, 11, 12, 12, 12, 12, 12, 12, 11, 12, 11, 13, 12, + 13, 12, 13, 14, 14, 14, 15, 13, 14, 13, 14, 18, 18, 17, 7, 16, + 9, + ]; + + public static readonly int[] Offset2 = [ + 5, 6, 4, 4, 3, 3, 3, 3, 3, 4, 4, 4, 6, + ]; + + public static readonly int[] First3 = [ + 6, 6, 6, 6, 6, 9, 8, 8, 4, 9, 8, 9, 8, 9, 9, 9, + 8, 9, 9, 10, 8, 10, 10, 10, 9, 10, 10, 10, 9, 10, 10, 9, + 9, 9, 8, 10, 9, 10, 9, 10, 9, 10, 9, 10, 9, 9, 8, 9, + 8, 9, 9, 9, 10, 10, 10, 10, 9, 9, 9, 10, 9, 10, 9, 9, + 7, 8, 8, 9, 8, 9, 9, 9, 8, 9, 9, 10, 9, 9, 8, 9, + 8, 9, 8, 8, 8, 9, 9, 9, 9, 9, 10, 10, 10, 10, 10, 9, + 8, 8, 9, 8, 9, 7, 8, 8, 9, 8, 10, 10, 8, 9, 8, 8, + 8, 10, 8, 8, 8, 8, 9, 9, 9, 9, 10, 10, 10, 10, 10, 9, + 7, 9, 9, 10, 10, 10, 10, 10, 9, 10, 10, 10, 10, 10, 10, 9, + 9, 10, 10, 10, 10, 10, 10, 10, 10, 9, 10, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 10, 10, 10, 10, 9, 9, 9, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 9, 10, 10, 10, 10, 9, + 8, 9, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 9, 10, 10, 10, + 9, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 9, + 9, 10, 10, 10, 10, 10, 10, 9, 10, 10, 10, 10, 10, 10, 9, 9, + 9, 10, 10, 10, 10, 10, 10, 9, 9, 10, 9, 9, 8, 9, 8, 9, + 4, 6, 6, 6, 7, 8, 8, 9, 9, 10, 10, 10, 9, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 7, 10, + 10, 10, 7, 10, 10, 7, 7, 7, 7, 7, 6, 7, 10, 7, 7, 10, + 7, 7, 7, 6, 7, 6, 6, 7, 7, 6, 6, 9, 6, 9, 10, 6, + 10, + ]; + + public static readonly int[] Second3 = [ + 5, 6, 6, 6, 6, 7, 7, 7, 6, 8, 7, 8, 7, 9, 8, 8, + 7, 7, 8, 9, 9, 9, 9, 10, 8, 9, 9, 10, 8, 10, 9, 8, + 6, 10, 8, 10, 8, 10, 9, 9, 9, 9, 9, 10, 9, 9, 8, 9, + 8, 9, 8, 9, 9, 10, 9, 10, 9, 9, 8, 10, 9, 11, 10, 8, + 8, 8, 8, 9, 7, 9, 9, 10, 8, 9, 8, 11, 9, 10, 9, 10, + 8, 9, 9, 9, 9, 8, 9, 9, 10, 10, 10, 12, 10, 11, 10, 10, + 8, 9, 9, 9, 8, 9, 8, 8, 10, 9, 10, 11, 8, 10, 9, 9, + 8, 12, 8, 9, 9, 9, 9, 8, 9, 10, 9, 12, 10, 10, 10, 8, + 7, 11, 10, 9, 10, 11, 9, 11, 7, 11, 10, 12, 10, 12, 10, 11, + 9, 11, 9, 12, 10, 12, 10, 12, 10, 9, 11, 12, 10, 12, 10, 11, + 9, 10, 9, 10, 9, 11, 11, 12, 9, 10, 8, 12, 11, 12, 9, 12, + 10, 12, 10, 13, 10, 12, 10, 12, 10, 12, 10, 9, 10, 12, 10, 9, + 8, 11, 10, 12, 10, 12, 10, 12, 10, 11, 10, 12, 8, 12, 10, 11, + 10, 10, 10, 12, 9, 11, 10, 12, 10, 12, 11, 12, 10, 9, 10, 12, + 9, 10, 10, 12, 10, 11, 10, 11, 10, 12, 8, 12, 9, 12, 8, 12, + 8, 11, 10, 11, 10, 11, 9, 10, 8, 10, 9, 9, 8, 9, 8, 7, + 4, 3, 5, 5, 6, 5, 6, 6, 7, 7, 8, 8, 8, 7, 7, 7, + 9, 8, 9, 9, 11, 9, 11, 9, 8, 9, 9, 11, 12, 11, 12, 12, + 13, 13, 12, 13, 14, 13, 14, 13, 14, 13, 13, 13, 12, 13, 13, 12, + 13, 13, 14, 14, 13, 13, 14, 14, 14, 14, 15, 18, 17, 18, 8, 16, + 10, + ]; + + public static readonly int[] Offset3 = [ + 6, 7, 4, 4, 3, 3, 3, 3, 3, 4, 4, 4, 5, 7, + ]; + + public static readonly int[] First4 = [ + 2, 6, 6, 7, 7, 8, 7, 8, 7, 8, 8, 9, 8, 9, 9, 9, + 8, 8, 9, 9, 9, 10, 10, 9, 8, 10, 9, 10, 9, 10, 9, 9, + 6, 9, 8, 9, 9, 10, 9, 9, 9, 10, 9, 9, 9, 9, 8, 8, + 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 10, 10, 9, + 7, 7, 8, 8, 8, 8, 9, 9, 7, 8, 9, 10, 8, 8, 7, 8, + 8, 10, 8, 8, 8, 9, 8, 9, 9, 10, 9, 11, 10, 11, 9, 9, + 8, 7, 9, 8, 8, 6, 8, 8, 8, 7, 10, 9, 7, 8, 7, 7, + 8, 10, 7, 7, 7, 8, 9, 9, 9, 9, 10, 11, 9, 11, 10, 9, + 7, 9, 10, 10, 10, 11, 11, 10, 10, 11, 10, 10, 10, 11, 11, 10, + 9, 10, 10, 11, 10, 11, 10, 11, 10, 10, 10, 11, 10, 11, 10, 10, + 9, 10, 10, 11, 10, 10, 10, 10, 9, 10, 10, 10, 10, 11, 10, 11, + 10, 11, 10, 11, 11, 11, 10, 12, 10, 11, 10, 11, 10, 11, 11, 10, + 8, 10, 10, 11, 10, 11, 11, 11, 10, 11, 10, 11, 10, 11, 11, 11, + 9, 10, 11, 11, 10, 11, 11, 11, 10, 11, 11, 11, 10, 10, 10, 10, + 10, 11, 10, 10, 11, 11, 10, 10, 9, 11, 10, 10, 11, 11, 10, 10, + 10, 11, 10, 10, 10, 10, 10, 10, 9, 11, 10, 10, 8, 10, 8, 6, + 5, 6, 6, 7, 7, 8, 8, 8, 9, 10, 11, 10, 10, 11, 11, 12, + 12, 10, 11, 12, 12, 12, 12, 13, 13, 13, 13, 13, 12, 13, 13, 15, + 14, 12, 14, 15, 16, 12, 12, 13, 15, 14, 16, 15, 17, 18, 15, 17, + 16, 15, 15, 15, 15, 13, 13, 10, 14, 12, 13, 17, 17, 18, 10, 17, + 4, + ]; + + public static readonly int[] Second4 = [ + 4, 5, 6, 6, 6, 6, 7, 7, 6, 7, 7, 9, 6, 8, 8, 7, + 7, 8, 8, 8, 6, 9, 8, 8, 7, 9, 8, 9, 8, 9, 8, 9, + 6, 9, 8, 9, 8, 10, 9, 9, 8, 10, 8, 10, 8, 9, 8, 9, + 8, 8, 7, 9, 9, 9, 9, 9, 8, 10, 9, 10, 9, 10, 9, 8, + 7, 8, 9, 9, 8, 9, 9, 9, 7, 10, 9, 10, 9, 9, 8, 9, + 8, 9, 8, 8, 8, 9, 9, 10, 9, 9, 8, 11, 9, 11, 10, 10, + 8, 8, 10, 8, 8, 9, 9, 9, 10, 9, 10, 11, 9, 9, 9, 9, + 8, 9, 8, 8, 8, 10, 10, 9, 9, 8, 10, 11, 10, 11, 11, 9, + 8, 9, 10, 11, 9, 10, 11, 11, 9, 12, 10, 10, 10, 12, 11, 11, + 9, 11, 11, 12, 9, 11, 9, 10, 10, 10, 10, 12, 9, 11, 10, 11, + 9, 11, 11, 11, 10, 11, 11, 12, 9, 10, 10, 12, 11, 11, 10, 11, + 9, 11, 10, 11, 10, 11, 9, 11, 11, 9, 8, 11, 10, 11, 11, 10, + 7, 12, 11, 11, 11, 11, 11, 12, 10, 12, 11, 13, 11, 10, 12, 11, + 10, 11, 10, 11, 10, 11, 11, 11, 10, 12, 11, 11, 10, 11, 10, 10, + 10, 11, 10, 12, 11, 12, 10, 11, 9, 11, 10, 11, 10, 11, 10, 12, + 9, 11, 11, 11, 9, 11, 10, 10, 9, 11, 10, 10, 9, 10, 9, 7, + 4, 5, 5, 5, 6, 6, 7, 6, 8, 7, 8, 9, 9, 7, 8, 8, + 10, 9, 10, 10, 12, 10, 11, 11, 11, 11, 10, 11, 12, 11, 11, 11, + 11, 11, 13, 12, 11, 12, 13, 12, 12, 12, 13, 11, 9, 12, 13, 7, + 13, 11, 13, 11, 10, 11, 13, 15, 15, 12, 14, 15, 15, 15, 6, 15, + 5, + ]; + + public static readonly int[] Offset4 = [ + 3, 6, 5, 4, 2, 3, 3, 3, 4, 4, 6, + ]; + + public static readonly int[] First5 = [ + 7, 9, 9, 9, 9, 9, 9, 9, 9, 8, 9, 9, 9, 7, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 10, 9, 10, 9, 10, 9, 10, 9, 9, + 5, 9, 7, 9, 9, 9, 9, 9, 7, 7, 7, 9, 7, 7, 8, 7, + 8, 8, 7, 7, 9, 9, 9, 9, 7, 7, 7, 9, 9, 9, 9, 9, + 9, 7, 9, 7, 7, 7, 7, 9, 9, 7, 9, 9, 7, 7, 7, 7, + 7, 9, 7, 8, 7, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + 9, 7, 8, 7, 7, 7, 8, 8, 6, 7, 9, 7, 7, 8, 7, 5, + 6, 9, 5, 7, 5, 6, 7, 7, 9, 8, 9, 9, 9, 9, 9, 9, + 9, 9, 10, 9, 10, 10, 10, 9, 9, 10, 10, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 9, 10, 10, 10, + 9, 10, 10, 10, 9, 9, 10, 9, 9, 9, 9, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 9, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 9, 10, 10, 10, 9, 9, 9, 10, 10, 10, 10, 10, + 9, 10, 9, 10, 10, 9, 10, 10, 9, 10, 10, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 10, 10, 10, 10, 9, 10, 9, 10, 9, 10, 10, 9, + 5, 6, 8, 8, 7, 7, 7, 9, 9, 9, 9, 9, 9, 9, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + 9, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 9, 10, 10, 5, 10, 8, 9, 8, + 9, + ]; + + public static readonly int[] Second5 = [ + 8, 10, 11, 11, 11, 12, 11, 11, 12, 6, 11, 12, 10, 5, 12, 12, + 12, 12, 12, 12, 12, 13, 13, 14, 13, 13, 12, 13, 12, 13, 12, 15, + 4, 10, 7, 9, 11, 11, 10, 9, 6, 7, 8, 9, 6, 7, 6, 7, + 8, 7, 7, 8, 8, 8, 8, 8, 8, 9, 8, 7, 10, 9, 10, 10, + 11, 7, 8, 6, 7, 8, 8, 9, 8, 7, 10, 10, 8, 7, 8, 8, + 7, 10, 7, 6, 7, 9, 9, 8, 11, 11, 11, 10, 11, 11, 11, 8, + 11, 6, 7, 6, 6, 6, 6, 8, 7, 6, 10, 9, 6, 7, 6, 6, + 7, 10, 6, 5, 6, 7, 7, 7, 10, 8, 11, 9, 13, 7, 14, 16, + 12, 14, 14, 15, 15, 16, 16, 14, 15, 15, 15, 15, 15, 15, 15, 15, + 14, 15, 13, 14, 14, 16, 15, 17, 14, 17, 15, 17, 12, 14, 13, 16, + 12, 17, 13, 17, 14, 13, 13, 14, 14, 12, 13, 15, 15, 14, 15, 17, + 14, 17, 15, 14, 15, 16, 12, 16, 15, 14, 15, 16, 15, 16, 17, 17, + 15, 15, 17, 17, 13, 14, 15, 15, 13, 12, 16, 16, 17, 14, 15, 16, + 15, 15, 13, 13, 15, 13, 16, 17, 15, 17, 17, 17, 16, 17, 14, 17, + 14, 16, 15, 17, 15, 15, 14, 17, 15, 17, 15, 16, 15, 15, 16, 16, + 14, 17, 17, 15, 15, 16, 15, 17, 15, 14, 16, 16, 16, 16, 16, 12, + 4, 4, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 8, 9, 9, + 9, 9, 9, 10, 10, 10, 11, 10, 11, 11, 11, 11, 11, 12, 12, 12, + 13, 13, 12, 13, 12, 14, 14, 12, 13, 13, 13, 13, 14, 12, 13, 13, + 14, 14, 14, 13, 14, 14, 15, 15, 13, 15, 13, 17, 17, 17, 9, 17, + 7, + ]; + + public static readonly int[] Offset5 = [ + 6, 7, 7, 6, 4, 3, 2, 2, 3, 3, 6, + ]; +} diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItArsenicDecoder.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItArsenicDecoder.cs new file mode 100644 index 000000000..3b245a330 --- /dev/null +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItArsenicDecoder.cs @@ -0,0 +1,225 @@ +using Compression.Core.Checksums; + +namespace FileFormat.StuffIt; + +/// +/// StuffIt method 15 (Arsenic, StuffIt 5's "maximum" method): adaptive arithmetic coding of a +/// BWT + move-to-front + zero-run stream, with optional block randomisation and a final +/// bzip2-style run-length stage. The stream carries a CRC-32 of the output, checked at the end. +/// +/// Converted from XADMaster's XADStuffItArsenicHandle (LGPL-2.1-or-later); the randomisation table is copied verbatim. +internal static class StuffItArsenicDecoder { + private const int NumBits = 26; + private const int One = 1 << (NumBits - 1); + private const int Half = 1 << (NumBits - 2); + + private sealed class Model { + private readonly int _increment, _limit, _first; + private readonly int[] _freq; + public int Total; + + public Model(int first, int last, int increment, int limit) { + this._first = first; + this._increment = increment; + this._limit = limit; + this._freq = new int[last - first + 1]; + this.Reset(); + } + + public void Reset() { + this.Total = this._increment * this._freq.Length; + Array.Fill(this._freq, this._increment); + } + + public int Count => this._freq.Length; + public int Freq(int index) => this._freq[index]; + public int Symbol(int index) => this._first + index; + + public void Increase(int index) { + this._freq[index] += this._increment; + this.Total += this._increment; + if (this.Total <= this._limit) return; + this.Total = 0; + for (var i = 0; i < this._freq.Length; ++i) { + this._freq[i] = (this._freq[i] + 1) >> 1; + this.Total += this._freq[i]; + } + } + } + + private sealed class Decoder { + private readonly StuffItBitReader _reader; + private int _range = One; + private int _code; + + public Decoder(StuffItBitReader reader) { + this._reader = reader; + this._code = reader.ReadBits(NumBits); + } + + public int Next(Model model) { + var frequency = this._code / (this._range / model.Total); + int cumulative = 0, n; + for (n = 0; n < model.Count - 1; ++n) { + if (cumulative + model.Freq(n) > frequency) break; + cumulative += model.Freq(n); + } + this.Narrow(cumulative, model.Freq(n), model.Total); + model.Increase(n); + return model.Symbol(n); + } + + public int NextBits(Model model, int bits) { + var result = 0; + for (var i = 0; i < bits; ++i) + if (this.Next(model) != 0) result |= 1 << i; + return result; + } + + private void Narrow(int low, int size, int total) { + var factor = this._range / total; + var lowIncrement = factor * low; + this._code -= lowIncrement; + if (low + size == total) this._range -= lowIncrement; + else this._range = size * factor; + while (this._range <= Half) { + this._range <<= 1; + this._code = (this._code << 1) | this._reader.ReadBit(); + } + } + } + + public static byte[] Decode(byte[] input, int outputLength) { + var output = new byte[outputLength]; + if (outputLength == 0) return output; + var reader = new StuffItBitReader(input); + var dec = new Decoder(reader); + + var initial = new Model(0, 1, 1, 256); + var selector = new Model(0, 10, 8, 1024); + Model[] mtfModels = [ + new(2, 3, 8, 1024), new(4, 7, 4, 1024), new(8, 15, 4, 1024), new(16, 31, 4, 1024), + new(32, 63, 2, 1024), new(64, 127, 2, 1024), new(128, 255, 1, 1024), + ]; + + if (dec.NextBits(initial, 8) != 'A' || dec.NextBits(initial, 8) != 's') + throw new InvalidDataException("StuffIt Arsenic stream has no 'As' signature."); + var blockBits = dec.NextBits(initial, 4) + 9; + var blockSize = 1 << blockBits; + var block = new byte[blockSize]; + var transform = new int[blockSize]; + var endOfBlocks = dec.Next(initial) != 0; + uint storedCrc = 0; + + var crc = new Crc32(); + Span counts = stackalloc int[256]; + Span cumulative = stackalloc int[256]; + var o = 0; + while (o < outputLength) { + if (endOfBlocks) throw new InvalidDataException("StuffIt Arsenic stream ended before the recorded size."); + + // ── read one block ── + var mtf = new byte[256]; + for (var i = 0; i < 256; ++i) mtf[i] = (byte)i; + var randomized = dec.Next(initial) != 0; + var transformIndex = dec.NextBits(initial, blockBits); + var numBytes = 0; + while (true) { + var sel = dec.Next(selector); + if (sel is 0 or 1) { + int zeroState = 1, zeroCount = 0; + while (sel < 2) { + zeroCount += sel == 0 ? zeroState : 2 * zeroState; + zeroState <<= 1; + if (zeroCount > blockSize) throw new InvalidDataException("StuffIt Arsenic zero run overflows the block."); + sel = dec.Next(selector); + } + if (numBytes + zeroCount > blockSize) throw new InvalidDataException("StuffIt Arsenic block overflows."); + block.AsSpan(numBytes, zeroCount).Fill(mtf[0]); + numBytes += zeroCount; + } + if (sel == 10) break; + var symbol = sel == 2 ? 1 : dec.Next(mtfModels[sel - 3]); + if (numBytes >= blockSize) throw new InvalidDataException("StuffIt Arsenic block overflows."); + block[numBytes++] = MoveToFront(mtf, symbol); + } + if (transformIndex >= numBytes) throw new InvalidDataException("StuffIt Arsenic BWT index is out of range."); + + selector.Reset(); + foreach (var m in mtfModels) m.Reset(); + if (dec.Next(initial) != 0) { + storedCrc = (uint)dec.NextBits(initial, 32); + endOfBlocks = true; + } + + // Inverse BWT vector. + counts.Clear(); + for (var i = 0; i < numBytes; ++i) ++counts[block[i]]; + var total = 0; + for (var i = 0; i < 256; ++i) { cumulative[i] = total; total += counts[i]; counts[i] = 0; } + for (var i = 0; i < numBytes; ++i) transform[cumulative[block[i]] + counts[block[i]]++] = i; + + // ── emit the block through de-randomisation and the final RLE ── + int count = 0, last = 0, randIndex = 0, randCount = RandomizationTable[0]; + for (var byteCount = 0; byteCount < numBytes;) { + transformIndex = transform[transformIndex]; + int b = block[transformIndex]; + if (randomized && randCount == byteCount) { + b ^= 1; + randIndex = (randIndex + 1) & 255; + randCount += RandomizationTable[randIndex]; + } + ++byteCount; + + if (count == 4) { + count = 0; + for (var k = 0; k < b; ++k) Emit(output, ref o, (byte)last, crc); + continue; + } + if (b == last) ++count; + else { count = 1; last = b; } + Emit(output, ref o, (byte)b, crc); + } + } + + // The CRC-32 arrives with the last block; a stream that still has blocks after the recorded + // size cannot be verified and is refused rather than trusted. + if (!endOfBlocks) + throw new InvalidDataException("StuffIt Arsenic stream continues past the recorded size."); + if (crc.Value != storedCrc) + throw new InvalidDataException("StuffIt Arsenic CRC-32 mismatch."); + return output; + } + + private static void Emit(byte[] output, ref int o, byte b, Crc32 crc) { + if (o >= output.Length) throw new InvalidDataException("StuffIt Arsenic stream is longer than the recorded size."); + output[o++] = b; + crc.Update(b); + } + + private static byte MoveToFront(byte[] table, int symbol) { + var value = table[symbol]; + Array.Copy(table, 0, table, 1, symbol); + table[0] = value; + return value; + } + + private static readonly ushort[] RandomizationTable = [ + 0xee, 0x56, 0xf8, 0xc3, 0x9d, 0x9f, 0xae, 0x2c, 0xad, 0xcd, 0x24, 0x9d, 0xa6, 0x101, 0x18, 0xb9, + 0xa1, 0x82, 0x75, 0xe9, 0x9f, 0x55, 0x66, 0x6a, 0x86, 0x71, 0xdc, 0x84, 0x56, 0x96, 0x56, 0xa1, + 0x84, 0x78, 0xb7, 0x32, 0x6a, 0x3, 0xe3, 0x2, 0x11, 0x101, 0x8, 0x44, 0x83, 0x100, 0x43, 0xe3, + 0x1c, 0xf0, 0x86, 0x6a, 0x6b, 0xf, 0x3, 0x2d, 0x86, 0x17, 0x7b, 0x10, 0xf6, 0x80, 0x78, 0x7a, + 0xa1, 0xe1, 0xef, 0x8c, 0xf6, 0x87, 0x4b, 0xa7, 0xe2, 0x77, 0xfa, 0xb8, 0x81, 0xee, 0x77, 0xc0, + 0x9d, 0x29, 0x20, 0x27, 0x71, 0x12, 0xe0, 0x6b, 0xd1, 0x7c, 0xa, 0x89, 0x7d, 0x87, 0xc4, 0x101, + 0xc1, 0x31, 0xaf, 0x38, 0x3, 0x68, 0x1b, 0x76, 0x79, 0x3f, 0xdb, 0xc7, 0x1b, 0x36, 0x7b, 0xe2, + 0x63, 0x81, 0xee, 0xc, 0x63, 0x8b, 0x78, 0x38, 0x97, 0x9b, 0xd7, 0x8f, 0xdd, 0xf2, 0xa3, 0x77, + 0x8c, 0xc3, 0x39, 0x20, 0xb3, 0x12, 0x11, 0xe, 0x17, 0x42, 0x80, 0x2c, 0xc4, 0x92, 0x59, 0xc8, + 0xdb, 0x40, 0x76, 0x64, 0xb4, 0x55, 0x1a, 0x9e, 0xfe, 0x5f, 0x6, 0x3c, 0x41, 0xef, 0xd4, 0xaa, + 0x98, 0x29, 0xcd, 0x1f, 0x2, 0xa8, 0x87, 0xd2, 0xa0, 0x93, 0x98, 0xef, 0xc, 0x43, 0xed, 0x9d, + 0xc2, 0xeb, 0x81, 0xe9, 0x64, 0x23, 0x68, 0x1e, 0x25, 0x57, 0xde, 0x9a, 0xcf, 0x7f, 0xe5, 0xba, + 0x41, 0xea, 0xea, 0x36, 0x1a, 0x28, 0x79, 0x20, 0x5e, 0x18, 0x4e, 0x7c, 0x8e, 0x58, 0x7a, 0xef, + 0x91, 0x2, 0x93, 0xbb, 0x56, 0xa1, 0x49, 0x1b, 0x79, 0x92, 0xf3, 0x58, 0x4f, 0x52, 0x9c, 0x2, + 0x77, 0xaf, 0x2a, 0x8f, 0x49, 0xd0, 0x99, 0x4d, 0x98, 0x101, 0x60, 0x93, 0x100, 0x75, 0x31, 0xce, + 0x49, 0x20, 0x56, 0x57, 0xe2, 0xf5, 0x26, 0x2b, 0x8a, 0xbf, 0xde, 0xd0, 0x83, 0x34, 0xf4, 0x17, + ]; +} diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItBitReader.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItBitReader.cs new file mode 100644 index 000000000..a9ab0bff7 --- /dev/null +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItBitReader.cs @@ -0,0 +1,151 @@ +namespace FileFormat.StuffIt; + +/// +/// Bit reader over a compressed fork. StuffIt's coders read in two orders: most-significant bit +/// of each byte first (Huffman, LZAH, Arsenic) and least-significant bit first (method 13, the +/// LZW of method 2). Reading past the end yields zero bits up to a small slack, then throws, so a +/// truncated fork cannot loop forever or decode into garbage silently. +/// +internal sealed class StuffItBitReader(byte[] data, int offset = 0, int length = -1) { + private const int SlackBytes = 8; + + private readonly byte[] _data = data; + private readonly int _end = length < 0 ? data.Length : offset + length; + private long _bitPos = (long)offset * 8; + + private int ByteAt(long index) { + if (index < this._end) return this._data[index]; + if (index < this._end + SlackBytes) return 0; + throw new InvalidDataException("StuffIt compressed fork ended early."); + } + + /// Next bit, most-significant bit of each byte first. + public int ReadBit() { + var bit = (ByteAt(this._bitPos >> 3) >> (7 - (int)(this._bitPos & 7))) & 1; + ++this._bitPos; + return bit; + } + + /// Next bits MSB-first, first bit read becomes the value's top bit. + public int ReadBits(int count) { + var v = 0; + for (var i = 0; i < count; ++i) v = (v << 1) | this.ReadBit(); + return v; + } + + /// Next bit, least-significant bit of each byte first. + public int ReadBitLe() { + var bit = (ByteAt(this._bitPos >> 3) >> (int)(this._bitPos & 7)) & 1; + ++this._bitPos; + return bit; + } + + /// Next bits LSB-first, first bit read becomes the value's bit 0. + public int ReadBitsLe(int count) { + var v = 0; + for (var i = 0; i < count; ++i) v |= this.ReadBitLe() << i; + return v; + } + + /// Skips bits. + public void Skip(long count) => this._bitPos += count; + + /// Next whole byte (the reader must be byte-aligned). + public int ReadByte() { + var b = ByteAt(this._bitPos >> 3); + this._bitPos += 8; + return b; + } + + /// Whether every input byte has been consumed. + public bool AtEnd => (this._bitPos >> 3) >= this._end; +} + +/// +/// A binary prefix-code tree. Codes are added most-significant bit first: the first bit read +/// from the stream picks the branch at the root, in whichever bit order the stream uses. +/// +internal sealed class StuffItPrefixCode { + // Node layout: two child slots per node. A child value >= 0 is a node index, < 0 encodes leaf ~symbol. + private readonly List _children = [Open, Open]; + private const int Open = int.MinValue; + + /// Canonical code from code lengths: shorter codes first, ties by symbol, all-zero code first. + public static StuffItPrefixCode FromLengths(ReadOnlySpan lengths, int maxLength = 32) { + var code = new StuffItPrefixCode(); + uint next = 0; + var remaining = 0; + foreach (var l in lengths) if (l > 0) ++remaining; + for (var length = 1; length <= maxLength && remaining > 0; ++length) { + for (var symbol = 0; symbol < lengths.Length; ++symbol) { + if (lengths[symbol] != length) continue; + code.Add(symbol, next, length); + ++next; + --remaining; + } + next <<= 1; + } + return code; + } + + /// Adds a code given with its first-read bit as the most significant of bits. + public void Add(int symbol, uint codeBits, int length) { + var node = 0; + for (var i = length - 1; i >= 0; --i) { + var bit = (int)((codeBits >> i) & 1); + var slot = node * 2 + bit; + var child = this._children[slot]; + if (i == 0) { + if (child != Open) throw new InvalidDataException("StuffIt prefix code is not prefix-free."); + this._children[slot] = ~symbol; + return; + } + if (child == Open) { + child = this._children.Count / 2; + this._children.Add(Open); + this._children.Add(Open); + this._children[slot] = child; + } else if (child < 0) { + throw new InvalidDataException("StuffIt prefix code is not prefix-free."); + } + node = child; + } + } + + /// Adds a code given least-significant bit first (the first-read bit is bit 0). + public void AddLowBitFirst(int symbol, uint codeBits, int length) { + uint reversed = 0; + for (var i = 0; i < length; ++i) reversed = (reversed << 1) | ((codeBits >> i) & 1); + this.Add(symbol, reversed, length); + } + + /// Starts a tree built by explicit branching (method 3's serialised tree). + internal int NewNode() { + var index = this._children.Count / 2; + this._children.Add(Open); + this._children.Add(Open); + return index; + } + + internal void SetChild(int node, int bit, int child) => this._children[node * 2 + bit] = child; + + internal static int Leaf(int symbol) => ~symbol; + + /// Decodes one symbol reading bits MSB-first. + public int Decode(StuffItBitReader reader) => this.Walk(reader, lsbFirst: false); + + /// Decodes one symbol reading bits LSB-first. + public int DecodeLe(StuffItBitReader reader) => this.Walk(reader, lsbFirst: true); + + private int Walk(StuffItBitReader reader, bool lsbFirst) { + var node = 0; + for (var depth = 0; depth < 64; ++depth) { + var bit = lsbFirst ? reader.ReadBitLe() : reader.ReadBit(); + var child = this._children[node * 2 + bit]; + if (child == Open) throw new InvalidDataException("Invalid StuffIt prefix code in bitstream."); + if (child < 0) return ~child; + node = child; + } + throw new InvalidDataException("Invalid StuffIt prefix code in bitstream."); + } +} diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItCodecs.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItCodecs.cs new file mode 100644 index 000000000..dd0801de0 --- /dev/null +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItCodecs.cs @@ -0,0 +1,108 @@ +namespace FileFormat.StuffIt; + +/// +/// StuffIt method 1: RLE90, the BinHex run-length scheme. 0x90 0x00 is a literal 0x90; +/// 0x90 n (n ≥ 2) repeats the previous byte until the run is n long including that byte; +/// 0x90 0x01 is invalid. +/// +internal static class StuffItRle90 { + public static byte[] Decode(ReadOnlySpan input, int outputLength) { + var output = new byte[outputLength]; + var o = 0; + byte last = 0; + for (var i = 0; i < input.Length && o < outputLength; ++i) { + var b = input[i]; + if (b != 0x90) { + output[o++] = last = b; + continue; + } + if (++i >= input.Length) throw new InvalidDataException("StuffIt RLE90 data ends after a marker."); + var n = input[i]; + if (n == 0) { + output[o++] = last = 0x90; + continue; + } + if (n == 1) throw new InvalidDataException("StuffIt RLE90 run of length 1 is invalid."); + for (var k = 1; k < n && o < outputLength; ++k) output[o++] = last; + } + if (o != outputLength) throw new InvalidDataException("StuffIt RLE90 data is shorter than the recorded size."); + return output; + } + + /// Encodes so that (and every RLE90 reader) restores the input. + public static byte[] Encode(ReadOnlySpan data) { + var output = new List(data.Length + 16); + var i = 0; + while (i < data.Length) { + var b = data[i]; + var run = 1; + while (i + run < data.Length && data[i + run] == b && run < 255) ++run; + output.Add(b); + if (b == 0x90) output.Add(0x00); + if (run >= 3) { + output.Add(0x90); + output.Add((byte)run); + } else { + for (var k = 1; k < run; ++k) { + output.Add(b); + if (b == 0x90) output.Add(0x00); + } + } + i += run; + } + return [.. output]; + } +} + +/// +/// StuffIt method 3: a static Huffman code whose tree is serialised at the start of the fork +/// (bit 1 = leaf followed by an 8-bit value, bit 0 = branch: zero subtree then one subtree), +/// all read most-significant bit first. +/// +internal static class StuffItHuffman { + public static byte[] Decode(byte[] input, int outputLength) { + var reader = new StuffItBitReader(input); + var code = new StuffItPrefixCode(); + int? onlySymbol = null; + if (reader.ReadBit() == 1) onlySymbol = reader.ReadBits(8); + else ParseBranch(reader, code, 0, 0); + + var output = new byte[outputLength]; + for (var i = 0; i < outputLength; ++i) + output[i] = (byte)(onlySymbol ?? code.Decode(reader)); + return output; + } + + private static void ParseBranch(StuffItBitReader reader, StuffItPrefixCode code, int node, int depth) { + if (depth > 256) throw new InvalidDataException("StuffIt Huffman tree is too deep."); + for (var bit = 0; bit < 2; ++bit) { + if (reader.ReadBit() == 1) { + code.SetChild(node, bit, StuffItPrefixCode.Leaf(reader.ReadBits(8))); + } else { + var child = code.NewNode(); + code.SetChild(node, bit, child); + ParseBranch(reader, code, child, depth + 1); + } + } + } +} + +/// +/// StuffIt method 2: Unix compress LZW (block mode, up to 14-bit codes) without the 3-byte .Z +/// header. Decoded by the .Z decoder with that header put back. +/// +internal static class StuffItLzw { + public static byte[] Decode(byte[] input, int outputLength) { + var withHeader = new byte[input.Length + 3]; + withHeader[0] = 0x1F; + withHeader[1] = 0x9D; + withHeader[2] = 0x8E; // block mode, 14 bits + input.CopyTo(withHeader, 3); + using var ms = new MemoryStream(withHeader, writable: false); + using var z = new FileFormat.Compress.CompressStream(ms, Compression.Core.Streams.CompressionStreamMode.Decompress); + var output = new byte[outputLength]; + var got = z.ReadAtLeast(output, outputLength, throwOnEndOfStream: false); + if (got != outputLength) throw new InvalidDataException("StuffIt LZW data is shorter than the recorded size."); + return output; + } +} diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItConstants.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItConstants.cs index c2e62d674..983125a2c 100644 --- a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItConstants.cs +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItConstants.cs @@ -12,28 +12,12 @@ internal static class StuffItConstants { // Entry header layout — 112 bytes total. public const int EntryHeaderSize = 112; public const int FileNameOffset = 3; // offset of name bytes within header - public const int FileNameMaxLength = 63; + public const int FileNameMaxLength = 31; // bytes 34-35 follow the name field - // Compression method codes. + // Compression method codes written by StuffItWriter (the reader names all of them, see StuffItReader.MethodName). public const byte MethodStore = 0; public const byte MethodRle = 1; - public const byte MethodLzc = 2; // 14-bit LZW — not supported - public const byte MethodHuffman = 3; // not supported - public const byte MethodLzah = 5; // LZ + adaptive Huffman — not supported - public const byte MethodFixedHuffman = 6; // MW — not supported - public const byte MethodMwImproved = 8; // not supported - public const byte MethodLzc12 = 13; // 12-bit LZW — not supported - public const byte MethodLzc12Rle = 14; // 12-bit LZW + RLE — not supported - public const byte MethodEncrypted = 15; // DES — not supported - - // RLE escape byte used by method 1. - public const byte RleMarker = 0x90; // Mac epoch: January 1, 1904 00:00:00 UTC. public static readonly DateTime MacEpoch = new(1904, 1, 1, 0, 0, 0, DateTimeKind.Utc); - - // CRC-16/CCITT polynomial (normal, non-reflected) — used for StuffIt entry checksums. - // The reflected form of 0x1021 is 0x8408, used by ARC. - // StuffIt uses the forward (non-reflected) variant with init=0. - public const ushort Crc16Polynomial = 0x1021; } diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItEntry.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItEntry.cs index b64f8428e..0883e0b1b 100644 --- a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItEntry.cs +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItEntry.cs @@ -1,10 +1,10 @@ namespace FileFormat.StuffIt; /// -/// Represents a single file entry in a StuffIt (SIT) archive. +/// Represents a single file or folder entry in a StuffIt archive (classic "SIT!" or StuffIt 5). /// public sealed class StuffItEntry { - /// Gets or sets the file name as stored in the archive (up to 63 characters). + /// Gets or sets the entry's path inside the archive, folders separated by '/'. public string FileName { get; set; } = string.Empty; /// Gets or sets the uncompressed size of the data fork in bytes. @@ -19,10 +19,10 @@ public sealed class StuffItEntry { /// Gets or sets the compressed size of the resource fork in bytes. public long CompressedResourceSize { get; set; } - /// Gets or sets the compression method code for the data fork. + /// Gets or sets the compression method code for the data fork (encryption flag removed). public int DataMethod { get; set; } - /// Gets or sets the compression method code for the resource fork. + /// Gets or sets the compression method code for the resource fork (encryption flag removed). public int ResourceMethod { get; set; } /// Gets or sets the Mac four-character file type code (e.g. "TEXT"). @@ -34,8 +34,14 @@ public sealed class StuffItEntry { /// Gets or sets the last modification date/time of the file. public DateTime LastModified { get; set; } - /// StuffIt classic archives do not support directory entries. - public bool IsDirectory => false; + /// Gets or sets whether this entry is a folder. + public bool IsDirectory { get; set; } + + /// Gets or sets whether the forks are encrypted (classic DES or StuffIt 5 RC4). + public bool IsEncrypted { get; set; } + + /// Gets whether the entry carries a resource fork. + public bool HasResourceFork => this.ResourceForkSize > 0 || this.CompressedResourceSize > 0; // Internal: byte offset of the resource fork compressed data within the archive stream. internal long ResourceDataOffset { get; set; } @@ -43,7 +49,7 @@ public sealed class StuffItEntry { // Internal: byte offset of the data fork compressed data within the archive stream. internal long DataForkOffset { get; set; } - // Stored CRC-16 values for integrity checking. - internal ushort DataForkCrc16 { get; set; } + // Stored CRC-16/ARC values for integrity checking (not used with method 15, which carries a CRC-32). + internal ushort DataForkCrc16 { get; set; } internal ushort ResourceForkCrc16 { get; set; } } diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItFormatDescriptor.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItFormatDescriptor.cs index b4d5746e2..a88cfd5b9 100644 --- a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItFormatDescriptor.cs +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItFormatDescriptor.cs @@ -71,7 +71,7 @@ public IEnumerable EnumerateLayout(Stream archive) { public FormatCapabilities Capabilities => FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanCreate | FormatCapabilities.CanModify | - FormatCapabilities.CanTest | FormatCapabilities.SupportsMultipleEntries; + FormatCapabilities.CanTest | FormatCapabilities.SupportsMultipleEntries | FormatCapabilities.SupportsDirectories; /// /// Gets the default extension. /// @@ -87,7 +87,10 @@ public IEnumerable EnumerateLayout(Stream archive) { /// /// Gets the magic signatures. /// - public IReadOnlyList MagicSignatures => [new([(byte)'S', (byte)'I', (byte)'T', (byte)'!'], Confidence: 0.95)]; + public IReadOnlyList MagicSignatures => [ + new([(byte)'S', (byte)'I', (byte)'T', (byte)'!'], Confidence: 0.95), + new("StuffIt (c)1997-"u8.ToArray(), Confidence: 0.95), + ]; /// /// Gets the methods. /// @@ -103,25 +106,40 @@ public IEnumerable EnumerateLayout(Stream archive) { /// /// Gets the description. /// - public string Description => "Macintosh StuffIt archive, classic Mac compression"; + public string Description => "Macintosh StuffIt archive (classic SIT! and StuffIt 5); reads methods None/RLE/Compress/Huffman/LZAH/LZ+Huffman/Arsenic, writes None/RLE"; + + /// Suffix under which a file's resource fork is listed and extracted. + public const string ResourceForkSuffix = ".rsrc"; + + private static IEnumerable<(StuffItEntry Entry, bool Resource, string Name)> Items(StuffItReader r) { + foreach (var e in r.Entries) { + if (e.IsDirectory) { yield return (e, false, e.FileName); continue; } + if (e.DataForkSize > 0 || !e.HasResourceFork) yield return (e, false, e.FileName); + if (e.HasResourceFork) yield return (e, true, e.FileName + ResourceForkSuffix); + } + } /// /// Lists the entries in the supplied container. /// public List List(Stream stream, string? password) { - var r = new StuffItReader(stream); - return r.Entries.Select((e, i) => new ArchiveEntryInfo(i, e.FileName, e.DataForkSize, - e.CompressedDataSize, $"Method {e.DataMethod}", false, false, e.LastModified)).ToList(); + var r = new StuffItReader(stream, leaveOpen: true); + return Items(r).Select((x, i) => new ArchiveEntryInfo(i, x.Name, + x.Entry.IsDirectory ? 0 : x.Resource ? x.Entry.ResourceForkSize : x.Entry.DataForkSize, + x.Entry.IsDirectory ? 0 : x.Resource ? x.Entry.CompressedResourceSize : x.Entry.CompressedDataSize, + x.Entry.IsDirectory ? "" : StuffItReader.MethodName(x.Resource ? x.Entry.ResourceMethod : x.Entry.DataMethod), + x.Entry.IsDirectory, x.Entry.IsEncrypted, x.Entry.LastModified)).ToList(); } /// /// Decodes the supplied input. /// public void Extract(Stream stream, string outputDir, string? password, string[]? files) { - var r = new StuffItReader(stream); - foreach (var e in r.Entries) { - if (files != null && !MatchesFilter(e.FileName, files)) continue; - WriteFile(outputDir, e.FileName, r.Extract(e)); + var r = new StuffItReader(stream, leaveOpen: true); + foreach (var (e, resource, name) in Items(r)) { + if (files != null && !MatchesFilter(name, files)) continue; + if (e.IsDirectory) continue; // folders come into being with the files inside them + WriteFile(outputDir, name, resource ? r.ExtractResourceFork(e) : r.Extract(e)); } } @@ -136,9 +154,9 @@ public Stream OpenEntry(Stream archive, string entryName, string? password) { ArgumentNullException.ThrowIfNull(entryName); if (archive.CanSeek) archive.Position = 0; var r = new StuffItReader(archive, leaveOpen: true); - foreach (var e in r.Entries) { - if (!string.Equals(e.FileName, entryName, StringComparison.OrdinalIgnoreCase)) continue; - var bytes = r.Extract(e); + foreach (var (e, resource, name) in Items(r)) { + if (e.IsDirectory || !string.Equals(name, entryName, StringComparison.OrdinalIgnoreCase)) continue; + var bytes = resource ? r.ExtractResourceFork(e) : r.Extract(e); return new BoundedEntryStream(new MemoryStream(bytes, writable: false), bytes.Length, leaveOpen: false); } diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItLzahDecoder.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItLzahDecoder.cs new file mode 100644 index 000000000..26635ffa2 --- /dev/null +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItLzahDecoder.cs @@ -0,0 +1,165 @@ +namespace FileFormat.StuffIt; + +/// +/// StuffIt method 5 (LZAH): LZSS over a 4 KiB window with an adaptive Huffman code for the 314 +/// literal/length symbols (the LZHUF scheme of LHarc's -lh1-) and a fixed 64-entry code for the +/// top six distance bits, all read most-significant bit first. +/// +/// Converted from XADMaster's XADLZHDynamicHandle (LGPL-2.1-or-later), including its window pre-fill. +internal static class StuffItLzahDecoder { + private const int NumLeaves = 314; + private const int NumNodes = NumLeaves * 2 - 1; + private const int WindowSize = 4096; + + private static readonly int[] DistanceLengths = [ + 3, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, + ]; + + private sealed class Node { + public Node? Parent, Left, Right; + public int Index, Freq, Value; + public bool IsLeaf => this.Left is null && this.Right is null; + } + + public static byte[] Decode(byte[] input, int outputLength) { + var output = new byte[outputLength]; + if (outputLength == 0) return output; + var reader = new StuffItBitReader(input); + var distanceCode = StuffItPrefixCode.FromLengths(DistanceLengths, 8); + + // Node storage: storage[0] is always the root; nodes[] is the frequency-ordered list. + var storage = new Node[NumNodes]; + for (var i = 0; i < NumNodes; ++i) storage[i] = new Node(); + var nodes = (Node[])storage.Clone(); + for (var i = 0; i < NumLeaves; ++i) { + var index = NumNodes - 1 - i; + nodes[index].Index = index; + nodes[index].Freq = 1; + nodes[index].Value = i; + } + for (var i = NumLeaves - 2; i >= 0; --i) { + var n = nodes[i]; + n.Index = i; + n.Left = nodes[2 * i + 1]; + n.Right = nodes[2 * i + 2]; + n.Left.Parent = n; + n.Right.Parent = n; + n.Freq = n.Left.Freq + n.Right.Freq; + } + + var window = new byte[WindowSize]; + for (var i = 0; i < 256; ++i) window.AsSpan(i * 13 + 18, 13).Fill((byte)i); + for (var i = 0; i < 256; ++i) window[256 * 13 + 18 + i] = (byte)i; + for (var i = 0; i < 256; ++i) window[256 * 13 + 256 + 18 + i] = (byte)(255 - i); + window.AsSpan(256 * 13 + 512 + 18, 128).Clear(); + window.AsSpan(256 * 13 + 512 + 128 + 18, 128 - 18).Fill((byte)' '); + + var o = 0; + while (o < outputLength) { + var node = storage[0]; + while (!node.IsLeaf) { + node = (reader.ReadBit() != 0 ? node.Left : node.Right) + ?? throw new InvalidDataException("StuffIt LZAH tree is damaged."); + } + Update(storage, nodes, node); + + var symbol = node.Value; + if (symbol < 0x100) { + window[o & (WindowSize - 1)] = (byte)symbol; + output[o++] = (byte)symbol; + continue; + } + var length = symbol - 0x100 + 3; + var high = distanceCode.Decode(reader); + var distance = (high << 6) + reader.ReadBits(6) + 1; + var src = o - distance; + for (var k = 0; k < length && o < outputLength; ++k, ++src) { + var b = window[src & (WindowSize - 1)]; + window[o & (WindowSize - 1)] = b; + output[o++] = b; + } + } + return output; + } + + private static void Update(Node[] storage, Node[] nodes, Node node) { + if (storage[0].Freq == 0x8000) Reconstruct(storage, nodes); + for (Node? n = node; n is not null; n = n.Parent) { + ++n.Freq; + if (n.Parent is null) break; + Rearrange(nodes, n); + } + } + + private static void Rearrange(Node[] nodes, Node p) { + var pIndex = p.Index; + var qIndex = pIndex; + while (qIndex > 0 && nodes[qIndex - 1].Freq < p.Freq) --qIndex; + if (qIndex >= pIndex) return; + + var q = nodes[qIndex]; + var newQParent = p.Parent!; + var newPParent = q.Parent!; + var pIsRight = p.Parent!.Right == p; + var qIsRight = q.Parent!.Right == q; + + if (pIsRight) p.Parent.Right = q; else p.Parent.Left = q; + if (qIsRight) q.Parent.Right = p; else q.Parent.Left = p; + + p.Parent = newPParent; + q.Parent = newQParent; + + nodes[pIndex] = q; + q.Index = pIndex; + nodes[qIndex] = p; + p.Index = qIndex; + } + + private static void Reconstruct(Node[] storage, Node[] nodes) { + var leaves = new Node[NumLeaves]; + var n = 0; + for (var i = 0; i < NumNodes; ++i) { + if (!nodes[i].IsLeaf) continue; + var leaf = nodes[i]; + leaf.Freq = (leaf.Freq + 1) / 2; + leaves[n++] = leaf; + } + + var leafIndex = NumLeaves - 1; + var branchIndex = NumLeaves - 2; + var nodeIndex = NumNodes - 1; + var pairIndex = NumNodes - 2; + + while (nodeIndex >= 0) { + while (nodeIndex >= pairIndex) { + nodes[nodeIndex] = leaves[leafIndex]; + nodes[nodeIndex].Index = nodeIndex; + --nodeIndex; + --leafIndex; + } + + var branch = storage[branchIndex--]; + branch.Left = nodes[pairIndex]; + branch.Right = nodes[pairIndex + 1]; + branch.Left.Parent = branch; + branch.Right.Parent = branch; + branch.Freq = branch.Left.Freq + branch.Right.Freq; + + while (leafIndex >= 0 && leaves[leafIndex].Freq <= branch.Freq) { + nodes[nodeIndex] = leaves[leafIndex]; + nodes[nodeIndex].Index = nodeIndex; + --nodeIndex; + --leafIndex; + } + + nodes[nodeIndex] = branch; + nodes[nodeIndex].Index = nodeIndex; + --nodeIndex; + pairIndex -= 2; + } + nodes[0].Parent = null; + } +} diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItReader.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItReader.cs index 76a77b834..64b2d8259 100644 --- a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItReader.cs +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItReader.cs @@ -1,246 +1,340 @@ using System.Buffers.Binary; using System.Text; +using Compression.Core.Checksums; namespace FileFormat.StuffIt; /// -/// Reads entries from a StuffIt (SIT) classic archive. +/// Reads StuffIt archives: the classic "SIT!" format of StuffIt 1.5-4.x (with folders) and the +/// StuffIt 5 format ("StuffIt (c)1997-..." banner) written by StuffIt 5.x-7.x and StuffIt +/// Deluxe/Standard for Windows. /// /// -/// Supports the classic SIT format (magic "SIT!", version 1) with Store (method 0) -/// and RLE (method 1) compression for the data fork. Resource forks are available -/// via . +/// Forks are decoded with methods 0 (store), 1 (RLE90), 2 (compress LZW), 3 (Huffman), 5 (LZAH), +/// 13 (LZ+Huffman) and 15 (Arsenic). Method 8 (MW), 14 (installer) and encrypted forks are +/// listed but refused on extraction. Layouts follow XADMaster's XADStuffItParser and +/// XADStuffIt5Parser (LGPL-2.1-or-later). /// public sealed class StuffItReader : IDisposable { + private const int ClassicHeaderSize = 22; + private const int ClassicEntrySize = 112; + private const byte FlagEncrypted = 0x80; + private const byte FolderStart = 0x20; + private const byte FolderEnd = 0x21; + private const byte FolderContainsEncrypted = 0x10; + private const uint Sit5EntryId = 0xA5A5A5A5; + private const byte Sit5FlagDirectory = 0x40; + private const byte Sit5FlagCrypted = 0x20; + + private static ReadOnlySpan Sit5Banner => "StuffIt (c)1997-"u8; + + private const string MacRomanHigh = + "ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûü†°¢£§•¶ß®©™´¨≠ÆØ∞±≤≥¥µ∂∑∏π∫ªºΩæø¿¡¬√ƒ≈∆«»… ÀÃÕŒœ–—“”‘’÷◊ÿŸ⁄€‹›fifl‡·‚„‰ÂÊÁËÈÍÎÏÌÓÔÒÚÛÙıˆ˜¯˘˙˚¸˝˛ˇ"; + private readonly Stream _stream; private readonly bool _leaveOpen; private readonly List _entries = []; + private readonly long _base; private bool _disposed; /// /// Opens a StuffIt archive from the given stream and parses the entry directory. /// - /// A seekable stream containing a SIT archive. - /// - /// to leave open after this reader is disposed. - /// - /// Thrown when is null. - /// Thrown when the stream does not contain a valid SIT archive. + /// A seekable stream positioned at a classic or StuffIt 5 archive. + /// to leave open after disposal. + /// The stream is not a StuffIt archive or its directory is damaged. public StuffItReader(Stream stream, bool leaveOpen = false) { - this._stream = stream ?? throw new ArgumentNullException(nameof(stream)); + this._stream = stream ?? throw new ArgumentNullException(nameof(stream)); this._leaveOpen = leaveOpen; - ParseArchive(); + this._base = stream.CanSeek ? stream.Position : 0; + Span head = stackalloc byte[100]; + var got = stream.ReadAtLeast(head, head.Length, throwOnEndOfStream: false); + stream.Position = this._base; + if (got >= 14 && BinaryPrimitives.ReadUInt32BigEndian(head[10..]) == 0x724C6175 && IsClassicSignature(head)) { + this.IsStuffIt5 = false; + this.ParseClassic(); + } else if (got >= 100 && head.StartsWith(Sit5Banner)) { + this.IsStuffIt5 = true; + this.ParseSit5(); + } else { + throw new InvalidDataException("Not a StuffIt archive (no 'SIT!'/'rLau' header and no StuffIt 5 banner)."); + } } - /// Gets the list of entries found in the archive. + /// Gets whether the archive uses the StuffIt 5 layout. + public bool IsStuffIt5 { get; } + + /// Gets the entries (folders first in archive order, then their contents). public IReadOnlyList Entries => this._entries; - /// - /// Extracts and decompresses the data fork of the specified entry. - /// - /// An entry obtained from . - /// The decompressed data fork bytes. - /// Thrown when is null. - /// - /// Thrown when the data fork compression method is not supported. - /// - /// Thrown when the CRC-16 of the decompressed data does not match. + private static bool IsClassicSignature(ReadOnlySpan h) + => BinaryPrimitives.ReadUInt32BigEndian(h) == 0x53495421 // "SIT!" + || h[0] == 'S' && h[1] == 'T' && (h[2] == 'i' && (h[3] == 'n' || h[3] is >= (byte)'0' and <= (byte)'9') + || h[2] is >= (byte)'0' and <= (byte)'9' && h[3] is >= (byte)'0' and <= (byte)'9'); + + /// Extracts and verifies the data fork of . + /// The fork is encrypted or uses an unsupported method. + /// The fork is damaged or fails its checksum. public byte[] Extract(StuffItEntry entry) { ArgumentNullException.ThrowIfNull(entry); ObjectDisposedException.ThrowIf(this._disposed, this); - - if (entry.CompressedDataSize == 0 && entry.DataForkSize == 0) - return []; - - this._stream.Position = entry.DataForkOffset; - var compressed = ReadExact((int)entry.CompressedDataSize); - var decompressed = Decompress(entry.DataMethod, compressed, (int)entry.DataForkSize); - - VerifyCrc16(decompressed, entry.DataForkCrc16, entry.FileName, "data fork"); - return decompressed; + if (entry.IsDirectory) return []; + return this.ReadFork(entry, entry.DataForkOffset, entry.CompressedDataSize, entry.DataForkSize, + entry.DataMethod, entry.DataForkCrc16, "data fork"); } - /// - /// Extracts and decompresses the resource fork of the specified entry. - /// - /// An entry obtained from . - /// The decompressed resource fork bytes. - /// Thrown when is null. - /// - /// Thrown when the resource fork compression method is not supported. - /// - /// Thrown when the CRC-16 does not match. + /// Extracts and verifies the resource fork of . public byte[] ExtractResourceFork(StuffItEntry entry) { ArgumentNullException.ThrowIfNull(entry); ObjectDisposedException.ThrowIf(this._disposed, this); + if (entry.IsDirectory) return []; + return this.ReadFork(entry, entry.ResourceDataOffset, entry.CompressedResourceSize, entry.ResourceForkSize, + entry.ResourceMethod, entry.ResourceForkCrc16, "resource fork"); + } - if (entry.CompressedResourceSize == 0 && entry.ResourceForkSize == 0) - return []; - - this._stream.Position = entry.ResourceDataOffset; - var compressed = ReadExact((int)entry.CompressedResourceSize); - var decompressed = Decompress(entry.ResourceMethod, compressed, (int)entry.ResourceForkSize); + private byte[] ReadFork(StuffItEntry entry, long offset, long compressedSize, long size, int method, ushort crc, string fork) { + if (size == 0 && compressedSize == 0) return []; + if (entry.IsEncrypted) + throw new NotSupportedException($"'{entry.FileName}' ({fork}) is encrypted; StuffIt decryption is not supported."); + if (size > int.MaxValue || compressedSize > int.MaxValue) + throw new NotSupportedException($"'{entry.FileName}' ({fork}) exceeds the 2 GiB in-memory limit."); + if (offset < 0 || offset + compressedSize > this._stream.Length) + throw new InvalidDataException($"'{entry.FileName}' ({fork}) points outside the archive."); + + this._stream.Position = offset; + var compressed = new byte[compressedSize]; + this._stream.ReadExactly(compressed); + var length = (int)size; + var data = method switch { + 0 => compressed.Length == length ? compressed : throw new InvalidDataException($"'{entry.FileName}' ({fork}): stored size mismatch."), + 1 => StuffItRle90.Decode(compressed, length), + 2 => StuffItLzw.Decode(compressed, length), + 3 => StuffItHuffman.Decode(compressed, length), + 5 => StuffItLzahDecoder.Decode(compressed, length), + 13 => StuffIt13Decoder.Decode(compressed, length), + 15 => StuffItArsenicDecoder.Decode(compressed, length), + _ => throw new NotSupportedException($"StuffIt compression method {method} ({MethodName(method)}) is not supported."), + }; - VerifyCrc16(decompressed, entry.ResourceForkCrc16, entry.FileName, "resource fork"); - return decompressed; + // Method 15 verifies its own CRC-32; every other method carries a CRC-16/ARC of the fork. + if (method != 15 && Crc16.Compute(data) != crc) + throw new InvalidDataException($"CRC-16 mismatch for '{entry.FileName}' ({fork})."); + return data; } + /// Human-readable name of a StuffIt method number. + public static string MethodName(int method) => (method & 0x0F) switch { + 0 => "None", + 1 => "RLE", + 2 => "Compress", + 3 => "Huffman", + 5 => "LZAH", + 6 => "Fixed Huffman", + 8 => "MW", + 13 => "LZ+Huffman", + 14 => "Installer", + 15 => "Arsenic", + var m => $"Method {m}", + }; + /// public void Dispose() { - if (!this._disposed) { - this._disposed = true; - if (!this._leaveOpen) - this._stream.Dispose(); - } + if (this._disposed) return; + this._disposed = true; + if (!this._leaveOpen) this._stream.Dispose(); } - // ── Parsing ────────────────────────────────────────────────────────────────── - - private void ParseArchive() { - Span archiveHeader = stackalloc byte[StuffItConstants.ArchiveHeaderSize]; - ReadExact(archiveHeader); - - var magic = BinaryPrimitives.ReadUInt32BigEndian(archiveHeader); - if (magic != StuffItConstants.MagicSit) - throw new InvalidDataException( - $"Not a StuffIt archive. Expected magic 0x{StuffItConstants.MagicSit:X8}, found 0x{magic:X8}."); - - var fileCount = BinaryPrimitives.ReadUInt16BigEndian(archiveHeader[4..]); - // archiveHeader[6..10] = total archive length (uint32 BE) — informational only. - var signature = BinaryPrimitives.ReadUInt32BigEndian(archiveHeader[10..]); - if (signature != StuffItConstants.ArchiveSignatureRLau) - throw new InvalidDataException( - $"StuffIt archive missing 'rLau' signature (found 0x{signature:X8})."); - - // Read each entry header sequentially. - for (var i = 0; i < fileCount; ++i) { - var entry = ReadEntryHeader(); - this._entries.Add(entry); - - // Skip past the compressed resource fork + data fork so we are positioned - // at the next entry header. - this._stream.Position = entry.DataForkOffset + entry.CompressedDataSize; + // ── classic "SIT!" ────────────────────────────────────────────────── + + private void ParseClassic() { + var header = this.ReadAt(this._base, ClassicHeaderSize); + var totalSize = BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(6)); + var end = Math.Min(this._base + totalSize, this._stream.Length); + if (totalSize < ClassicHeaderSize) end = this._stream.Length; + + var dirs = new Stack(); + var pos = this._base + ClassicHeaderSize; + while (pos + ClassicEntrySize <= end) { + var h = this.ReadAt(pos, ClassicEntrySize); + if (BinaryPrimitives.ReadUInt16BigEndian(h.AsSpan(110)) != Crc16.Compute(h.AsSpan(0, 110))) + throw new InvalidDataException($"StuffIt entry header at offset {pos} fails its CRC."); + pos += ClassicEntrySize; + + int resMethod = h[0], dataMethod = h[1]; + var nameLength = Math.Min((int)h[2], 31); + var name = Component(h.AsSpan(3, nameLength)); + var path = dirs.Count == 0 ? name : dirs.Peek() + "/" + name; + var modified = MacDate(BinaryPrimitives.ReadUInt32BigEndian(h.AsSpan(80))); + + var dataFolder = dataMethod & ~(FlagEncrypted | FolderContainsEncrypted); + var resFolder = resMethod & ~(FlagEncrypted | FolderContainsEncrypted); + if (dataFolder == FolderStart || resFolder == FolderStart) { + this._entries.Add(new StuffItEntry { + FileName = path, + IsDirectory = true, + LastModified = modified, + IsEncrypted = ((dataMethod | resMethod) & FolderContainsEncrypted) != 0, + }); + dirs.Push(path); + continue; + } + if (dataFolder == FolderEnd || resFolder == FolderEnd) { + if (dirs.Count > 0) dirs.Pop(); + continue; + } + + var resLength = BinaryPrimitives.ReadUInt32BigEndian(h.AsSpan(84)); + var dataLength = BinaryPrimitives.ReadUInt32BigEndian(h.AsSpan(88)); + var resCompressed = BinaryPrimitives.ReadUInt32BigEndian(h.AsSpan(92)); + var dataCompressed = BinaryPrimitives.ReadUInt32BigEndian(h.AsSpan(96)); + this._entries.Add(new StuffItEntry { + FileName = path, + FileType = Encoding.Latin1.GetString(h, 66, 4), + FileCreator = Encoding.Latin1.GetString(h, 70, 4), + LastModified = modified, + ResourceMethod = resMethod & 0x7F, + DataMethod = dataMethod & 0x7F, + IsEncrypted = ((resMethod | dataMethod) & FlagEncrypted) != 0, + ResourceForkSize = resLength, + DataForkSize = dataLength, + CompressedResourceSize = resCompressed, + CompressedDataSize = dataCompressed, + ResourceForkCrc16 = BinaryPrimitives.ReadUInt16BigEndian(h.AsSpan(100)), + DataForkCrc16 = BinaryPrimitives.ReadUInt16BigEndian(h.AsSpan(102)), + ResourceDataOffset = pos, + DataForkOffset = pos + resCompressed, + }); + pos += (long)resCompressed + dataCompressed; } } - private StuffItEntry ReadEntryHeader() { - var hdr = ReadExact(StuffItConstants.EntryHeaderSize); - - var resourceMethod = hdr[0]; - var dataMethod = hdr[1]; - var nameLength = hdr[2]; - - // Clamp to max 63 characters. - if (nameLength > StuffItConstants.FileNameMaxLength) - nameLength = StuffItConstants.FileNameMaxLength; - - // Mac Roman is not registered by default on .NET Core; Latin-1 (ISO-8859-1) is the - // closest portable encoding and correctly round-trips all ASCII filenames. - var fileName = Encoding.Latin1.GetString(hdr, StuffItConstants.FileNameOffset, nameLength); - - // hdr[66..70] = file type (4-char Mac code) - var fileType = Encoding.ASCII.GetString(hdr, 66, 4); - // hdr[70..74] = file creator (4-char Mac code) - var fileCreator = Encoding.ASCII.GetString(hdr, 70, 4); - - // hdr[74..76] = Finder flags (big-endian uint16) — not exposed - // hdr[76..80] = creation date (Mac timestamp, uint32 BE) — not exposed - var modDate = BinaryPrimitives.ReadUInt32BigEndian(hdr.AsSpan(80, 4)); - - var resourceForkUncompressed = BinaryPrimitives.ReadUInt32BigEndian(hdr.AsSpan(84, 4)); - var dataForkUncompressed = BinaryPrimitives.ReadUInt32BigEndian(hdr.AsSpan(88, 4)); - var resourceForkCompressed = BinaryPrimitives.ReadUInt32BigEndian(hdr.AsSpan(92, 4)); - var dataForkCompressed = BinaryPrimitives.ReadUInt32BigEndian(hdr.AsSpan(96, 4)); - - var resourceCrc16 = BinaryPrimitives.ReadUInt16BigEndian(hdr.AsSpan(100, 2)); - var dataCrc16 = BinaryPrimitives.ReadUInt16BigEndian(hdr.AsSpan(102, 2)); - - // hdr[104..110] = reserved (6 bytes) - // hdr[110..112] = header CRC-16 — not verified here - - var lastModified = modDate == 0 - ? DateTime.MinValue - : StuffItConstants.MacEpoch.AddSeconds(modDate); - - var resourceDataOffset = this._stream.Position; - var dataForkOffset = resourceDataOffset + resourceForkCompressed; - - return new StuffItEntry { - FileName = fileName, - DataMethod = dataMethod, - ResourceMethod = resourceMethod, - DataForkSize = dataForkUncompressed, - ResourceForkSize = resourceForkUncompressed, - CompressedDataSize = dataForkCompressed, - CompressedResourceSize = resourceForkCompressed, - FileType = fileType, - FileCreator = fileCreator, - LastModified = lastModified, - ResourceDataOffset = resourceDataOffset, - DataForkOffset = dataForkOffset, - DataForkCrc16 = dataCrc16, - ResourceForkCrc16 = resourceCrc16, - }; - } - - // ── Decompression ───────────────────────────────────────────────────────────── - - private static byte[] Decompress(int method, byte[] compressed, int uncompressedSize) => - method switch { - StuffItConstants.MethodStore => compressed, - StuffItConstants.MethodRle => StuffItRle.Decode(compressed), - _ => throw new NotSupportedException( - $"StuffIt compression method {method} is not supported."), - }; - - // ── CRC-16/CCITT (forward, non-reflected, init=0) ───────────────────────────── - - private static readonly ushort[] Crc16Table = BuildCrc16Table(); - - private static ushort[] BuildCrc16Table() { - const ushort poly = StuffItConstants.Crc16Polynomial; - var table = new ushort[256]; - for (var i = 0; i < 256; ++i) { - var crc = (ushort)(i << 8); - for (var j = 0; j < 8; ++j) - crc = (crc & 0x8000) != 0 ? (ushort)((crc << 1) ^ poly) : (ushort)(crc << 1); - table[i] = crc; + // ── StuffIt 5 ─────────────────────────────────────────────────────── + + private void ParseSit5() { + var h = this.ReadAt(this._base, 100); + if (h[82] != 5) + throw new InvalidDataException($"StuffIt 5 archive version {h[82]} is not supported."); + var rootCount = BinaryPrimitives.ReadUInt16BigEndian(h.AsSpan(92)); + var firstOffset = BinaryPrimitives.ReadUInt32BigEndian(h.AsSpan(94)); + + var dirs = new Dictionary(); + var pos = this._base + firstOffset; + var remaining = (long)rootCount; + var guard = 0; + while (remaining-- > 0) { + if (++guard > 1_000_000) throw new InvalidDataException("StuffIt 5 directory does not terminate."); + var entryOffset = pos; + var fixedPart = this.ReadAt(pos, 48); + if (BinaryPrimitives.ReadUInt32BigEndian(fixedPart) != Sit5EntryId) + throw new InvalidDataException($"StuffIt 5 entry at offset {pos} has no A5A5A5A5 marker."); + int version = fixedPart[4]; + int headerSize = BinaryPrimitives.ReadUInt16BigEndian(fixedPart.AsSpan(6)); + int flags = fixedPart[9]; + var created = BinaryPrimitives.ReadUInt32BigEndian(fixedPart.AsSpan(10)); + var modified = BinaryPrimitives.ReadUInt32BigEndian(fixedPart.AsSpan(14)); + var parentOffset = BinaryPrimitives.ReadUInt32BigEndian(fixedPart.AsSpan(26)); + int nameLength = BinaryPrimitives.ReadUInt16BigEndian(fixedPart.AsSpan(30)); + var dataLength = BinaryPrimitives.ReadUInt32BigEndian(fixedPart.AsSpan(34)); + var dataCompressed = BinaryPrimitives.ReadUInt32BigEndian(fixedPart.AsSpan(38)); + var dataCrc = BinaryPrimitives.ReadUInt16BigEndian(fixedPart.AsSpan(42)); + _ = created; + + pos += 46; + var isDir = (flags & Sit5FlagDirectory) != 0; + var crypted = (flags & Sit5FlagCrypted) != 0; + int dataMethod = 0, childCount = 0; + if (isDir) { + childCount = BinaryPrimitives.ReadUInt16BigEndian(this.ReadAt(pos, 2)); + pos += 2; + if (dataLength == 0xFFFFFFFF) { + // Marker records that follow every folder: 48 bytes, not counted among its children. + ++remaining; + continue; + } + } else { + var mp = this.ReadAt(pos, 2); + dataMethod = mp[0]; + int passLength = mp[1]; + pos += 2 + (crypted && dataLength != 0 ? passLength : 0); + if (!crypted && passLength != 0) + throw new InvalidDataException("StuffIt 5 entry has key material but is not marked encrypted."); + } + + var name = Component(this.ReadAt(pos, nameLength)); + pos += nameLength; + if (pos < entryOffset + headerSize) { + var commentSize = BinaryPrimitives.ReadUInt16BigEndian(this.ReadAt(pos, 2)); + pos += 4 + commentSize; + } + // Second block: fork flags, type/creator, Finder flags, then (optionally) the resource fork record. + var second = this.ReadAt(pos, 14); + var hasResource = (BinaryPrimitives.ReadUInt16BigEndian(second) & 0x01) != 0; + var fileType = Encoding.Latin1.GetString(second, 4, 4); + var fileCreator = Encoding.Latin1.GetString(second, 8, 4); + pos += 14 + (version == 1 ? 22 : 18); + + uint resLength = 0, resCompressed = 0; + ushort resCrc = 0; + var resMethod = 0; + if (hasResource) { + var r = this.ReadAt(pos, 14); + resLength = BinaryPrimitives.ReadUInt32BigEndian(r); + resCompressed = BinaryPrimitives.ReadUInt32BigEndian(r.AsSpan(4)); + resCrc = BinaryPrimitives.ReadUInt16BigEndian(r.AsSpan(8)); + resMethod = r[12]; + int passLength = r[13]; + pos += 14 + (crypted && resLength != 0 ? passLength : 0); + } + + var parent = dirs.GetValueOrDefault(parentOffset - this._base) ?? dirs.GetValueOrDefault(parentOffset); + var path = parent is null ? name : parent + "/" + name; + + if (isDir) { + dirs[entryOffset - this._base] = path; + this._entries.Add(new StuffItEntry { FileName = path, IsDirectory = true, LastModified = MacDate(modified), IsEncrypted = crypted }); + remaining += childCount; + continue; + } + + this._entries.Add(new StuffItEntry { + FileName = path, + FileType = fileType, + FileCreator = fileCreator, + LastModified = MacDate(modified), + IsEncrypted = crypted, + DataMethod = dataMethod, + ResourceMethod = resMethod, + DataForkSize = dataLength, + CompressedDataSize = dataCompressed, + DataForkCrc16 = dataCrc, + ResourceForkSize = resLength, + CompressedResourceSize = resCompressed, + ResourceForkCrc16 = resCrc, + ResourceDataOffset = pos, + DataForkOffset = pos + resCompressed, + }); + pos += (long)resCompressed + dataCompressed; } - return table; } - private static ushort ComputeCrc16(ReadOnlySpan data) { - ushort crc = 0; - foreach (var b in data) - crc = (ushort)((crc << 8) ^ Crc16Table[(byte)(crc >> 8) ^ b]); - return crc; - } + // ── helpers ───────────────────────────────────────────────────────── - private static void VerifyCrc16(byte[] data, ushort expected, string fileName, string forkName) { - if (expected == 0) - return; // zero means no checksum stored - - var actual = ComputeCrc16(data); - if (actual != expected) - throw new InvalidDataException( - $"CRC-16 mismatch for '{fileName}' ({forkName}): expected 0x{expected:X4}, computed 0x{actual:X4}."); + private byte[] ReadAt(long offset, int count) { + if (offset < 0 || offset + count > this._stream.Length) + throw new InvalidDataException($"StuffIt structure at offset {offset} runs past the end of the archive."); + this._stream.Position = offset; + var buffer = new byte[count]; + this._stream.ReadExactly(buffer); + return buffer; } - // ── Stream helpers ──────────────────────────────────────────────────────────── - - private byte[] ReadExact(int count) { - var buf = new byte[count]; - ReadExact(buf.AsSpan()); - return buf; + private static string Component(ReadOnlySpan raw) { + var sb = new StringBuilder(raw.Length); + foreach (var b in raw) sb.Append(b < 0x80 ? (char)b : MacRomanHigh[b - 0x80]); + // A Mac name may contain '/', which would read as a folder separator here. + return sb.Replace('/', ':').ToString(); } - private void ReadExact(Span buffer) { - var total = 0; - while (total < buffer.Length) { - var read = this._stream.Read(buffer[total..]); - if (read == 0) - throw new EndOfStreamException("Unexpected end of stream reading StuffIt data."); - total += read; - } - } + private static DateTime MacDate(uint seconds) + => seconds == 0 ? DateTime.MinValue : new DateTime(1904, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(seconds); } diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItRle.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItRle.cs deleted file mode 100644 index 1552c78be..000000000 --- a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItRle.cs +++ /dev/null @@ -1,107 +0,0 @@ -namespace FileFormat.StuffIt; - -/// -/// StuffIt RLE decoder (compression method 1). -/// -/// -/// The encoding uses 0x90 as the repeat marker: -/// -/// 0x90 followed by 0x00 encodes a literal 0x90 byte. -/// 0x90 followed by count (1–255) means repeat the preceding byte -/// so that the total run length is count + 1. -/// -/// -internal static class StuffItRle { - private const byte Marker = StuffItConstants.RleMarker; - - /// - /// Encodes data using StuffIt RLE compression. - /// - /// - /// Runs of 4 or more identical bytes are encoded as: byte, 0x90, (count-1). - /// Literal 0x90 bytes are escaped as 0x90 0x00. - /// - public static byte[] Encode(ReadOnlySpan data) { - if (data.IsEmpty) - return []; - - var output = new List(data.Length); - var i = 0; - - while (i < data.Length) { - var current = data[i]; - - // Count the run length. - var runLength = 1; - while (i + runLength < data.Length && data[i + runLength] == current && runLength < 255) - ++runLength; - - if (runLength >= 4) { - // Emit the byte once, then marker + (runLength - 1). - // The decoder sees the byte, then marker + count, and repeats it count more times. - // Total output = 1 + count additional = runLength. - // But if the byte itself is the marker, we need to emit it escaped first. - if (current == Marker) { - output.Add(Marker); - output.Add(0x00); // literal 0x90 - } else { - output.Add(current); - } - output.Add(Marker); - output.Add((byte)(runLength - 1)); - i += runLength; - } else { - // Emit individual bytes, escaping 0x90. - for (var r = 0; r < runLength; ++r) { - output.Add(current); - if (current == Marker) - output.Add(0x00); - } - i += runLength; - } - } - - return [.. output]; - } - - /// - /// Decodes StuffIt RLE-encoded data. - /// - public static byte[] Decode(ReadOnlySpan data) { - if (data.IsEmpty) - return []; - - var output = new List(data.Length * 2); - byte lastByte = 0; - var i = 0; - - while (i < data.Length) { - var current = data[i++]; - - if (current != Marker) { - output.Add(current); - lastByte = current; - continue; - } - - // Marker byte: read the count. - if (i >= data.Length) - throw new InvalidDataException("Unexpected end of StuffIt RLE stream after marker byte."); - - var count = data[i++]; - - if (count == 0) { - // Literal 0x90. - output.Add(Marker); - lastByte = Marker; - continue; - } - - // Repeat lastByte `count` additional times (it was already emitted once). - for (var r = 0; r < count; ++r) - output.Add(lastByte); - } - - return [.. output]; - } -} diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItWriter.cs b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItWriter.cs index 753afa90d..5ed44bfb2 100644 --- a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItWriter.cs +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItWriter.cs @@ -76,6 +76,9 @@ public void AddFileWithResourceFork( ArgumentNullException.ThrowIfNull(fileName); ArgumentNullException.ThrowIfNull(dataFork); ArgumentNullException.ThrowIfNull(resourceFork); + if (Encoding.Latin1.GetByteCount(fileName) is 0 or > StuffItConstants.FileNameMaxLength) + throw new NotSupportedException( + $"A classic StuffIt name holds 1-{StuffItConstants.FileNameMaxLength} bytes; '{fileName}' would be truncated."); byte dataMethod; var compressedData = CompressFork(dataFork, out dataMethod); @@ -157,7 +160,7 @@ private static void WriteEntry(Stream output, PendingEntry entry) { // [2] name length, [3..66] filename (Latin-1, null-padded) var nameBytes = Encoding.Latin1.GetBytes(entry.FileName); - var nameLen = Math.Min(nameBytes.Length, StuffItConstants.FileNameMaxLength); + var nameLen = nameBytes.Length; hdr[2] = (byte)nameLen; nameBytes.AsSpan(0, nameLen).CopyTo(hdr.Slice(StuffItConstants.FileNameOffset, nameLen)); @@ -212,7 +215,7 @@ private static byte[] CompressFork(byte[] data, out byte method) { return []; } - var rleEncoded = StuffItRle.Encode(data); + var rleEncoded = StuffItRle90.Encode(data); if (rleEncoded.Length < data.Length) { method = StuffItConstants.MethodRle; return rleEncoded; @@ -222,28 +225,8 @@ private static byte[] CompressFork(byte[] data, out byte method) { return data; } - // ── CRC-16/CCITT (forward, non-reflected, init=0) ─────────────────────── - - private static readonly ushort[] Crc16Table = BuildCrc16Table(); - - private static ushort[] BuildCrc16Table() { - const ushort poly = StuffItConstants.Crc16Polynomial; - var table = new ushort[256]; - for (var i = 0; i < 256; ++i) { - var crc = (ushort)(i << 8); - for (var j = 0; j < 8; ++j) - crc = (crc & 0x8000) != 0 ? (ushort)((crc << 1) ^ poly) : (ushort)(crc << 1); - table[i] = crc; - } - return table; - } - - private static ushort ComputeCrc16(ReadOnlySpan data) { - ushort crc = 0; - foreach (var b in data) - crc = (ushort)((crc << 8) ^ Crc16Table[(byte)(crc >> 8) ^ b]); - return crc; - } + // StuffIt checks headers and forks with CRC-16/ARC (reflected 0xA001, init 0), as every reader does. + private static ushort ComputeCrc16(ReadOnlySpan data) => Compression.Core.Checksums.Crc16.Compute(data); // ── Helpers ────────────────────────────────────────────────────────────── diff --git a/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/THIRD-PARTY-NOTICE.XADMaster.txt b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/THIRD-PARTY-NOTICE.XADMaster.txt new file mode 100644 index 000000000..f4d71ad94 --- /dev/null +++ b/Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/THIRD-PARTY-NOTICE.XADMaster.txt @@ -0,0 +1,24 @@ +StuffIt decoding in this folder (StuffItReader's classic and StuffIt 5 directory parsing, +StuffIt13Decoder, StuffIt13Tables, StuffItLzahDecoder, StuffItArsenicDecoder, StuffItHuffman, +StuffItRle90) is converted from XADMaster, the archive library of The Unarchiver. + +Upstream: https://github.com/MacPaw/XADMaster +Revision: 7cb9ee0abbb163f261e4cb74501e15067032319c +Files: XADStuffItParser.m, XADStuffIt5Parser.m, XADStuffIt13Handle.m, XADStuffItHuffmanHandle.m, + XADStuffItArsenicHandle.m, XADLZHDynamicHandle.m, XADRLE90Handle.m, XADPrefixCode.m, BWT.c +Licence: GNU Lesser General Public License, version 2.1 or (at your option) any later version. + This repository is distributed under LGPL-3.0-or-later, which the "any later version" + grant permits. + +Copyright (c) 2017-present, MacPaw Way Ltd. and the XADMaster contributors (Dag Ågren et al.). + +This library is free software; you can redistribute it and/or modify it under the terms of the +GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 +of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without +even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +The code-length tables of method 13 and the Arsenic randomisation table are copied verbatim; all +other code was rewritten in C# for this codebase. diff --git a/Hawkynt.FileFormats.Archives/README.md b/Hawkynt.FileFormats.Archives/README.md index 9357f828e..38d8ccd31 100644 --- a/Hawkynt.FileFormats.Archives/README.md +++ b/Hawkynt.FileFormats.Archives/README.md @@ -139,7 +139,7 @@ Every State, Test, Maintenance, Compress/Decompress and Demux/Mux/Remux cell is | [Spark (RISC OS)](https://en.wikipedia.org/wiki/ARC_(file_format)) | `Spark` | `.spk` `.spark` | R/W | ✅ | defrag · wipe | | [Archive Team](http://fileformats.archiveteam.org/wiki/Spark) | | [Split File (.001)](https://en.wikipedia.org/wiki/File_spanning) | `SplitFile` | `.001` | WORM | ✅ | — | | [Wikipedia](https://en.wikipedia.org/wiki/File_spanning) | | [SQX](https://en.wikipedia.org/wiki/SQX) | `Sqx` | `.sqx` | R/W | ✅ | defrag · wipe | | [encode.su](https://encode.su/threads/1290-SQX-(by-SpeedProject)) | -| [StuffIt](https://en.wikipedia.org/wiki/StuffIt) | `StuffIt` | `.sit` | R/W | ✅ | defrag · wipe | | [GitHub](https://github.com/MacPaw/XADMaster) | +| [StuffIt](https://en.wikipedia.org/wiki/StuffIt) | `StuffIt` | `.sit` | R/W | ✅ | defrag · wipe | Classic SIT! and StuffIt 5 read (methods 0/1/2/3/5/13/15, folders, resource forks as `.rsrc`); writes classic store/RLE | [GitHub](https://github.com/MacPaw/XADMaster) | | [StuffIt X](https://en.wikipedia.org/wiki/StuffIt) | `StuffItX` | `.sitx` | WORM | ✅ | wipe | Writer emits the envelope shell only; the proprietary element catalog is not synthesised | [GitHub](https://github.com/MacPaw/XADMaster) | | [Split WIM (.swm)](https://en.wikipedia.org/wiki/Windows_Imaging_Format) | `Swm` | `.swm` `.swm2` `.swm3` `.swm4` … | WORM | ✅ | wipe | Reads wimlib-written sets from part 1 with its siblings beside it; `SwmFormatDescriptor.CreateSplit` writes multi-part sets that wimlib-imagex verifies and applies (XPRESS, LZX, LZMS, none); the generic single-stream Create writes a one-part set | [Microsoft Learn](https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/wim-and-esd-windows-image-files-overview) | | [T64 (Commodore tape image)](https://en.wikipedia.org/wiki/T64_(file_format)) | `T64` | `.t64` | R/W | ✅ | defrag · wipe | | [vice-emu.sourceforge.net](https://vice-emu.sourceforge.io/) | diff --git a/Hawkynt.FileFormats.Archives/REFERENCE.md b/Hawkynt.FileFormats.Archives/REFERENCE.md index 464eb0ba6..f7a9b5265 100644 --- a/Hawkynt.FileFormats.Archives/REFERENCE.md +++ b/Hawkynt.FileFormats.Archives/REFERENCE.md @@ -28482,7 +28482,7 @@ Implements `IArchiveCreatable`, `IArchiveFormatOperations`, `IArchiveInMemoryExt #### `StuffItEntry` -Represents a single file entry in a StuffIt (SIT) archive. +Represents a single file or folder entry in a StuffIt archive (classic "SIT!" or StuffIt 5). | Member | Signature | Summary | | --- | --- | --- | @@ -28490,14 +28490,16 @@ Represents a single file entry in a StuffIt (SIT) archive. | `CompressedDataSize` | `long CompressedDataSize { get; set; }` | Gets or sets the compressed size of the data fork in bytes. | | `CompressedResourceSize` | `long CompressedResourceSize { get; set; }` | Gets or sets the compressed size of the resource fork in bytes. | | `DataForkSize` | `long DataForkSize { get; set; }` | Gets or sets the uncompressed size of the data fork in bytes. | -| `DataMethod` | `int DataMethod { get; set; }` | Gets or sets the compression method code for the data fork. | +| `DataMethod` | `int DataMethod { get; set; }` | Gets or sets the compression method code for the data fork (encryption flag removed). | | `FileCreator` | `string FileCreator { get; set; }` | Gets or sets the Mac four-character file creator code. | -| `FileName` | `string FileName { get; set; }` | Gets or sets the file name as stored in the archive (up to 63 characters). | +| `FileName` | `string FileName { get; set; }` | Gets or sets the entry's path inside the archive, folders separated by '/'. | | `FileType` | `string FileType { get; set; }` | Gets or sets the Mac four-character file type code (e.g. "TEXT"). | -| `IsDirectory` | `bool IsDirectory { get; }` | StuffIt classic archives do not support directory entries. | +| `HasResourceFork` | `bool HasResourceFork { get; }` | Gets whether the entry carries a resource fork. | +| `IsDirectory` | `bool IsDirectory { get; set; }` | Gets or sets whether this entry is a folder. | +| `IsEncrypted` | `bool IsEncrypted { get; set; }` | Gets or sets whether the forks are encrypted (classic DES or StuffIt 5 RC4). | | `LastModified` | `DateTime LastModified { get; set; }` | Gets or sets the last modification date/time of the file. | | `ResourceForkSize` | `long ResourceForkSize { get; set; }` | Gets or sets the uncompressed size of the resource fork in bytes. | -| `ResourceMethod` | `int ResourceMethod { get; set; }` | Gets or sets the compression method code for the resource fork. | +| `ResourceMethod` | `int ResourceMethod { get; set; }` | Gets or sets the compression method code for the resource fork (encryption flag removed). | #### `StuffItFormatDescriptor` @@ -28508,6 +28510,7 @@ Implements `IArchiveCreatable`, `IArchiveDefragmentable`, `IArchiveFormatOperati | Member | Signature | Summary | | --- | --- | --- | | `StuffItFormatDescriptor` | `StuffItFormatDescriptor()` | | +| `ResourceForkSuffix` | `const string ResourceForkSuffix` | Suffix under which a file's resource fork is listed and extracted. | | `Capabilities` | `FormatCapabilities Capabilities { get; }` | Gets the capabilities. | | `Category` | `FormatCategory Category { get; }` | Gets the category. | | `CompoundExtensions` | `IReadOnlyList CompoundExtensions { get; }` | Gets the compound extensions. | @@ -28531,17 +28534,19 @@ Implements `IArchiveCreatable`, `IArchiveDefragmentable`, `IArchiveFormatOperati #### `StuffItReader` -Reads entries from a StuffIt (SIT) classic archive. +Reads StuffIt archives: the classic "SIT!" format of StuffIt 1.5-4.x (with folders) and the StuffIt 5 format ("StuffIt (c)1997-..." banner) written by StuffIt 5.x-7.x and StuffIt Deluxe/Standard for Windows. Implements `IDisposable`. | Member | Signature | Summary | | --- | --- | --- | | `StuffItReader` | `StuffItReader(Stream stream, bool leaveOpen = false)` | Opens a StuffIt archive from the given stream and parses the entry directory. | -| `Entries` | `IReadOnlyList Entries { get; }` | Gets the list of entries found in the archive. | +| `Entries` | `IReadOnlyList Entries { get; }` | Gets the entries (folders first in archive order, then their contents). | +| `IsStuffIt5` | `bool IsStuffIt5 { get; }` | Gets whether the archive uses the StuffIt 5 layout. | | `Dispose` | `void Dispose()` | | -| `ExtractResourceFork` | `byte[] ExtractResourceFork(StuffItEntry entry)` | Extracts and decompresses the resource fork of the specified entry. | -| `Extract` | `byte[] Extract(StuffItEntry entry)` | Extracts and decompresses the data fork of the specified entry. | +| `ExtractResourceFork` | `byte[] ExtractResourceFork(StuffItEntry entry)` | Extracts and verifies the resource fork of `entry`. | +| `Extract` | `byte[] Extract(StuffItEntry entry)` | Extracts and verifies the data fork of `entry`. | +| `MethodName` | `static string MethodName(int method)` | Human-readable name of a StuffIt method number. | #### `StuffItWriter` 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/LICENCE-PROVENANCE.md b/docs/LICENCE-PROVENANCE.md index ca50e4c15..36dcdf06d 100644 --- a/docs/LICENCE-PROVENANCE.md +++ b/docs/LICENCE-PROVENANCE.md @@ -31,7 +31,7 @@ package carries; everything else keeps a `THIRD-PARTY-NOTICE..txt` beside | Class | Upstream rows | Notes | |---|---|---| -| A | 18 | | +| A | 19 | | B | 17 | FFmpeg alone covers 38 codec folders | | C | 3 | specifications: `docs/CVF-CODECS-ON-DISK.md` (rewrite already under way), `docs/IT-SAMPLE-COMPRESSION-SPEC.md`, `docs/FDS-EXPANSION-SOUND-SPEC.md` | | R | 8 | | @@ -70,6 +70,7 @@ package carries; everything else keeps a `THIRD-PARTY-NOTICE..txt` beside | Claim site | Upstream | Licence | Where the notice is | |---|---|---|---| +| `Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/` | XADMaster StuffIt parsing and fork decoding, revision `7cb9ee0abbb163f261e4cb74501e15067032319c`, file list in the notice | LGPL-2.1-or-later | `Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/THIRD-PARTY-NOTICE.XADMaster.txt` | | `Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.AndroidBootImage/AndroidBootImage.cs`, `Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.AndroidBootImage/AndroidBootImageMetadata.cs` | AOSP mkbootimg `bootimg.h`, `mkbootimg.py` and `unpack_bootimg.py`, revision `d2bb0af5ba6d3198a3e99529c97eda1be0b5a093` | BSD-3-Clause and Apache-2.0 | `Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.AndroidBootImage/THIRD-PARTY-NOTICE.mkbootimg.txt` | | `Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.AndroidVbmeta/AvbVbmeta.cs`, `Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.AndroidVbmeta/AvbDescriptorText.cs` | AOSP Android Verified Boot headers and `avbtool.py`, revision `c5066a96caa7bf4150c0a8cc8cc14ab81733fdc7` | MIT | `Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.AndroidVbmeta/THIRD-PARTY-NOTICE.avb.txt` | | `Compression.Core/Dictionary/Lzbitmap/` | libzbitmap (Ernesto A. Fernández, Corellium) | MIT | `THIRD-PARTY-NOTICE.libzbitmap.txt` | diff --git a/docs/catalogue/reference-formats.csv b/docs/catalogue/reference-formats.csv index d168a2e19..31d1c5b56 100644 --- a/docs/catalogue/reference-formats.csv +++ b/docs/catalogue/reference-formats.csv @@ -43,7 +43,7 @@ LBR (CP/M library),lbr;lqr;lzr,archive,,,R,,R,,Y,3,https://en.wikipedia.org/wiki 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) 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 (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,,"methods 8 (MW), 14 (Installer) and encrypted forks (DES / StuffIt 5 RC4) unsupported; writer emits classic store/RLE only (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) DiskDoubler,dd,archive,,,R,,,,Y,2,https://en.wikipedia.org/wiki/DiskDoubler,DiskDoubler,Y,Y,,PARTIAL,,"single files only (no DDAR/DDA2 archives); methods 0, 1, 6, 9 decoded and checked against unar on real files; 2-5, 7, 8, 10 and delta refused (DiskDoublerReader.cs)" Compact Pro,cpt,archive,,,R,,,,Y,2,https://en.wikipedia.org/wiki/Compact_Pro,CompactPro,Y,Y,Y,PARTIAL,,stored and RLE only; LZ+RLE and LZ+Huffman unsupported (CompactProReader.cs) @@ -174,10 +174,10 @@ BCJ SPARC,sparc,filter,RW,,R,,,,,2,,bb:BB_BcjSparc,Y,Y,,OK,, BCJ RISC-V,riscv,filter,RW,,,,,,,1,,bb:BB_BcjRiscV,Y,Y,,OK,, Swap2 / Swap4 filter,swap2;swap4,filter,RW,,,,,,,1,,,,,,MISSING,, RLE90 (BinHex / StuffIt RLE),,codec,,,R,,,,,1,,BinHex;StuffIt,Y,Y,Y,OK,, -StuffIt Arsenic (method 15),,codec,,,R,,,,,1,,,,,,MISSING,, -StuffIt method 13,,codec,,,R,,,,,1,,,,,,MISSING,, -StuffIt Huffman (method 3),,codec,,,R,,,,,1,,,,,,MISSING,, -StuffIt LZW (method 2),,codec,,,R,,,,,1,,,,,,MISSING,, +StuffIt Arsenic (method 15),,codec,,,R,,,,,1,,StuffIt,Y,Y,Y,OK,, +StuffIt method 13,,codec,,,R,,,,,1,,StuffIt,Y,Y,Y,OK,, +StuffIt Huffman (method 3),,codec,,,R,,,,,1,,StuffIt,Y,Y,Y,OK,, +StuffIt LZW (method 2),,codec,,,R,,,,,1,,StuffIt,Y,Y,Y,OK,, StuffIt DES encryption,,encryption,,,R,,,,,1,,,,,,MISSING,, StuffIt X Blend,,codec,,,R,,,,,1,,,,,,MISSING,, StuffIt X Cyanide,,codec,,,R,,,,,1,,,,,,MISSING,,