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