From 1fd67442e8699ab1e04eae1c5fa6c0cfdf1e364b Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Fri, 28 Aug 2026 19:21:52 +0200 Subject: [PATCH] + create standards-compliant Python wheel archives Promotes Wheel from read-only to WORM creation. A caller-supplied root *.dist-info/METADATA and WHEEL are kept as given; RECORD is regenerated from the bytes actually written, with PEP 427 URL-safe SHA-256 hashes and sizes, and its own row left empty as the spec requires. The .dist-info entries are placed last, and the reader-only metadata.ini input is ignored. A tree that carries no packaging metadata gets a minimal deterministic pair synthesized rather than being refused, which is what eight conversion pairs need. The directory takes the escaped distribution name and the metadata the dashed one. --- Compression.Tests/Wheel/WheelTests.cs | 83 ++++++++++- FileFormats/FileFormat.Wheel/WheelCreator.cs | 131 ++++++++++++++++++ .../FileFormat.Wheel/WheelFormatDescriptor.cs | 8 +- Hawkynt.FileFormats.Archives/README.md | 15 +- Hawkynt.FileFormats.FileSystems/README.md | 14 +- 5 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 FileFormats/FileFormat.Wheel/WheelCreator.cs diff --git a/Compression.Tests/Wheel/WheelTests.cs b/Compression.Tests/Wheel/WheelTests.cs index 6a80002e3..943a6076b 100644 --- a/Compression.Tests/Wheel/WheelTests.cs +++ b/Compression.Tests/Wheel/WheelTests.cs @@ -1,4 +1,5 @@ using System.Text; +using Compression.Registry; using FileFormat.Wheel; using FileFormat.Zip; @@ -44,9 +45,12 @@ private static byte[] BuildWheel() { [Test, Category("HappyPath")] public void Descriptor_Properties() { var d = new WheelFormatDescriptor(); - Assert.That(d.Id, Is.EqualTo("Wheel")); - Assert.That(d.Extensions, Contains.Item(".whl")); - Assert.That(d.MagicSignatures, Is.Empty); + Assert.Multiple(() => { + Assert.That(d.Id, Is.EqualTo("Wheel")); + Assert.That(d.Extensions, Contains.Item(".whl")); + Assert.That(d.MagicSignatures, Is.Empty); + Assert.That(d.Capabilities.HasFlag(FormatCapabilities.CanCreate), Is.True); + }); } [Test, Category("HappyPath")] @@ -83,6 +87,79 @@ public void Extract_WritesParsedMetadata() { } } + [Test, Category("HappyPath"), Category("RoundTrip")] + public void Create_GeneratesRecordAndRoundTrips() { + var metadata = "Metadata-Version: 2.1\nName: foo\nVersion: 1.2.3\n"u8.ToArray(); + var wheel = "Wheel-Version: 1.0\nGenerator: CompressionWorkbench\nRoot-Is-Purelib: true\nTag: py3-none-any\n"u8.ToArray(); + var module = "# foo\n"u8.ToArray(); + ArchiveInputInfo[] inputs = [ + ArchiveInputInfo.InMemory("foo/__init__.py", module), + ArchiveInputInfo.InMemory("foo-1.2.dist-info/METADATA", metadata), + ArchiveInputInfo.InMemory("foo-1.2.dist-info/WHEEL", wheel), + ]; + + using var output = new MemoryStream(); + var descriptor = new WheelFormatDescriptor(); + descriptor.Create(output, inputs, new FormatCreateOptions()); + + output.Position = 0; + using (var zip = new ZipReader(output, leaveOpen: true)) { + var recordEntry = zip.Entries.Single(entry => entry.FileName == "foo-1.2.dist-info/RECORD"); + var record = Encoding.UTF8.GetString(zip.ExtractEntry(recordEntry)); + Assert.Multiple(() => { + Assert.That(record, Does.Contain("foo/__init__.py,sha256=")); + Assert.That(record, Does.Contain("foo-1.2.dist-info/METADATA,sha256=")); + Assert.That(record, Does.Contain("foo-1.2.dist-info/WHEEL,sha256=")); + Assert.That(record, Does.EndWith("foo-1.2.dist-info/RECORD,,\n")); + }); + } + + output.Position = 0; + var listed = descriptor.List(output, null).Select(entry => entry.Name).ToArray(); + Assert.Multiple(() => { + Assert.That(listed, Does.Contain("foo/__init__.py")); + Assert.That(listed, Does.Contain("foo-1.2.dist-info/METADATA")); + Assert.That(listed, Does.Contain("foo-1.2.dist-info/WHEEL")); + Assert.That(listed, Does.Contain("foo-1.2.dist-info/RECORD")); + }); + } + + [Test, Category("HappyPath"), Category("RoundTrip")] + public void Create_GenericFiles_SynthesizesMinimalWheelMetadata() { + ArchiveInputInfo[] inputs = [ArchiveInputInfo.InMemory("docs/readme.txt", "hello\n"u8.ToArray())]; + using var output = new MemoryStream(); + var descriptor = new WheelFormatDescriptor(); + + descriptor.Create(output, inputs, new FormatCreateOptions()); + + output.Position = 0; + using var zip = new ZipReader(output, leaveOpen: true); + var names = zip.Entries.Select(entry => entry.FileName).ToArray(); + var metadataEntry = zip.Entries.Single(entry => entry.FileName == "compression_workbench_archive-0.dist-info/METADATA"); + var metadata = Encoding.UTF8.GetString(zip.ExtractEntry(metadataEntry)); + Assert.Multiple(() => { + Assert.That(names, Does.Contain("docs/readme.txt")); + Assert.That(names, Does.Contain("compression_workbench_archive-0.dist-info/WHEEL")); + Assert.That(names, Does.Contain("compression_workbench_archive-0.dist-info/RECORD")); + Assert.That(metadata, Does.Contain("Name: compression-workbench-archive")); + Assert.That(metadata, Does.Contain("Version: 0")); + }); + + output.Position = 0; + Assert.That(descriptor.List(output, null).Select(entry => entry.Name), Does.Contain("docs/readme.txt")); + } + + /// The same tree twice must give the same bytes. + [Test, Category("EdgeCase")] + public void Create_GenericFiles_IsDeterministic() { + ArchiveInputInfo[] inputs = [ArchiveInputInfo.InMemory("docs/readme.txt", "hello\n"u8.ToArray())]; + using var first = new MemoryStream(); + using var second = new MemoryStream(); + new WheelFormatDescriptor().Create(first, inputs, new FormatCreateOptions()); + new WheelFormatDescriptor().Create(second, inputs, new FormatCreateOptions()); + Assert.That(second.ToArray(), Is.EqualTo(first.ToArray())); + } + [Test, Category("EdgeCase")] public void List_ZipWithoutDistInfo_Throws() { using var ms = new MemoryStream(); diff --git a/FileFormats/FileFormat.Wheel/WheelCreator.cs b/FileFormats/FileFormat.Wheel/WheelCreator.cs new file mode 100644 index 000000000..34b542b21 --- /dev/null +++ b/FileFormats/FileFormat.Wheel/WheelCreator.cs @@ -0,0 +1,131 @@ +using System.Security.Cryptography; +using System.Text; +using Compression.Registry; +using FileFormat.Zip; + +namespace FileFormat.Wheel; + +/// Creates standards-compliant Python wheel ZIP containers. +internal static class WheelCreator { + // The directory name carries the escaped form of the distribution name, the + // metadata the dashed one; PEP 427 asks for exactly that pairing. + private const string SynthesizedDistribution = "compression_workbench_archive"; + private const string SynthesizedDistributionName = "compression-workbench-archive"; + private const string SynthesizedVersion = "0"; + + private const string SynthesizedMetadata = + "Metadata-Version: 2.1\n" + + "Name: " + SynthesizedDistributionName + "\n" + + "Version: " + SynthesizedVersion + "\n"; + + private const string SynthesizedWheel = + "Wheel-Version: 1.0\n" + + "Generator: CompressionWorkbench\n" + + "Root-Is-Purelib: true\n" + + "Tag: py3-none-any\n"; + + /// + /// Writes a wheel from already-named package files. A caller-supplied root + /// *.dist-info/METADATA and WHEEL are kept as they are; a tree that + /// has neither gets a minimal deterministic pair so an ordinary set of files can + /// still become a wheel a Python tool will accept. RECORD is generated from the + /// actual bytes written so hashes and sizes cannot drift from the contents. + /// + public static void Create(Stream output, IReadOnlyList inputs) { + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(inputs); + + var files = new List<(string Name, byte[] Data)>(); + foreach (var input in inputs) { + if (input.IsDirectory) + continue; + var name = NormalizeName(input.ArchiveName); + if (string.Equals(name, "metadata.ini", StringComparison.OrdinalIgnoreCase)) + continue; // synthetic reader-only entry + files.Add((name, input.ReadContent())); + } + + if (files.Count == 0) + throw new InvalidDataException("A Python wheel must contain package files and a .dist-info directory."); + + var names = new HashSet(StringComparer.Ordinal); + foreach (var (name, _) in files) + if (!names.Add(name)) + throw new InvalidDataException($"Wheel input contains duplicate archive path '{name}'."); + + string? distInfo = null; + foreach (var (name, _) in files) { + if (!name.EndsWith("/METADATA", StringComparison.Ordinal)) + continue; + var candidate = name[..^"/METADATA".Length]; + if (candidate.Contains('/') || !candidate.EndsWith(".dist-info", StringComparison.OrdinalIgnoreCase)) + continue; + if (distInfo != null) + throw new InvalidDataException("A wheel must contain exactly one root-level *.dist-info/METADATA file."); + distInfo = candidate; + } + + // An arbitrary file tree carries no packaging metadata, so a conversion into a + // wheel has to supply it. The synthesized names and contents are fixed, so the + // same tree always produces the same wheel. + if (distInfo == null) { + distInfo = SynthesizedDistribution + "-" + SynthesizedVersion + ".dist-info"; + files.Add((distInfo + "/METADATA", Encoding.UTF8.GetBytes(SynthesizedMetadata))); + names.Add(distInfo + "/METADATA"); + } + + if (!names.Contains(distInfo + "/WHEEL")) + files.Add((distInfo + "/WHEEL", Encoding.UTF8.GetBytes(SynthesizedWheel))); + + var recordName = distInfo + "/RECORD"; + files.RemoveAll(file => string.Equals(file.Name, recordName, StringComparison.Ordinal)); + + // PEP 427 recommends placing .dist-info physically at the end of the archive. + // Stable ordering also makes identical input produce identical wheel bytes. + files.Sort((a, b) => { + var aMeta = a.Name.StartsWith(distInfo + "/", StringComparison.Ordinal); + var bMeta = b.Name.StartsWith(distInfo + "/", StringComparison.Ordinal); + if (aMeta != bMeta) + return aMeta ? 1 : -1; + return StringComparer.Ordinal.Compare(a.Name, b.Name); + }); + + var record = new StringBuilder(); + foreach (var (name, data) in files) + AppendRecordRow(record, name, data); + record.Append(EscapeCsv(recordName)).Append(",,\n"); + var recordData = Encoding.UTF8.GetBytes(record.ToString()); + + using var zip = new ZipWriter(output, leaveOpen: true); + foreach (var (name, data) in files) + zip.AddEntry(name, data, ZipCompressionMethod.Deflate); + zip.AddEntry(recordName, recordData, ZipCompressionMethod.Deflate); + } + + private static string NormalizeName(string name) { + if (string.IsNullOrWhiteSpace(name)) + throw new InvalidDataException("Wheel entries require a non-empty archive path."); + var normalized = name.Replace('\\', '/').TrimStart('/'); + if (normalized.Length == 0 || normalized.EndsWith('/')) + throw new InvalidDataException($"Wheel file path '{name}' is invalid."); + foreach (var component in normalized.Split('/')) + if (component is "" or "." or "..") + throw new InvalidDataException($"Wheel file path '{name}' contains an unsafe path component."); + return normalized; + } + + private static void AppendRecordRow(StringBuilder record, string name, byte[] data) { + var hash = SHA256.HashData(data); + var encodedHash = Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + record.Append(EscapeCsv(name)) + .Append(",sha256=").Append(encodedHash) + .Append(',').Append(data.Length) + .Append('\n'); + } + + private static string EscapeCsv(string value) { + if (!value.Contains(',') && !value.Contains('"') && !value.Contains('\r') && !value.Contains('\n')) + return value; + return "\"" + value.Replace("\"", "\"\"") + "\""; + } +} diff --git a/FileFormats/FileFormat.Wheel/WheelFormatDescriptor.cs b/FileFormats/FileFormat.Wheel/WheelFormatDescriptor.cs index 53cb683cd..78250bd23 100644 --- a/FileFormats/FileFormat.Wheel/WheelFormatDescriptor.cs +++ b/FileFormats/FileFormat.Wheel/WheelFormatDescriptor.cs @@ -33,7 +33,7 @@ namespace FileFormat.Wheel; /// WHEEL fields. The underlying ZIP is read via . /// /// -public sealed class WheelFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, IArchiveLayoutMap { +public sealed class WheelFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, IArchiveCreatable, IArchiveLayoutMap { /// public IEnumerable EnumerateLayout(Stream archive) => ZipLayoutMap.Enumerate(archive); @@ -49,7 +49,7 @@ public sealed class WheelFormatDescriptor : IFormatDescriptor, IArchiveFormatOpe /// public FormatCapabilities Capabilities => - FormatCapabilities.CanList | FormatCapabilities.CanExtract | + FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanCreate | FormatCapabilities.CanTest | FormatCapabilities.SupportsMultipleEntries | FormatCapabilities.SupportsDirectories; @@ -164,6 +164,10 @@ public byte[] ExtractEntryToMemory(Stream archive, string entryName, string? pas return memoryStream.ToArray(); } + /// + public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options) + => WheelCreator.Create(output, inputs); + /// /// Returns the dist-info directory name (without trailing slash). Throws /// if the ZIP doesn't contain exactly one diff --git a/Hawkynt.FileFormats.Archives/README.md b/Hawkynt.FileFormats.Archives/README.md index 6b7a34cac..b32f2ac3b 100644 --- a/Hawkynt.FileFormats.Archives/README.md +++ b/Hawkynt.FileFormats.Archives/README.md @@ -3222,7 +3222,7 @@ Implements `IFormatDescriptor`, `IStreamFormatOperations`. #### `CscStream` -CSC: Context Stream Compression by Fu Siyuan. Format: 10-byte big-endian property header + 4-byte uncompressed size, then range-coded LZ77. Header layout: uint32 dict_size \| uint24 csc_blocksize \| uint24 raw_blocksize \| uint32 actual_size +CSC: Context Stream Compression by Fu Siyuan. Format: 10-byte big-endian property header + 4-byte uncompressed size, then range-coded LZ77. Header layout: uint32 dict_size | uint24 csc_blocksize | uint24 raw_blocksize | uint32 actual_size | Member | Signature | Summary | | --- | --- | --- | @@ -4820,7 +4820,7 @@ DEFLATE (RFC 1951) decoder for the PEtite dialect: block type 1 carries the dyna #### `PetiteUnpacker` -Container format of a PEtite-packed Win32 PE, reconstructed from the on-disk layout and the entry stub of the samples themselves. A PEtite image keeps the original section virtual addresses. The packed bytes live in one oversized section mapped at the first original section's RVA; a second section holds the untouched resources; the last section (the one the entry point falls into) holds the loader stub. Right behind the stub code sits a block table that drives unpacking:a record whose first dword has bit 31 set is a descending `rep movsd` — `{0x80000000\|dwordCount, sourceEndRva, destinationEndRva}`, 12 bytes — which lifts the packed bytes out of the way of the image that is about to be written over them;any other record is `{sourceRva, decompressedSize, destinationRva, unused}`, 16 bytes, and expands one original section in place. A zero length marks an original section without initialised data and is skipped; a zero source ends the table.The compressed streams are DEFLATE (RFC 1951) with one deviation: the stub has no fixed-Huffman tables, so block type `1` selects the dynamic Huffman tables that standard DEFLATE assigns to type `2`, and types 2 and 3 are rejected. Everything else — LSB-first bit order, the 14-bit HLIT/HDIST/HCLEN header, the code-length alphabet, the length/distance base and extra-bit tables — matches RFC 1951 byte for byte; those five tables are stored verbatim at the head of the stub section and were read from there.Code blocks are additionally stored with relative branch targets converted to absolute ones: scanning forward, every `E8`/`E9` and every `0F 80..0F 8F` has the block offset of its opcode added to the following dword, and the scan then skips the whole instruction. Reversing it subtracts the same offset again. References: `https://www.rfc-editor.org/rfc/rfc1951` — DEFLATE compressed data format`https://www.un4seen.com/petite/` — PEtite (Ian Luck / Un4seen Developments) +Container format of a PEtite-packed Win32 PE, reconstructed from the on-disk layout and the entry stub of the samples themselves. A PEtite image keeps the original section virtual addresses. The packed bytes live in one oversized section mapped at the first original section's RVA; a second section holds the untouched resources; the last section (the one the entry point falls into) holds the loader stub. Right behind the stub code sits a block table that drives unpacking:a record whose first dword has bit 31 set is a descending `rep movsd` — `{0x80000000|dwordCount, sourceEndRva, destinationEndRva}`, 12 bytes — which lifts the packed bytes out of the way of the image that is about to be written over them;any other record is `{sourceRva, decompressedSize, destinationRva, unused}`, 16 bytes, and expands one original section in place. A zero length marks an original section without initialised data and is skipped; a zero source ends the table.The compressed streams are DEFLATE (RFC 1951) with one deviation: the stub has no fixed-Huffman tables, so block type `1` selects the dynamic Huffman tables that standard DEFLATE assigns to type `2`, and types 2 and 3 are rejected. Everything else — LSB-first bit order, the 14-bit HLIT/HDIST/HCLEN header, the code-length alphabet, the length/distance base and extra-bit tables — matches RFC 1951 byte for byte; those five tables are stored verbatim at the head of the stub section and were read from there.Code blocks are additionally stored with relative branch targets converted to absolute ones: scanning forward, every `E8`/`E9` and every `0F 80..0F 8F` has the block offset of its opcode added to the following dword, and the scan then skips the whole instruction. Reversing it subtracts the same offset again. References: `https://www.rfc-editor.org/rfc/rfc1951` — DEFLATE compressed data format`https://www.un4seen.com/petite/` — PEtite (Ian Luck / Un4seen Developments) | Member | Signature | Summary | | --- | --- | --- | @@ -9003,8 +9003,8 @@ WORM writer for the NumPy NPY array serialization format (NEP 1). Emits a v1 fil | Member | Signature | Summary | | --- | --- | --- | | `DefaultDtype` | `const string DefaultDtype` | Default dtype string used when no explicit type is supplied. | -| `Write` | `static void Write(Stream output, ReadOnlySpan payload, string dtype = "|u1", string shape = null, bool fortranOrder = false)` | Writes an NPY file from `payload` with the supplied dtype/shape header. When `shape` is null, a 1-D shape matching the payload's element count is inferred from the dtype's item-size. | -| `Write` | `static void Write(Stream output, byte[] payload, string dtype = "|u1", string shape = null, bool fortranOrder = false)` | Convenience: writes an NPY file from a byte array. See span overload for parameter docs. | +| `Write` | `static void Write(Stream output, ReadOnlySpan payload, string dtype = "\|u1", string shape = null, bool fortranOrder = false)` | Writes an NPY file from `payload` with the supplied dtype/shape header. When `shape` is null, a 1-D shape matching the payload's element count is inferred from the dtype's item-size. | +| `Write` | `static void Write(Stream output, byte[] payload, string dtype = "\|u1", string shape = null, bool fortranOrder = false)` | Convenience: writes an NPY file from a byte array. See span overload for parameter docs. | #### `NpzFormatDescriptor` @@ -11909,8 +11909,8 @@ Reader and writer for the Microsoft SZDD / COMPRESS.EXE file format. SZDD uses a | --- | --- | --- | | `CompressQBasic` | `static byte[] CompressQBasic(ReadOnlySpan data)` | Compresses `data` in the older "SZ " (QBasic) COMPRESS variant and returns the result. The body is the same LZSS stream as SZDD, wrapped in the 12-byte "SZ " header (8-byte magic + little-endian u32 uncompressed length). Round-trips through `Decompress`. | | `CompressQBasic` | `static void CompressQBasic(Stream input, Stream output)` | Stream overload of `CompressQBasic`. | -| `Compress` | `static byte[] Compress(ReadOnlySpan data, char missingChar = _)` | Compresses `data` in SZDD format and returns the result as a new byte array. | -| `Compress` | `static void Compress(Stream input, Stream output, char missingChar = _)` | Compresses `input` in SZDD format and writes the result to `output`. | +| `Compress` | `static byte[] Compress(ReadOnlySpan data, char missingChar = '_')` | Compresses `data` in SZDD format and returns the result as a new byte array. | +| `Compress` | `static void Compress(Stream input, Stream output, char missingChar = '_')` | Compresses `input` in SZDD format and writes the result to `output`. | | `Decompress` | `static byte[] Decompress(ReadOnlySpan data)` | Decompresses an SZDD-encoded byte array and returns the raw data. | | `Decompress` | `static void Decompress(Stream input, Stream output)` | Decompresses an SZDD-encoded stream and writes the raw data to `output`. | | `GetMissingChar` | `static char GetMissingChar(Stream input)` | Returns the "missing character" stored in the SZDD header — the last character of the original filename extension before it was replaced with `'_'`. | @@ -13539,7 +13539,7 @@ WORM writer for Web Bundle (`.wbn`) files. Emits the canonical 10-byte CBOR-arra Descriptor for a Python wheel distribution (`.whl`) — a ZIP archive that obeys the on-disk layout mandated by PEP 427. References: `https://peps.python.org/pep-0427/` — PEP 427, the original wheel binary-package specification`https://packaging.python.org/en/latest/specifications/binary-distribution-format/` — the living binary-distribution (wheel) format spec that superseded the PEP text -Implements `IArchiveFormatOperations`, `IArchiveLayoutMap`, `IFormatDescriptor`. +Implements `IArchiveCreatable`, `IArchiveFormatOperations`, `IArchiveLayoutMap`, `IFormatDescriptor`. | Member | Signature | Summary | | --- | --- | --- | @@ -13556,6 +13556,7 @@ Implements `IArchiveFormatOperations`, `IArchiveLayoutMap`, `IFormatDescriptor`. | `MagicSignatures` | `IReadOnlyList MagicSignatures { get; }` | | | `Methods` | `IReadOnlyList Methods { get; }` | | | `TarCompressionFormatId` | `string TarCompressionFormatId { get; }` | | +| `Create` | `void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options)` | | | `EnumerateLayout` | `IEnumerable EnumerateLayout(Stream archive)` | | | `ExtractEntryToMemory` | `byte[] ExtractEntryToMemory(Stream archive, string entryName, string password)` | Native in-memory single-entry extraction routed through the bounded `OpenEntry`. | | `Extract` | `void Extract(Stream stream, string outputDir, string password, string[] files)` | | diff --git a/Hawkynt.FileFormats.FileSystems/README.md b/Hawkynt.FileFormats.FileSystems/README.md index af7ac0e76..b2bf80568 100644 --- a/Hawkynt.FileFormats.FileSystems/README.md +++ b/Hawkynt.FileFormats.FileSystems/README.md @@ -921,7 +921,7 @@ Reader for Intel HEX records (`:LLAAAATT[DD…]CC`), the long-standing flash-pro #### `SRecordReader` -Reader for Motorola S-Record files (`Stnn[aaaa\|aaaaaa\|aaaaaaaa]dd…cc`). Recognised types: S0 header, S1/S2/S3 data (16/24/32-bit address), S5/S6 record counts (informational), S7/S8/S9 termination (32/24/16-bit start addr). +Reader for Motorola S-Record files (`Stnn[aaaa|aaaaaa|aaaaaaaa]dd…cc`). Recognised types: S0 header, S1/S2/S3 data (16/24/32-bit address), S5/S6 record counts (informational), S7/S8/S9 termination (32/24/16-bit start addr). | Member | Signature | Summary | | --- | --- | --- | @@ -2820,7 +2820,7 @@ Random-access in-place modifier for BBC Micro Acorn DFS `.ssd` images. The DFS c | Member | Signature | Summary | | --- | --- | --- | -| `AddFile` | `static void AddFile(Stream image, string name, byte[] data, char directory = $, uint loadAddr = 6400, uint execAddr = 6400, bool locked = false)` | Adds a file to an existing single-sided DFS image. Caller is responsible for ensuring the name does not already exist (use `RemoveFile` first for replace-by-name semantics). The file is placed in the lowest contiguous gap large enough to hold it. | +| `AddFile` | `static void AddFile(Stream image, string name, byte[] data, char directory = '$', uint loadAddr = 6400, uint execAddr = 6400, bool locked = false)` | Adds a file to an existing single-sided DFS image. Caller is responsible for ensuring the name does not already exist (use `RemoveFile` first for replace-by-name semantics). The file is placed in the lowest contiguous gap large enough to hold it. | | `RemoveFile` | `static bool RemoveFile(Stream image, string name, bool wipeData = true)` | Removes a named file from the image. Returns true if found and removed. When `wipeData` is true, the data sectors are zeroed. | #### `BbcReader` @@ -2855,7 +2855,7 @@ Builds a fresh BBC Micro Acorn DFS `.ssd` single-sided disk image from scratch ( | `SectorSize` | `const int SectorSize` | | | `SectorsPerTrack` | `const int SectorsPerTrack` | | | `TotalSectors40` | `const int TotalSectors40` | | -| `AddFile` | `void AddFile(string name, byte[] data, char directory = $, uint loadAddr = 6400, uint execAddr = 6400, bool locked = false)` | | +| `AddFile` | `void AddFile(string name, byte[] data, char directory = '$', uint loadAddr = 6400, uint execAddr = 6400, bool locked = false)` | | | `Build` | `byte[] Build(string diskTitle = "WORMDISK", int bootOption = 0)` | Builds the complete 40-track SSD image (100 000 bytes). | ### Namespace `FileSystem.BcacheFs` @@ -3232,7 +3232,7 @@ From-scratch writer for the Commodore nibble container the `CbmNibbleReader` con | `AddFile` | `void AddFile(string name, byte[] data)` | Adds a file to the flat directory. Commodore names are PETSCII and at most 16 characters; longer names are truncated. The default file type is PRG. | | `Build` | `byte[] Build()` | Builds the G64 GCR nibble image holding all added files. | | `DecodeToD64` | `static byte[] DecodeToD64(NibbleImage image)` | Reconstructs a standard 174 848-byte D64 image from the GCR tracks of a nibble image previously parsed by `CbmNibbleReader`. Each track is rescanned for sync marks and its header/data blocks GCR-decoded back into the correct sector slots. | -| `SetDisk` | `void SetDisk(string name, char id1 = 0, char id2 = 0)` | Sets the on-disk volume name (PETSCII, ≤16 chars) and the 2-byte disk id. | +| `SetDisk` | `void SetDisk(string name, char id1 = '0', char id2 = '0')` | Sets the on-disk volume name (PETSCII, ≤16 chars) and the 2-byte disk id. | | `WriteTo` | `void WriteTo(Stream output)` | Writes the G64 image to `output`. | #### `G64FormatDescriptor` @@ -6152,7 +6152,7 @@ Implements `IDisposable`. #### `JfsWriter` -Writes a minimal IBM Journaled File System (JFS1) aggregate image with a single allocation group, one fileset, and an inline dtree root directory. Byte layout matches the on-disk structures in `linux/fs/jfs` and the `jfsutils` reference (mkfs.jfs / fsck.jfs); validated by exit-zero from `fsck.jfs -n -f -v`. All integer fields are little-endian. `pxd_t` is packed as `len_addr = (len & 0xFFFFFF) \| ((addr >> 32) << 24)`, `addr2 = addr & 0xFFFFFFFF`. Dtree slot names are UCS-2 (UTF-16 LE). Round-trips through `JfsReader`. Aggregate inode table (block 11..14, IXSIZE=16 KB) holds the AGGR_RESERVED_I (0), AGGREGATE_I (1, → AIM), BMAP_I (2, → block-allocation map), LOG_I (3), BADBLOCK_I (4) and FILESYSTEM_I (16, → fileset AIM) metadata inodes. The fileset inode table at blocks 29..32 holds FILESET_RSVD_I (0), FILESET_EXT_I (1), ROOT_I (2, dtroot inline), ACL_I (3) and user file inodes (4+). +Writes a minimal IBM Journaled File System (JFS1) aggregate image with a single allocation group, one fileset, and an inline dtree root directory. Byte layout matches the on-disk structures in `linux/fs/jfs` and the `jfsutils` reference (mkfs.jfs / fsck.jfs); validated by exit-zero from `fsck.jfs -n -f -v`. All integer fields are little-endian. `pxd_t` is packed as `len_addr = (len & 0xFFFFFF) | ((addr >> 32) << 24)`, `addr2 = addr & 0xFFFFFFFF`. Dtree slot names are UCS-2 (UTF-16 LE). Round-trips through `JfsReader`. Aggregate inode table (block 11..14, IXSIZE=16 KB) holds the AGGR_RESERVED_I (0), AGGREGATE_I (1, → AIM), BMAP_I (2, → block-allocation map), LOG_I (3), BADBLOCK_I (4) and FILESYSTEM_I (16, → fileset AIM) metadata inodes. The fileset inode table at blocks 29..32 holds FILESET_RSVD_I (0), FILESET_EXT_I (1), ROOT_I (2, dtroot inline), ACL_I (3) and user file inodes (4+). | Member | Signature | Summary | | --- | --- | --- | @@ -7535,7 +7535,7 @@ Implements `IArchiveCreatable`, `IArchiveDefragmentable`, `IArchiveFormatOperati #### `Ocfs2InPlaceModifier` -True in-place R/W modifier for OCFS2 (Oracle Cluster Filesystem 2) images produced by `Ocfs2Writer`. Performs O(touched bytes) random-access I/O against the image: only the global bitmap data block, the root directory dinode (inline dirents), the affected file dinode block, and the file's data blocks are read or written. No whole-image read or rewrite. Layout (matches `Ocfs2Writer`'s single-node geometry): 4 KB blocks = 4 KB clusters; one dinode per block.Superblock dinode at block 2; global bitmap dinode at block 3; bitmap data at block 4 (1 bit per cluster, LSB-first, bit=1 means used).Root directory dinode at block 5 (INODE01) with inline dirents in id2 after the 8-byte ocfs2_inline_data header (id2 + 8), each entry `inode(8) \| rec_len(2) \| name_len(1) \| file_type(1) \| name[]`.User files start at block 8: each gets one dinode block, plus contiguous data clusters whose run is held in a single extent record.Scope (MVP, single-node only): root-directory mutations only. Sub-directory mutation, DLM/heartbeat lockdown, multi-node cluster semantics, and root-directory B-tree splits (extent-backed root) are out of scope and throw `NotSupportedException` if encountered. +True in-place R/W modifier for OCFS2 (Oracle Cluster Filesystem 2) images produced by `Ocfs2Writer`. Performs O(touched bytes) random-access I/O against the image: only the global bitmap data block, the root directory dinode (inline dirents), the affected file dinode block, and the file's data blocks are read or written. No whole-image read or rewrite. Layout (matches `Ocfs2Writer`'s single-node geometry): 4 KB blocks = 4 KB clusters; one dinode per block.Superblock dinode at block 2; global bitmap dinode at block 3; bitmap data at block 4 (1 bit per cluster, LSB-first, bit=1 means used).Root directory dinode at block 5 (INODE01) with inline dirents in id2 after the 8-byte ocfs2_inline_data header (id2 + 8), each entry `inode(8) | rec_len(2) | name_len(1) | file_type(1) | name[]`.User files start at block 8: each gets one dinode block, plus contiguous data clusters whose run is held in a single extent record.Scope (MVP, single-node only): root-directory mutations only. Sub-directory mutation, DLM/heartbeat lockdown, multi-node cluster semantics, and root-directory B-tree splits (extent-backed root) are out of scope and throw `NotSupportedException` if encountered. | Member | Signature | Summary | | --- | --- | --- | @@ -10429,7 +10429,7 @@ Builds a fresh ZX Spectrum `.scl` TR-DOS archive from scratch (WORM). | --- | --- | --- | | `ZxSclWriter` | `ZxSclWriter()` | | | `MaxEntries` | `const int MaxEntries` | TR-DOS hard cap: headers are stored in a single 256-entry directory-like table. | -| `AddFile` | `void AddFile(string name, byte[] data, char fileType = C, ushort param1 = 32768, ushort param2 = 0)` | | +| `AddFile` | `void AddFile(string name, byte[] data, char fileType = 'C', ushort param1 = 32768, ushort param2 = 0)` | | | `Build` | `byte[] Build()` | |