From 4c8b68c66b2891ba7f22f84d0bf95b49e321924d Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Mon, 19 Oct 2026 13:24:47 +0200 Subject: [PATCH 1/3] + StuffIt: real classic and StuffIt 5 archives (methods 2/3/5/13/15, folders, resource forks) The reader understood only a classic SIT! subset with store/RLE, checked forks with the wrong CRC (CCITT instead of ARC), read 63-byte names into fields that start at byte 34, had no folders and no StuffIt 5 layout, so none of the sample archives made by real StuffIt installs extracted. The writer shared the CRC mistake and wrote RLE90 runs one byte short. Reader: classic SIT! (header CRC-16/ARC verified, folder start/end records, 31-byte names, Mac Roman) and StuffIt 5 (entry versions 1 and 3, nested folders, comments, resource-fork records). Forks decode with method 0, 1 (RLE90), 2 (compress LZW, 14-bit block mode), 3 (Huffman), 5 (LZAH), 13 (LZ+Huffman) and 15 (Arsenic), each verified by its CRC-16/ARC or, for Arsenic, its CRC-32; methods 8/14 and encrypted forks are listed and refused. The descriptor lists folders and exposes resource forks as ".rsrc". Writer: CRC-16/ARC for header and forks, BinHex-style RLE90 run lengths, names longer than the 31-byte field are refused instead of corrupting the header. Oracle: four archives made by StuffIt Deluxe 4.5 / 6.5.1 / 7.0 (Mac OS 9) and StuffIt 7 (Windows) from ssokolow/stuffit-test-files (CC0) are committed; every data and resource fork matches the SHA-256 of the source file it was made from. unar (The Unarchiver) extracts the same bytes from them and from archives our writer produces; a tool-gated test also compares any local samples (CWB_STUFFIT_SAMPLES - the dexvert set covers methods 2 and 3). Sourcing: rung 1 - converted from XADMaster (LGPL-2.1-or-later, revision 7cb9ee0), notice in THIRD-PARTY-NOTICE.XADMaster.txt; method-13 code tables and the Arsenic randomisation table copied verbatim. --- Compression.Tests/Compression.Tests.csproj | 4 + .../StuffIt/ReferenceVectors/LICENSE.md | 27 + .../StuffIt/ReferenceVectors/README.md | 16 + .../testfile.stuffit45_dlx.mac9.sit | Bin 0 -> 2804 bytes .../testfile.stuffit651_dlx.mac9.sit | Bin 0 -> 2776 bytes .../testfile.stuffit7.win.sit | Bin 0 -> 795 bytes .../testfile.stuffit7_dlx.mac9.sit | Bin 0 -> 2514 bytes .../StuffIt/StuffItCorpusTests.cs | 224 ++++++++ Compression.Tests/StuffIt/StuffItTests.cs | 40 +- .../FileFormat.StuffIt/StuffIt13Decoder.cs | 117 +++++ .../FileFormat.StuffIt/StuffIt13Tables.cs | 264 ++++++++++ .../StuffItArsenicDecoder.cs | 225 ++++++++ .../FileFormat.StuffIt/StuffItBitReader.cs | 151 ++++++ .../FileFormat.StuffIt/StuffItCodecs.cs | 108 ++++ .../FileFormat.StuffIt/StuffItConstants.cs | 20 +- .../FileFormat.StuffIt/StuffItEntry.cs | 22 +- .../StuffItFormatDescriptor.cs | 44 +- .../FileFormat.StuffIt/StuffItLzahDecoder.cs | 165 ++++++ .../FileFormat.StuffIt/StuffItReader.cs | 482 +++++++++++------- .../FileFormat.StuffIt/StuffItRle.cs | 107 ---- .../FileFormat.StuffIt/StuffItWriter.cs | 31 +- .../THIRD-PARTY-NOTICE.XADMaster.txt | 24 + Hawkynt.FileFormats.Archives/README.md | 2 +- Hawkynt.FileFormats.Archives/REFERENCE.md | 23 +- docs/catalogue/reference-formats.csv | 10 +- 25 files changed, 1710 insertions(+), 396 deletions(-) create mode 100644 Compression.Tests/StuffIt/ReferenceVectors/LICENSE.md create mode 100644 Compression.Tests/StuffIt/ReferenceVectors/README.md create mode 100644 Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit45_dlx.mac9.sit create mode 100644 Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit651_dlx.mac9.sit create mode 100644 Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit7.win.sit create mode 100644 Compression.Tests/StuffIt/ReferenceVectors/testfile.stuffit7_dlx.mac9.sit create mode 100644 Compression.Tests/StuffIt/StuffItCorpusTests.cs create mode 100644 Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffIt13Decoder.cs create mode 100644 Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffIt13Tables.cs create mode 100644 Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItArsenicDecoder.cs create mode 100644 Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItBitReader.cs create mode 100644 Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItCodecs.cs create mode 100644 Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItLzahDecoder.cs delete mode 100644 Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/StuffItRle.cs create mode 100644 Hawkynt.FileFormats.Archives/FileFormats/FileFormat.StuffIt/THIRD-PARTY-NOTICE.XADMaster.txt diff --git a/Compression.Tests/Compression.Tests.csproj b/Compression.Tests/Compression.Tests.csproj index 7bba71ab0..142cce4ae 100644 --- a/Compression.Tests/Compression.Tests.csproj +++ b/Compression.Tests/Compression.Tests.csproj @@ -118,6 +118,10 @@ ZfsVectors.%(Filename)%(Extension) + + + StuffItVectors.%(Filename)%(Extension) + DarVectors.%(Filename)%(Extension) 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 0000000000000000000000000000000000000000..d114982d5185a9fa83328ee6bc622dcc38dfe48a GIT binary patch literal 2804 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0ed763b346def588f39409ce3191f00471ef9a1b GIT binary patch literal 2776 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4606f4c8f101394d7737371f3bfe3ae01841244b GIT binary patch literal 2514 zcmZuz3piA37yibWX=V%tMRX1mNs+q>5n)_%NiHR%WX34BF*7bBPULB7bTM*GZsp9l z6^9NA`HSTGtArxA+`33fby8%`-jkfp|NQTM*1OjC?X~ywt@Z71t?kMT_xHDB5;ys6 z)-^KPuBoS^qet8o6h-=Zy!QvfIe0N8E-03D!V-tbPG$8(`DDD(&lna2Y>2nYa-0T@N@q`(4sK|ov< zDqKvpP_K|1g~24+1^ZGd0HIcEwhR;i<{*B>3B;|KDrJy)8y-pmi@QDNC(q5#{mMYQ$2LS7dvB<<^4d29|QJhDhx-e9h?BC(c{0n|#?iX;!X>aQd6`Dtw<#~G2LtG}G7xc550J8gaqxv^7ny}zeQ!tj)F z;Bn_q>Be=X3bzuziQ={lycKP*DpbDtBT+_hqRdoafnuTqAaW264q)ADAr^x~9v(^p zVWQk9QA~g^q_~kh+?dQLCX{J&1Vld&_rm}P2guwB4~2P(a@5)8sovaj&as9q4qv_ca1M3SvMAv-PA1IoFvdr~%e@Nrj z4}D{3yt%Lt!7;XNE4%U|0uunL!&yT4zRk!>!d2k8g^(VE1=R@8FrlsXKN>{QI!2=c z5LdI?nPg*QzsE_q{-1(?ePIZm3Pu58x3!%$fMY~J7Z5xHmH;UN17;)yQM4!w3>u9> zV=*`!7K6p&@RE3(n1mP>D=sB2AxR)e5pZ~E8EJwHWD^8;fEWgd01*U23JC6j)$p7D z1q?L%3jrWdh@Z17fA)&6xCz%G{vOI0 zjtvE!438!M$(^&Er5iwe@`N>vmhcOx3@J28&c)eg7BC1 z+8)xm5ZTMtY6~676R$kM-4}F@L-f|{GqlIs=4bq}hRr&3HhS3rIreq7yq?R*Z?SK6ySckM9CLrYmcdQ1H+9jEspE3n)g6u*(ynr*v)W#dGkKhoITY$ z=lPU5xXLY`u+$Gh&KxAidU>?v5yIq@_^ESl@(*5SeHqmucm5{cj?m5+2nhX>a;C5` zl{{q}I={xJLVEw?i6Z4Xcg>y)G^yS}v27y{U#MeC0?bCK#`%HeB&`a0>;80&Ln17^ zQ&L__;1d3hMB#9KX_>XgwW`oQ2dUvdTf2+f7pN)P@#zE#b$AAI4vKQZPHF!sAT^ygw3f7PNPTwDqd%o1ta@5`NV($(51~;#razTD z%9XtsY;i1eyJa@++#rh4w|8_&ZTr?%`;13oZ%Q(BHk=S&=-~*`SYpJ3pLTXxzb|l= zA`WxhlHB_WY;JDt=NYyIHL%P6l1MAbqQ$Et>|ROtxE4hSf+~9M1IdXFB}Vty)8FrE zU0wT|cihE`7rQkZb}aEECrlfWv+tg0mgEe%&ZZY9o}4%+k{2426fSM$iOo~;`E-Kt z5E(3?$hoYcHlo4~8MN2+G76u`9k%v4J62PCU7TA!^han|^78^!lT%+~kEXNb75n~g z*@sI$7H(Dm;ODX--Vci^{Gaeo>7s=|Wk0OK*~!L#rR>MU9{&aha)ACcaL}x{ovoD% zq(xxCAR#6Ne3OL~+S$R41Zjal!0XQ&f-Jn9)h@Rvgx}%Aq888yAdN;N;Rk>n$;qnB z;_%7kio4vcMvNdnHCyrK&*y1v*{sy}$um%7C literal 0 HcmV?d00001 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 d3f8c6198..8c9906f38 100644 --- a/Hawkynt.FileFormats.Archives/README.md +++ b/Hawkynt.FileFormats.Archives/README.md @@ -141,7 +141,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 4f6a3d7bd..28f08b01f 100644 --- a/Hawkynt.FileFormats.Archives/REFERENCE.md +++ b/Hawkynt.FileFormats.Archives/REFERENCE.md @@ -28593,7 +28593,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 | | --- | --- | --- | @@ -28601,14 +28601,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` @@ -28619,6 +28621,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. | @@ -28642,17 +28645,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/docs/catalogue/reference-formats.csv b/docs/catalogue/reference-formats.csv index ab231c065..78267b932 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,,stored only; compressed members are returned as raw compressed bytes without an error (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,, From dc3047bda52b6db07e13e65f44e64f6ee97d30ed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:03:55 +0000 Subject: [PATCH 2/3] * refresh the application screenshot --- screenshots/main-window.png | Bin 66235 -> 66329 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/screenshots/main-window.png b/screenshots/main-window.png index a9ceb7f7ce233693ac240e941d4af64380209648..889bfc177c5e35513868581fa10c66cd00c9653b 100644 GIT binary patch delta 28188 zcmb?@1z1#D7q(un2x1_Olt?M5pdfuk0VO4*Lt0w8IbIn=q(r2oRHPh28irC@Qflb# zp<`%f{(bOXzZ>5BegFUbK0GqaoU`|yefD1KUGI9=F7^94=IPHL{a!(?x~e^UpH{<3 zNK>9a&YhS3@Qm;06Cvo$`C-prokX4w2xEpl-_d8GlKXUm>cyfHz4yb^IJ^%w zpQv``}(u3`Hql8*-;_4tB`p4v7d+` z1}#obCeiiK`@5lQyO5w3(LmSS%xxe2%UFJ|nbs|xvQi17di3e}`yVpeC+g6yW zDwPr$nYX+Py!v8_^YW%!%vs9caW#{xPn(%$a$Q69JVD?Fqqk8?x25Z1-$44UJ^FK{&#f1ul4PnPc?zMtyhXuLUuKz5OcXjz zpBs$e$;3x#tff@pJ-6gfNF#Kady#Fb_0UIL{qj~%nzHxGS3^|ZMs022{>eBTcBZd0 z)wUmH(=%M2JvF@TvWQA!JnKzgDC*Fo7|bSvH&4sos(W_TI6`)+H8a-LZY0uUrZq}6 zWp2D~BeemQ4!JI6?C-{&wo+F*FX1zeFLJqnIiafBIi7pd*oQlCV>A(x$9tA;BaIej zl~;4vqqub+OpduV8mj8%jH6ahA*nQaF0?MQtO&CsJmey?uT(q=VUtNxpO>&S)mCp@ z-3T;{xhumL9!lett70r?+cNsHeICM5?6=pSjulvLhl=ne+irS~Ccl1leSJNzu+YWn z(d=MOb!}jj(`vRlm({4jNJmGmWdw(wuyc@zXGqw_^W4kE7Usq_=Ei#Heo2l9lk{9U z9y>ELbdrt+Q%!+HmPGEVfw56zBsafL^K9&wFUQI5h=H#pYgc|HT%Qf!slG zZg`3UezdbKUpw>W)w*K|smLDOLu}<1Jv|*4H@BzL;>&~tdv@+jgj<-rygbIk-5vb_ zTaT46Xe!U~HoyI-vSO(lIndV^Onu&CSAK9r5VC!>Ib7_>zBstE?klm^)ru~BQhqSn zCNAc%F&i%;jZG~^5d{R?|DfjL=xi}+U=S0Ltj5vV$zQSSg6&mK3Yt?1tBeGL7b2!C zwC;JUyzYA9EUnKx+J>oS)Udh;VK;Ivr>Ut8+G)IhoR&t^x)^+;q1~gI{jU2S*zcC7W`Gvz_JW$+wzV=6E$9DXl`vCh6=s?Z(mHu7S=NpFF)(;&UWPmk1AmZwbWZsig;=%QP~mmo_E4n$yT?tZg|)h zouAHc)^@O-UAZOY!+5aH>u4q)C7Vo8OU>Yxpk3A}im7K*RZ#_dViSr#=RDult`}~g zTNZ8SG`usyqg(9OUTQnkV`n#53Q1sFAR5u3veR6FC9%MV-H z+A6)UAFdu;&dNdtHYGwhXa37`Y6})}#KdxPO)-u$KD+JyQ%wV+>LVv{+98S8gqWD@ zu^OCEZ)K4~vg|FHUEX}N*Bz>|XF{y%{j^e}-)qH=&d{kek%}g%YHBke zJV#1LK0Bf1Kp_|6TymkW(!kYT zE*R6T&ZT92&#I|#h2P#j7M*R%QqIAbm#eb;p7v+X-l(WA46Ay*`OX6=nF9!tl#~>? z={844XYR5)Grgh8Q;iX&U^RD4aju)UkxDwvca&IV4_7}urGANAN?>X9o-(059_Q`- zUamf55#>Y=2`wao<#WsbRBKZUXO_fq4Q)yMF-*^LPpa$evoP1?VLeeOAwHAa_b4y= z#OKeI!E{)kSQkk`85#eNtbxw>Np2qfDvdV^O_9sjtaf)7PbaHfMqF~^jVma)Z~tzgFxS%G%0i^h>_6dCzJCmEsbn~86ZPESv(vU~4Lj4`{r zyZcm#Gx`XuYz0ys!|b)a7N~7*UI_E<#A~O#Phk-9*!a2?wz=sd`ONSZfuK)nEmi{m zK|j;-3-_Jmj@L^|svVZVu67r6cYI!lrZ$grAqV(s{ zZaKnR&=X(DC%$F-`(X8AF4|vhZ>p*~8MU#xX-iFVum8Z_-UY2BA|jIdJ_VB^l5aCm zp%7c@y6jW#VS_&_K1g4ofTx0=RJMBdj<9tJGLRT-GUd9)j>f8YUn|eD_>E}Uy-{jo zNRm0#*3b|w?%CRCS7Foj@mE~TX_OSflqddFE|8U*B4}mJDuGA3dfRFRquRtHr#hYNE$`bsL_E+$~Ge{f`U5Li@@Lj&FMcDq%IrfQMxE*WM zi;{y!vnv?qc{>)CG&K&U+j_j&Ri+7V`k?MfK zAuM0prY51hw5muwT~~`LK**WHy1S>A6%lsgEPz#G+(q~}^iq4l{Nqpw8irl>uv8g_ zyBEXGJ6TjsURF3S7=m9c8uHw7wjRl9F#5D^zdT$%!m}@KxqSsJ9H!fq!{w3Lx)k^U zyNY4#^B}6N!E@{aj*dQxXMfAxqW)ddltYPmrgmgxqM|ZlgH}U!hnyE#YV1Z@22Z0i2Mi6oZOpB7=C}o!YH~kD z47f^X987cOeugwcVq(gkWCnk(Z)ueWctNi?HCn&4+v{wHl+Mj@L|Cq) zZAwZ?;Hl@)*A>~>*#TRJet)d~e79AZkLNAI_Ds1MXIcUM5*O~prJ>`2QQID#8ep;j zvKFxJ_u|-eQjf%_EzD`HPeV5@Xu58u6ywPESAmCWRjLm$=ZLV;DI%3r^JrS!=B+d_ zh0=Y3h0^!dwd1NPBSX%dX?gv#kB})Q7=c(=SQz2erxUQumrlBzb-X}3J~7eG#?+J} zJihXg;lwoeuGDdefU&4=CaFPhccXebN4h63Q(srlx0qx+2leylm$GPn#4goYv~#xg zigp!MvfPYfQQ%50DY^Jf>3e5emb9sdx1zS{8Uas0m zmL5CeVNVG%D@&Ir^nmsC=MCA0FJHW0#3Uyca2uf2*vKRwIyeN|z1P@U8nNjwGavC8 zs+KryI}qoDz^?!81GLK|Hrf*hc zPb*5CnH{lP+lYxR@&jms-|6ccEUkd8W@-Q6;9yU^NHJ6yW)L*6HYDumxc;QpE$dW5 zs%ao4mu_x`anX{HP)P)5`E_WMOHd<~kI16b&u!4|*-SXJm#L7s|39v!V^6^XH ztY2`5lo~$9cirWe-M;De)H4aH6UC&l5j? z$YmJz_L=jF^Vu*PgdX8oET1*1fAa|@p47IM=JE~r-ne0qWDXMI6*Bqcg>(i6U#h-p|D8a~Mb1%?={+h8D@qj~hv-Q(C8h2g6|0A@J{qp6s>yvCQ;rXJHlFCe zUPmtCxVg|l!O5#vu`rF{^VnVi4tG}y7zIYJwZ51<^J+nxN7>50pMU<=N&9p8{Zpj1 zRk@^9j{Mp4=B#{HT0#Z^0g{Fjms!}7+uIyx+RmP&7jSquo9josgm;7{plR;oWL3dB zg&fV~K}_&qSrAG_U)Ctz`ktWCx*-!Do}?}jr-8hVnXtT{;+$+N$su@&D?&?CZH4n~ zLB%miVb|HOCB`R)761r3iJP53YGjw7P$Bf!-(^Kbkr-+9Hij$?I4=&~B~(rc!uGZ& zZFJ)+?2Q3tnF^$G7KYk=4SySbPNo(D6+Jj4`MrvbKYq{M++KhY^ z>;udfDO=*_l{>vb7yzOSsil4k8yoLUT`zBgkybGeR#tY6OchhNSrP*~J0^rWGX9Et zV}s$XrdK&;r{LMYz~t4e_GoCAm9q*#qVI!~eD(qwee(-r+e>6|i5h7ksjt{2^!KhrnLghHKcFAr8l1z5qi0PG4gKVH-8=f+gBepM@e5&KtQL0VI!WPPGhs2M?3S9AkAI=LBasKZ(#OFr*tFDZ@ zWPXy$bD2dOpZE8@!V-BSWP$IE^O#Z0+tR^MIWKo+LnGqMk`m^k-=^SN*-C??B^`!wNek0P)kH#~3b|=&i^E7Aib!mX` z$fL(2bO00&AN}=1hJV}JawqYud>P&&N8D_ozN4Zv#BxZ|NZG!d@YJlBy)2iFFUVq@ zEUcqcSDT#u3B37f{w0a}nW@L{ct7P;u_nP+{vt^T{{ox-$dT}`Q$ItI!shJkXO6*d zmIV+QsyOiZ!#5lmu0fE%zrWwWMeltAaeHBSolgB#Y^;dk+A9KuSm&j7dz#|P5t zg4aR*>ui*4k=L6S4iaxAdVc5MPLEr-E?4xR76H9=YFr~rADeMM*7|T@L~q|-B$M#H zo%1mRu`<{^4-jdywtB0E(Yt%SZYKl2$Q9`D^YbfArJMqDcH{_e9+lVGv6oYvLA-HE zU)s~UcSE&fA|oIN(E}q*vyQOlNn?VW;q+Q~p#fl800m@5?@T$ikA~1?aDUPqDsjYJP@e7$v$CZy`I#op=nxC4Qsq~uq zK#Iod&lg@zRh&x!OTB!5pHbB90%CT!bou#x1MlrV=+je$*rMf*#oNty^%X-x0AoTBy*W8=_FNTROS+T z_a0GZ@OhEe!9nZ2lwkM%GNSO*pu`&%0BQ)5l2USMp*-$K`}@~NaB7^rE#=gAQy)c6v-5V-GyTZrlYpg#1`y{Y(acnF>7g|Iu) z!L$a$b!PZ1Leofx=3=5#2JqZwn@AR${mHca&9bOhRmI-Dn>sOmW?{(2&W;_iZ8Z6e zN6ZsSO-}w|!l$aLDksP3r>kF|=R)7sFQtxmXzVKAW5(v86i6h!$oU?um?xeu;Smuj zCH3e{H4p7Cuq1+aEDFrAh=qk^f$-8oN#eURk-T=Q6=e#uFK#lPrE4Z6fTs_`iui*$ z!t0-vh*m(dPdetPt1o6BO6stO>j0B&&3%gV|^Jy_H(m~M_Pd^FeLxwX`t zrk?4VodD=7sw{Dvjv9+_w&&xF9LCKkSw+I8sG@d-OU>kx&laqmG4X1Sj*i}g-L-yW z#Gvod2zkQ5YbK+gX7VojKc`qz#{y;l?j0D9EdXR7V!{dodxtA#?!I zr^KqAiI@hhx_Zl9@xCX{+76-0-TKyE4%j=}r_WDaw;C=B3JL;G4@GPXn}$&eIl>8} zcg~5Zr3h?iTgh2R^OlcS1eZIIN^L$jDHb>Z(!WbMHm}##-|unn;ll}6ZR^1@)8UL5 zO3G=;XGahA4)*7XDy4s|6E*FE8bIGq&(L@+;tUE(AQd~#h;t2W+Uvv zz-R4NDv(K20lGetP{;iL*Jv{RSF0KRX^Q-Rw~}6S$wTGyQ!~)=u$T+Hc>ypI)BrT< zxzeqVjPT!O+9qvK1TgH!&1u~|G0ecev#P1FKDD||b9P37OPC__CFgEQd5SA-!lrLzU3$ zIJ$z}u`=yUWkwAoUnZwBnM%n`!}R8=cTW;;e)L?fz#e$<>Xx|ya#Y6uxhlJ&sOQq) z(-xgqT3XyAo_1BeS5?y02*V zQ9HPUJ+MnyK2_rOxQ8hugA%W4CADYZEKN&tO*OnrJ!aF|bt-h&BV=X%VyUED9CqIM zNfKtNdt&pwg2nb*eGt*x{A~!+S>)3|&B>#%g z{GuW<$y2m^#j7IRS@@l(n3#({Fj2aLjmat&3a(rFL#2#j3<`(t8DM4!z>NKe9E1MC zj;&;%b%s;oY$bW3VgZ#D8;ch~zCh;X`H|ec`$h5@*hu6V5nh~qAm21NSmxzUt5?%q zo7}Jo-EeZlqOiatsBuJ)0F+AQdkX&gL}N*(X>P3eZGCQUzxkcApbTOkE=yNQ@92d1 zorK0(V(Uqr`w`O$+8H8XQ{!e)4#&}g(08N-b*9SWFtK(ge8e|wJv<6N z+!rT26WKH+`OH1fOg=_DPocgQl^UeKecmxR2!KQ9o|;VO&W+Z3??RHbPAASi1xuF{Y-RPDDr*`Is!$MKKSON znwg>U=e7&LZH$1}jEYCU3^?PYTeazA>d`G*%Oi{{?d(T! z*b%$ctgPOOhUJ;I1g%WV?$fbFL9y578a^L$G*?tAi)r`5Ug1-?9qV1PEkM7-h^aA9eycX{2vGA~Y zXlZJWy%ZlFK7H(`gl>=l zAT^&qJw><8x6BoQC~|_Eck?bAJypL=OK3o_=~CE}br2R6Re$)@SI)a#H7CT{MW@Z{ z?}XXKy1AeFBjp2-BD~Vvy2Zw6xXl&mcy~-!mF%=MqjNN#C*U>HY^kAvtdiKY_0uP| zTR{B2NKca1=&?=LFE=wG>$&mMk;gNeQS_g}b3W#C7+w~vg~#`aR(6xa84U=Gf%7_= z%3(<`8ak3KC#fm)>KKg5jHxA9x3L6{Z?w2og1n~Ml z;zmYxxc2{g!{PqBbUJsYvhmNc#os~;xol+l?g{L|aOSOn!c&L45j+#=uzuuTfw3((L0iMUVhm#AYzfaInNt zBU|6m*_mF%66WIDC)jk$oZQD>S&*g5uV?640s7m}#qE!Y% zRoJ8{c({0VhBC2CzN2Y56U{9xg?9bXsV%V}=HCtokL1;Vuahr!(Wrf}gyH&7(PFRA z6(`NftoXk!d1sDd+k=-BhLVK55)jjEur}X>nt!=JsLMg56OhAs#g(6MP+nG;o(gh( zux#t;DZ*4k#Vq4A*C)cw-O}2s2nbn6a|1>@aO*w1cUYKzD8qbzI`hO~G)Xj;M+K5_ zq;!*0GmMO$DpUc10s54b>c#Ivi(IQ8KTb86SbRlJMn=XiBQunNL?U_8|S7mSEA^@Z8m7-`OawGP0uYEaXc2pDm0GM*X{aJH10JtePt9q@jq-mq34$xl0pPw z@&uPVLA391ak^@`a(RTt*22Plt}#N1z@GuXI*Lrtc750h9z@lou=8Nx$IK7txQnKx-=wvUkc##*x_*Q0a@lO1+GWQQ<_ z;4bj5L)^pkLe^;|B|^rq6=S6ppAPJ|>)Blz$mYt-;Mwl{xUVs?Yb78*9?4{H9Eir3K<@H!6Ox_u+)YGhl(ZqeV&; z7yDJxH++@EK}0xEVPTvgUG+1+Y3%_Gt`%-Gt#YNfwOnm|*-pTUu z>bEi8X=^Bsc_nIvnFNkak`OXr1V2OXGBOtKBP+!i4w`(M<+FtolKh_v-%z`jrmsr_ za#I?}p4<5SdEe${%9dEaGrQJxgF8{#y7A&xOB-5_R^^L^j~uMGsV02H&M}AGq2o}u z?n&u>?BflH!LgrE5JZjNI4gE`x~Ewx3>r) zatZ|=78*3j0L@j~feS9wswZAgSNiFh^a(8;D!Z-=qX1h*W$V+3IH-X%-N|fk&zhz3 zun{}WgMzH|Nu46p=2DSkleEkU|0hvFlzr=oAyV1<)3GbT&|hf;5d2pweCmJ%GF=GA{0n|feW1Ax*JLsiwHXl|Qd=~bUKa-a|iMpl05oK}bz z@&x%L;u&)LC)bRYhlM&*OM22O9Jdk-lPY^lOpQw%rVu216%tph`d{_ic>?9YRWddT zeb1&XZ7!V>Og|%}maVQpc+UMoV`)}YlCLjxRcs5J<;s6~$3^z}t=i|Qt#KfX2oWO# z*x3owOtb9aY_Mm&BE|McqUUeCvzz+7^B*+@SmtTaTtGlTcv6xry1V;6m73KxmGrLe z+L#ejupOzDE`}kvweo!?pp?t}=EJCX%-GU!s`-|g!cVHpgF=+_<l58!1Wn zWGn1d;wqF2WKF$?#v3)YwQu9s<~J2+_4MKpB=iFX*K_$*((|RU7$8Cn!A)uSBEC}B zZS?)3;@~h?vaL5DF#jQlkiJ7?+ZU)D`uqFOzgifqD0kK4Wx5UFhs)2<1qq)&$g?d) zxh!d>x@5lWVdza8t2lPT|13>hVj?3A1$It`k$S{&3)5}fR#V=YF=qgcPz$?_286SV z6k5@G?#i#TDGVc4FMFnY@B>b&Ltje8VXb=Z{d+S!6pdwXrsq&^ZfYuZ-Yevh0&G~< zaYqNo0&Sb|7KmktMzSpbh9Iy~P@jPSunWxoPfs!d-+AJl51pVzBVZ8Ulctlrvx%}j zE;o$wMm}(ei1R9K;ERk@2UA+x*S;-Ihnwr&Rfr|C8&*qKlO!BE9utZz#=Uw$lJb5S zPG2Q@+q<|>O1^)eLa9g?AJ1SLk`%6riNAJG3AHhnY`SFXD83eOY)DOv14jPDS-#j4 z1m{N_B@7aK`OO<6FHdM8)vJ%-O%0K`xw(MZxCT@Jq*W^B`==3YjlkER{%K##5 zhm(~RJ7lJC`XtCg>~$dp9hb`PPSj0LPqidezOk=<>f!?AEo{FAvn#}IPW9$cEG$Cr z&d}!;6-+R|0F=6|qw1Yi>KVZE*5fyIkXs|+vdJelZ_g$eXtJE}KPeSi694jDx1}B1 zP)9sdSNNUhJ}qt@(sfa)sV%RC`+9ontD3C`LY^^((z!puQS~q$J6@`s^TbJ5?f>|A>aKnJQf1q1)nMAu?W1%l*0}$yHN-Oeh0)|%9qINwg%gJ zJUhB*OfxbMyuGafADNdofHL-s;X_A^ICzrPO$NGO_12ZoUsCNU&-K~e$v}O}Mi)Hn z7GC@G8RVko5-u4(SjwQGoSxS6z|QsYrakfYqfup>8_!EoOxG`f`ps{N+YP<#td)y8 zz~OBlfN6Mh!?mMBI*6W;mTGJCW{OJsAQ(^PKoKzJWA}259wa?gHE+3lrz|Eu-g|Cs z3hZ#pYp?O|uhzb@Pt(j%t|s80Zrz!eegbaHgYdkS2_%xeeUX(u2W@3#qQ2taC;?7D z36O;oLsYzgH=X!WSQ{-)>r~zMB|SkS-aRmCxZD#EF3z7{nx5W2AWmc;m%P2uN9)R? zpRQulPnq!ABUS6IDlcn>9!EqWZe(vvggHc7rpRt+EyrW&ere%SbLLXw7S_95(56p) zWM%}yw8e=)4Ijg@SGT>6niHNr#Aypcy3pkh8kw?FUvEA|5|5OvRxt=D|3pm8()AC5Rvp_#BfR2$)6|{jzP9p-qV}t66y+zpE`{CsX@CFe>C3x} zc=~}N9qb}TA`3sZFq!+H)q04`G|0en0G)}p>(?z&NiGr-WkAk2l$2$eFCu%g=Xty` zm*;^AXce3pY-48VcLy1&i;%%Z{3$VMNjIGFTa=-7>9p;;RzZTCrSd%j?0jd+eNLZf zeTm<4Rb0t9Et5Y{C3}-tH1A}RcsZ)bYgx>tmf!r4Iq5^@U;Ynaa+rJmMwlq|J^q~h z){T2r;@Kev{WK3n9|Bo7dK=C4(;{PjFLDpV$?q}aHl2`&kp1_(bok3~q3;1CSgOCX zCV!3`@158G6gUT%Y~ogdwaqS>vSUBVrKQQ9>apZwXpPm3))+k$Mxmb)dOH1jQ6 z`u;-GLAWuHN|lsi^C1W@GSng(D~ajB=8?t}WW;6H+?fkP!yw@# z2~1TkPzJNhWcu95R=4V^@BSb^FFS-#v%ROuk@qoe)SP5txYX`n;3M>(AaZVw#&062 zUZL1+Zms5WC!1o-v{oq^s2Zrn;7&ojG!y!ln!(S@a*V6Zv4h#<~60X)ZoYV8zR0P^kb!1==&;ei7g&PG~-D7{q z&iv46J3)`WDhw*#1x~`=!KteSg+gJT^jSKG*?(ihSP;n%8$SJ-Tz>KkKGDc2P4g-* z?=pIE07VY&P^M~zJ0xaeAsy}joAk&icJ&EA-Gto*uW8-5cyCO1tdOg|ws!yGt5<#N zRM%{KzqYn!6$7Q!aO-A&fyCO{8Z{&c4^{sd@ARp+xYK0LL!1(8He|`>z+|pd@?biU zp|>wuP?KEo*#7!*;@Iw1tf=?&w9OdciYG7sHg$}#pXCUn{QSiHIi;Md1nszPSeaXi znAdGo)jkjG`4;r~!on{(8DDDT0X)DxKyOR-< zHJvxx#L-E?V{p4pS~X7k&skQY`oj!`{;!C8cK?%bf}hZ385$D&XrJV`I>W19am`|X z8wk1NV@0omstu;wvX+=VoBji@$rl9$&RfIYu>wS*w2aW|I4j6x;HJPf{Po4?n-`-OcqQ=L zz2T@ky7C*jA3s*HdY6?$v;;qkBC*24H>g!J069N|r~+8Kp;et<9lrMkN#r}GV<#dv zIZo#6Y*bV$=-xEc)L;7XbLP_vi_(VHZ z8^_dDY4|S7b4sSd=H13AdcN(Z%c?s0w(~&xm}CV-MXRP#lfZr5A(RXI>$(INm2J*v zZ{!V?x?I+kXNsC%c7WN4$2|eB4r%Qpc5HpfF0FZONn0yxLs*;yH&w<%WH@#2%%Kq$ z<<#Xn3BSpLfdBuEv_3*)nC=L~M9_DF`(M;VGie}26?Wpxx1`w%l5vA?@`LI_anuUx z<$veD4P*}ni(O9tPDk;pn~a;kxI`HVMUW)Ky6Z`BhG{i5?#RlMpQKe{x;mx_b0wcR zUyxw9wJI(;tz2Lxo&g%WE{y-WWr4J}Zf0YCR+qw^DFVM_KH|WWJ_MhEB*3P(j8%-e zIw@r@`W&;UC2D@W2fxp`ofMk=M{Yp!*?-hR0L}k!U9XsLz*7Ylp#wY$&Hj7Q?gW9qfo7DTIuTjlYim zjr@iH`%M~vIC}U{5i6^zpPlEUG6=~e-F9|}nLyU*CvQ=?tnq1;yS?Y*ePc>#Y(L1G zSz20JSR}t_0wUQg5KdR7{V+2OY z)b{NdUNxV2)zwwi-4uo2ihKL}uKA-nh^u=Oq|*gXpX#zKIPvZsf!D^DFy9f7BqL&a z`7#`9`ts$=*U)u~ss?zjSNc6aFEd@k5mcqRj!&j1b5;Q%Ga&S*r!2ZH3)Ew?b@KnN zu6-4D=tK&(j9N)BBUhAr%Ga1j1XeS)J$g#LrW)UT{raj)Bw}!Iy5+Tnp<$LzSF&R& zh)J^aOEH*nl-KIJDoE)S+yrdGK8>;>fa+a#Pga>8)P8{6`03e^whIgu?#5wL#1+vM z4`}>!vnrR*!Po2Sskj{8SQcZFtzw>OMO2cR<3~Xugn~a^L9rkrZV5d&e4X*=%O&{5 zcLMP6KHw)?`@RHFeCPh6U&S>W!q<^@eNz!Lp#bOh_v}$6i4oA- zv`gSG0?vq@PT2V&Ub*2&!0mUOBR?SMf)4!#3nJ9~U;PI3xdAAEkIO}7=~GOPjoD8% zb^~b!3UQAx=g@on&3R~5JbX(ciR=dakPSdhJgu#nIyfZ_AJ5x0?@AoSEKzm?IgSrz zKQ`!z*jQUr%fs`1hcH;~cu%H5?nQaRv8fqeP?2)x|_d+o!P{ z;W4jY1ODgoy_TS~(csI(L0#ES@X8iw_^$-6whdey9 z>I9U0b8}w<=%IesPakA zWIOQDUCu-Lsf@zbRc2|oE+;BUr{zBYM71Q3(-E>16&Ai(UVteZP}IBVDsw|fnC~~N zBrf;=;4N7Zzj5KvAsHSM)99*GP~m|6;2C^g@)@YK+1qssKMmsn;Eq%N1rxH?WqlfK zd8BIkR|VUylvt@NQYSTp`sJM{B ziYLf0Ga=qXpWN#Sa5#sO{p~ghpt{j)+8R;4ME>pm?BsvnZBh&8((^&#b8OL5aBHgK z;=tA0@OmuEf^%knNSRR>EPDX9pN9PW>z5v>FJlLQxQm;sIgqQ*I>)=Tdmas>%gHX8 z+NTcJ8A!OP6DuG7g zP_M4}4`QmI!8>;}Kbp*8?i(^NFlc1yijh!EDR-7DAk_mSxh1F=4SJg72y?`YG>$dUA4vBv1B)R%wX24HE(w%u;k_mFv=n`Kjdg zhnq_S688JO<0s)!1FaE1$-m}>A%mp6@Xk1zPD&m<4^Yr`zWW1qsk^h>!DgYKG|_ZANx5c$F2<}u=<+9{eFAJqoz&QteZyK)cN4RG;!paPLO;{z(B zL3-7+t-k=^2naaCGmHI=Cb^_1`HkQql?mvte3Mbm#ous;Ea#qoMAXEBQY3eG?E>x0 z?&*0->@3=J@#*Q+nKoC<*Rim}E=RE||04$6P|P!PoaWtF$J#qOyPF(BE|^6k;APSU zDe-=d=a}f%ui-kJU+_K;{HQDn`s3o_c<4pWs~*Nv`e#*KZu%2ok zqD<&dI53!NcBD9COwMB({u&~MNV{BwB*^ym7F@3NyR_V9lbS5O26F$j*0CpQ79 zL@X##H(%7I?a7)0?3uU32r(5P6dbzS-;Q?$khLX<6dA5lnyRZeZ4Jj>aS2l6pnyF~ zk$uur`WxhWHJUc3uP`K)93a6Ud!S-S221o3_kh5`=hhc{3DT6>Xf`>N=2YAp1kY0AvZ z48%J4R*p%`g0=TmDs%IL{JcCzSxb&vC*b)23JzGhtYTwu(Y>n2nuV2JB2H1(68)a& z@bgc|r52B~Z-1}!n-a6F1%(xW^1Gaui?$nl_44JeZC=VLdbp`<6qV!Cl`y;LORV4M zG3Ymb3te+F1yS`{vVS0KdLaz_hAM6mGDXO?C3tN+b+(D0J;1`1m~ZbDm!M7&Vn$L- zn^H$Q!26l|XdO`9Z2le|zy&69R+fJurH#=dE*oh%Niy`II3Xu!@{+F1${2vnKu5V=WTjNPu-DhH|Em)ZLpveRo$;Y~S70llx19 zcdc-hk$}#bH3L~S+~-s%(V{?GnjMV$u?nkax8MU1lN}P};IM@sl|Xq6;$~Y(WLaKn z+j$Z)_2*j_JnUO@TwMj57eO+>>~ei&%hk#0l4k9+b#R`+THL3Bt8){9!> zVm$mf?hpnZ242t)BCqd(S7>*l0*FHUQ3`~HmX;_JoT#9N#y`2WD(4@S?B61Tdm}Q; z`ICrtflBe~C^v6-Oox*qEfj9=m!&Nt*qt&8(XQD2hORH zp_*&YZ0bKvJKmPK-1YWW*!>EvJONf#)4Er89aEHIOCO9e_GI&4gWb&w5=w3!HtntS zd3`O313ENqj%O$+3<4EU;Ls2ttNd0ZbdqYm8!9j55bG8Vc5)aQ#Y;dP^z`JU(_C-PgP35eo>{hiFK-{Q&2Vwt>IsO65h8gP z>$5U?6Q~iW5927=W6J`UoB=`~C|tgGi5wJ$<1`C=N>}!hn=~+PXfsB5n*pJrpp_%c zy|mjo-Ba^oo8Pb-tO1n{jWtA4;)*FZlIlW4Xd!Wyw93jv#_B_nA<>vBl5Tgu#~l;J zUQ%L@ThI#PR|IVxNq+t>{5@~kA+`lL;V7W44BUtTex-ogbOC$Fe3tHtIIMI7#J>^} zR2~6k4=mU^a>OLtDD}Ukv@!||why-K&>3P>kbXq`;xwiMW!fYz=6Q+yxrXVojc)1m z7Y_Jf9Z;xvQ0js)6SNx=F#lY2zbWcEw35w>0N5&c#3JY6a9rJK+O=|cS^$x({BJtS znrJM}VZ7#RSdyp9vH&=W29yp0ZQ(BNL08V~?3PZx+hV=!FW!|U#FWD*=K&x?C$z_G z%ze23A?|r_H!H$?VgevB*CEKmD;@rx0M{u0TS`si%FDz=32$^9sK5*+JkX^|S1-WK z_!3}J{zaXcxjf@JHeL&8OJQNPIAQzoKF1jW>*06Jjg~vZAdwytCJQB_jDl&e2Z^{Y zVY;nAsgSWua2i0lfhPoPop^BjwM|iM!HgsC?I%n*HE>QFy+HEh;apx9%8)77CSW&I zXt*A^5p)Wm0%PeQAW7HP{RkdkE4{E2Si_Uzf;OaX0^+#wS~(y>08Lwyj2eKm6hQ7H zJSPX_ayuc*g4Bst#q$!ji@VECu@K*;6R4L0g*B}CT90mRcwQF~@dRWFILBk%tmT@Z zAeE#pemxt+5(2$v!AUt61$9Tdf4y1W?>bj#Q!QN44W{yO*l>m?oJG-2=i2^K0m;Nr zJB#dA57h6{dUYsEZ033j@rO2xY=OPO$PB#M2;GLW{ zR>ow`Fr=xLZnFCl;Juj*ff8FsFhhcp-g~w|Y1ara)CAD(T(LLyHWZYcN)J^aBs+?4yz4(oiYkds~Ol^-b`a`&# zU(+rz-444su~!CYIS>KS2MB-7Qkb_7XPcUtA!ge5_ExH^JIMsp?b_|%|V|_4%Ngi_X*px6s#&b;Q3kA zAL9q*cZX~pBtiSflV#M67EU$~o%(5xhDP$)Ns0yHsPcc%aJ>eqR+4xBy?{ic=T1>; zytj85s6n}Tuq7qUAnuW2l^P=XEMcg$I}=isAq;!@+GBFI{nm&K2F+*PFj}{qUyPeE z0E+;`%0O>(vZM@vBS0AXk?_T2Jo>sVzBSjE1mbJSXAF9!JO(8gMQ!clo}Lg?ncP&3 zAYnIF>e<3Zd@%v10R@OT_QS<2Q%yf-C7`%4n(T~`Xyl~aAQR9u0vS6O9E2@M{xq00{tSC{LU+M~kh}o(+sin?yT!@u* ztdKho_$&Ois>HyN~`dmS^)_rmPdS&ceoqc@o;~^SjJS;q`0^D;`qm{9~ET z`7WCuN313*7P|}ga>jLRZR;T%E3Rl{x*3Y&L8L9uh%7bTRUu0cEh>^{<1A750=2b* zBD#(FfM_Lxv-&x1_CWl@y5LVLKnF(piEKg)5qk4tm^`(JSx-Vaz;t)+$b%E8DsVH2 z;8Y6SquCdMd3j3Z9{e+{a&mH|@h|y2aZiA4$0!EU*a7~b)7~DGa$so%lcD$TD+V&s zZyp6ANg#u(jN`*fOG*L+xlF}-hW5I4QhVGD_rC|wR5V<@{o&ZY%8C(C?>eNb=Ufuw zU%EH6vB7wj9zFESwx&&^yu6u0tTw38I4g0$B_@`HfG3@w4LJPAsjD9nn7k%w*<_)+ zK)H$bD#y%sTkR3}n;^RZYOv8RuslzF2@x$~RafR0h}ysa5$Sn?pMx00H|%MbJ4!lp zKi=2K)^m2Y*8>XI;9!L@zJI7A0_Vhl4Z?Tn+BH_JBCB8#s4CLdjHaTw_HdDCm+syO z#Oqbxs?LqyTE<_xlPr$KFo-eG->bxt^4f2cT$opE6SnGV{nY$Z@~PW?)0ANm^- zcbN0iwFuP*l?W??xZGoIe3X&utXxyycwe_$F6fB z^M8?iHk0}XB=QNVxM^nV@?TU2p#_`7m3Ums>jI#hM&{;$m7!f^-Ir>9)=zTv!$LWL zZK7JWFc|NntblqHhoFV&%z=&V3+N(?*iOtdp&1OCP=G$>x|5L{JI5Pm3o#riby%j^xzP34s zS>8wbh(CN|D=a2vyj10PHPwifv_0*nFDNXG;8v9;YzLCMKbKZP|5RJ`gM`;#6zSl& zB>h5%KUZ?1Hmt#+JG26JciS2O8WA_EAq3E*_W0fz!nwh>w`M}$|=&~NH{7STZ#~4IgKKOL}Z<^CR_GpaG#12N%p-dLX$0H8G{)`h_PkgC$ckk z#%$*Kj?VMD&hPo1>-pokp6l|5>oVh>`#bmj{eHgh&-?wlp$*Usgh!rw)I)X>)Qf=hCw~zki;3rM(2;BpIS^xfp{Z^1%;Y# z99W06G>nU7G zK=z6z?nUdE=yN!DaYskL^5o=FAqtF@p{_xn0ZjGcoH#HDMDWU5VkN5U9xSKP@d z3Lp!zU=SgkeU@clTkbvGy0z3F<0%i$52S*AW~G z30T~Ax9Lb=2c9$_Gdf7b@#H7Orz^T-XCyONx&}HroM=;84-Y3XN!Il`7D(bfAASe- z%1opCj`>SJEX7-1)pl(YyxVfN=`!8;0?$if@*O} zNsOfq6I6BRDv_|$5Ks$joNWA=Et`Gy^5S`YZWnqgZv<@d{|}YK|E-XK{m%eu{w_owv{f()w z*}eA9E-&;~f>#9b8cLp8;7JhLp>_d;8K7lQf4z9?YzqBvy&$#@U9T!6=6t|5cU1^x zRly+Du_Q#3XuSo<%|PGZnKYxWJX5Kx3t^*-E71|Wl4vx5G*Qji^QCt1rwwCv@c^wy z5C2&`=xyLC@Bh`1+eyCV4_4&22o#{l;Y$Sw0l&YJ(nrTytBRNp=YR;R8n$i;z*!=4 zZ|>K}eNQ8gUsp4oId3t*=WS)rEiHv<00#py4&kiS6GFKSFgwTX*{41P{%Pof&V;jH z@u#EfgX_u{3U5Cu&h(x7s#3{zVnLvQf7O!^bnw9qrJ~ zZQIY)Fe9^4=g@*>LULBa4c{_#Mxk~s!vFJ3c>;uv;60EwwR*Mr%SaD|f7!0g*6)Ck z@(QTGA|3(VR%{@+*}Enhn)e&qBwJ$vo&=HP-d*v$sRVoJW?9f!tEXm>b?{Eu!Xvd= zZw#wXiKo!Re=dHZf;5}IdxHlmDYUmpu%c{)^AeJdLgOuK1%+K=;kL!z=*yYc>=j|-EK zSjs;+RvweEs@RyV2gmmq-iPtf}Qgd6JZkyyebS?>Ndf z7V|3m!LUwTq55p17)6C< zS(#EW+F<-LIZat^VZ24N51hL$v2CN^&7dGkEQ!lgncB9pvgLU@cO;}vFN#}N#Uh=- zHw^aXH2gg$skHIZ+N-RdQPcXp74j;?CDH)=&FU8LRJm(z(=O^V{rtAR+FKpZArAEV zQS{I>ca!u`%SmGT<=y0)4v)%IQ_Nk~>0udCNs%pN_q=kfKqa}@!+0Zo#@>J2peh44 z$@C^7N0_Tfvl7o6#B}|-W#@o(xs3)EjE;=bKYN|qg<_xZ%4}qSWT8@B& zyTY~lw3pjCO^TA~zJ=t7p5YEL%RT^hiqSxHvo##R7F31Rx1eRtbfi1rkI3K^v5w~J|@ z*tj{|Tr%*fpsEDH(Jcd0Gl;9*GU_1+E@KCvR(Pr>QIQyNm;}@;kQ?Yp>Tv)<9uBoZkVy5+y`zYvjZ*$I#hIBuNW3h;>~K^#`Im}KytVze{l;ypS{=qg^NtG7L`TXe~m_{C5yvbpZ-z8UA)Db`CU)1>(dsH-{XMfj9yd||DS zvpiLty+i=p#D~Yv4rwQs$GZ|O?nYS!8`+00+xk8FN^-$rmuD;9xqFGWytixB3=+RF zalL73TQdmGWiDtj!I+K9Y)zZX&my)wM=&W~SyUFJN8Vp?c| z)8c%r)~E;V#H!)X4mX~fN*&FEOC7$#!5xgzXx`0TCVM8OzAVt!D;e!i#3m9J??A$I zEX`}AttaYfO-~7_#iDQE`CluHe}w}gTpX3)cU+}pX*P!gFhu6A(J=#p%fuClVrK{= zD*L8%Yxp)Y0(}M~8$U!8&bbc3BfVm0L6im@=_xStD0dALr zlk?_YU2-+*nl*t6YIbgEGj~^tduF1`_NR+0wiz;W!yYPCQ0>6Y`C7KM|0o=KDg(Zk zAMDj8&olQ>5Ld`XC8&1cZzbI_xyPR=Lq&E0V@NKsO+|fi0D)mN zJsTvW9D3`H{k53~+!mfQ*KN0!Q7GYJZ?{`2p;I7Meu|_7XbQGr|D6;PtNCZ3-ghBG zxW1X;enXLEVq(Shx@*{SIz=&I`l!XyBvWs{9;ZK>pYcbE!2Sv7+t600Gt1EN{T@mP z_`;{VW=Bl@_f(5-Yl(kNzt`#thqlbU3t-7iYXmCsL4>*EtHHq!`J>op==ofy*8h|y z5CoCDrN{uV(?z&(2f7~(xmbaV5QF^$y23MT%8SXmtAubIuQ-F*oMqLr1noJT0;dOu zV6``kni3?!En2VnpJM;V+H5@ytI|EpY569Fw_ItbCr)e1If#{$It z#V73B7PNP8A*(~L0PGAgfEU@`Qoa-#T({~0aH#l&5l>Wuw6^U^4jV#I>+KM01x>gy zsGw5_)_O&L=lnZb9H7=jwhI3QF@QXkKx(u3T%L1ZAArCbha+4k20b;S>h}4z=w%?~RkeV0tFDz=Kyp!$ z``p{}5=M;&MBm(S0Wk4?h=l~m<4ZTtca}o|M=3)&;RMIdq7$&`)C67`ME7{%R8)Y_ za6(0<7o;S^tIi9J6MP1+0Hd$0Ot<^={tw!gUxZF93)4qmQT9CXw#0UTs30Lh=}o%` zAyE0Fr6Af%4cU2Hzb3mlU|MX7PJAv7&ixOGjX-CvPQ(rrx|3R3M1nk79c0jbgatEh z;ydmyZfAK%WLM=LMp9}=3g8cEv&xFMqggr_B7P+pLEETcipJ&0}YItgk&Z6{>=Dq zu}iBE0J?x7q${~K4jP{ZfzASHYQyW(d3l(JL$99Kp;kal+o;Zdk-qckBrKc7@IBX! z*WSu|5JU3z`9@@K@@w6MSAL#&2T#=sNfbOXOjKl_(!E~*#ySO(LzGsO2_2^4z_G$5dw^J}8rnZPUpCVpJYv7q zdW;FzxcAqjdvCuGOtsu)jNqQZXL$%b5W$iD{27um>S`*e;Sh<8SrGGVjQRA5e7FXH zsrw3``*Hi=oMCwx?=hH#uF9=XXoV7 zZ!`qNUyPifL~CfUULp*@7I?RQg%nCpa;L7tBaJkErUrJ+y0`@R=>|?zWo401!!iRE z(B9KnYnx2)gE5tIG)vYl0!)LNBz=DV_MAeW$eg$QzLm zrK=EhfgTakd+q~k)N__qto9Rv}$h-Cne}yOIg^?z6 zqH99ONz%l*kVfGe+Y>GXG#MQm+>ru9r`I?$GE9UesDk($)6kMM^_ ztZIAQ2Iu6K$7-7S*$#q0-6Qba8)+#iSZBo48!yD=KbgK227x7hK1 zkByfsz#i}Qq>@2T%D)H;e6QtGMYrl)%5olQ^?Ya&;~#-mZ~xvCFzi3?zqn2Ax`aAr zF*7+$Tv%EHaqr{T27N2Zkz99ahB*P_^!s1GetKQ#pGcPZDRqK!ApaBnkmiRHh^Cf+H@QcoK;Z9-?EpR~Sg6|Hj$TBwVoFr(f zZgTX$WYPo3W6DtfvwAFI!Ow#<4v+(Y;|>z{QiCHxF}rDUXTUhE#*2pop9>%K^i60+ zKREKO^bDT4fq`CAFV$tYkMb=o*a%+t8eeYkS;6_7Q1X1;)>`dHPxe3?P`#`7Sir#>OmJ*Ks@X!HbNDN?;9{Af$K2=I)RVaL(B z5>%F=ALjDaYRbU{%7jkL2_*pFb0j87ih5jog7jbH1DEXbs;sc0D|n(pJAl{>c#Za(84L|eUgpiZsX>k~o-hFXj@ z$#YYQ!4fgw2TD!7Nj>~P#H6gbInymHjPSWLMUUm<-S+YILsKdB_Y&E_!=|ky9e0)c zN>nRfv?mTE?qdX%Kwd}lqy}UDYZu5#0HHU^dLXI5eC17U#Zs4r$UNm^Gal*7s)gQe zL;K}RBUI~S)-~rN%Ql+)95*}WPFx;JLc%k$1u>rx)N;6bi1*er=(EfaZO}d-T5d_+X)!5gu065L!G*%6GZYsA zCLqAaPqNq^74i#TZ@xKL=1aZ2IHK1#Hay1&%x`h1cQ=d7s_ei`3j78O$|1x`GWf*_ za&$yJhrys0%qs%kuO#KSvmPN&Vp$CRJoy5!qyu=kw+~fCTwGcjCcfQf<-tSDR1M7w zO8Ag3;?JhCop&0A8DwnEe__T&oZeoFf$^7^qLtjajSTQTOUC=zx7r`hi|pYl($D8? zZ98n=ES=N}EWl^eVfw7>eGq=)_^IU|e)wmU?&V9SPro%6ThxSs39VZ}{+(&@(cXM4 z0KmU)2bXk5CQLJAwiwLddSOO3ak5BcA1;_Yym~$dR5yM;UTNopvLhnWcqh&D?%`Ga zUfzxc91^pYI%VgHNms}ki^06J%HNI>rGO-Tyem?32J}a5Ru929FgG7<~JhHyfz`KF7~KhXoWr#M_|2r0X~TM%v7J z^z$Ljz!w7Beagh+?E~!`TjY)DL>G6}fVHjS0dy$L=G-o0-;pC|R4k<@HXm>z4D<}a zvp2>CsVGRUFXL7q>ST? zbTt!D&*ajT^FNOqMhJs%v6wM4!oOR+Jz?Ke=`yrFHe-OA)nhNK`Q{Xfsxy0<-I|O{ z5t{)mr?Qu^{yHQTNO~1k4WA0X-fGJ(T>nr$;>^X?>%LYr3hea;|7;D`Y7=a*wgEM_ z_xGF$2Y>(J2dz=9?{TrB_!ZHg;y!l$^3{T-`3lu!hB#!t&VGgN6~oKL@Y@gn3*jeB AMgRZ+ delta 28092 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 Date: Wed, 28 Oct 2026 06:49:48 +0100 Subject: [PATCH 3/3] # reject ambiguous firmware payloads before writing an image Adding a second named payload to Intel HEX could silently discard a file because the shared image builder kept only the first payload in filesystem enumeration order. Reject multiple non-metadata payloads, including an empty first payload, before any output is written. Tests verify creation refusal and byte-preserving add refusal. Verified all three regressions fail before the fix and pass after it, 93 focused tests, 34280 required tests with 1582 skips and no failures, and three package documentation checks. --- .../FirmwareHex/FirmwareHexPayloadTests.cs | 37 +++++++++++++++++++ .../FirmwareHexWriter.cs | 5 ++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 Compression.Tests/FirmwareHex/FirmwareHexPayloadTests.cs diff --git a/Compression.Tests/FirmwareHex/FirmwareHexPayloadTests.cs b/Compression.Tests/FirmwareHex/FirmwareHexPayloadTests.cs new file mode 100644 index 000000000..bbbc898af --- /dev/null +++ b/Compression.Tests/FirmwareHex/FirmwareHexPayloadTests.cs @@ -0,0 +1,37 @@ +using Compression.Registry; +using FileFormat.FirmwareHex; + +namespace Compression.Tests.FirmwareHex; + +[TestFixture] +public sealed class FirmwareHexPayloadTests { + [TestCase(false)] + [TestCase(true)] + public void Create_RejectsMultiplePayloadsWithoutWritingOutput(bool emptyFirst) { + var descriptor = new IntelHexFormatDescriptor(); + using var output = new MemoryStream(); + var inputs = new[] { + ArchiveInputInfo.InMemory("first.bin", emptyFirst ? [] : [1, 2, 3]), + ArchiveInputInfo.InMemory("second.bin", [9, 8, 7]), + }; + + Assert.Throws(() => + ((IArchiveCreatable)descriptor).Create(output, inputs, new FormatCreateOptions())); + Assert.That(output.Length, Is.Zero); + } + + [Test] + public void Add_RejectsAnotherPayloadWithoutChangingTheArchive() { + var descriptor = new IntelHexFormatDescriptor(); + using var archive = new MemoryStream(); + ((IArchiveCreatable)descriptor).Create(archive, + [ArchiveInputInfo.InMemory("firmware.bin", [1, 2, 3])], new FormatCreateOptions()); + var original = archive.ToArray(); + archive.Position = 0; + + Assert.Throws(() => ((IArchiveModifiable)descriptor).Add(archive, + [ArchiveInputInfo.InMemory("ADD.BIN", [9, 8, 7])])); + Assert.That(archive.ToArray(), Is.EqualTo(original)); + } + +} diff --git a/Hawkynt.FileFormats.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;