Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 80 additions & 3 deletions Compression.Tests/Wheel/WheelTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
using Compression.Registry;
using FileFormat.Wheel;
using FileFormat.Zip;

Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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"));
}

/// <summary>The same tree twice must give the same bytes.</summary>
[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();
Expand Down
131 changes: 131 additions & 0 deletions FileFormats/FileFormat.Wheel/WheelCreator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System.Security.Cryptography;
using System.Text;
using Compression.Registry;
using FileFormat.Zip;

namespace FileFormat.Wheel;

/// <summary>Creates standards-compliant Python wheel ZIP containers.</summary>
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";

/// <summary>
/// Writes a wheel from already-named package files. A caller-supplied root
/// <c>*.dist-info/METADATA</c> and <c>WHEEL</c> 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.
/// </summary>
public static void Create(Stream output, IReadOnlyList<ArchiveInputInfo> 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<string>(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("\"", "\"\"") + "\"";
}
}
8 changes: 6 additions & 2 deletions FileFormats/FileFormat.Wheel/WheelFormatDescriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ namespace FileFormat.Wheel;
/// WHEEL fields. The underlying ZIP is read via <see cref="ZipReader"/>.
/// </para>
/// </remarks>
public sealed class WheelFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, IArchiveLayoutMap {
public sealed class WheelFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, IArchiveCreatable, IArchiveLayoutMap {

/// <inheritdoc />
public IEnumerable<DefragBlockInfo> EnumerateLayout(Stream archive) => ZipLayoutMap.Enumerate(archive);
Expand All @@ -49,7 +49,7 @@ public sealed class WheelFormatDescriptor : IFormatDescriptor, IArchiveFormatOpe

/// <inheritdoc/>
public FormatCapabilities Capabilities =>
FormatCapabilities.CanList | FormatCapabilities.CanExtract |
FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanCreate |
FormatCapabilities.CanTest | FormatCapabilities.SupportsMultipleEntries |
FormatCapabilities.SupportsDirectories;

Expand Down Expand Up @@ -164,6 +164,10 @@ public byte[] ExtractEntryToMemory(Stream archive, string entryName, string? pas
return memoryStream.ToArray();
}

/// <inheritdoc/>
public void Create(Stream output, IReadOnlyList<ArchiveInputInfo> inputs, FormatCreateOptions options)
=> WheelCreator.Create(output, inputs);

/// <summary>
/// Returns the dist-info directory name (without trailing slash). Throws
/// <see cref="InvalidDataException"/> if the ZIP doesn't contain exactly one
Expand Down
Loading
Loading