Skip to content

Commit aa02031

Browse files
feat: Support Zstandard-compressed assemblies on Android (.NET 11) (#5575)
.NET 11 compresses assemblies with Zstandard (XAZS) instead of LZ4 (XALZ). --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 8d4f28b commit aa02031

2 files changed

Lines changed: 130 additions & 11 deletions

File tree

‎src/Sentry.Android.AssemblyReader/ArchiveUtils.cs‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ namespace Sentry.Android.AssemblyReader;
22

33
internal static class ArchiveUtils
44
{
5+
internal const uint Lz4Magic = 0x5A4C4158; // 'XALZ', little-endian
6+
internal const uint ZstandardMagic = 0x535A4158; // 'XAZS', little-endian
7+
58
internal static PEReader CreatePEReader(string assemblyName, MemoryStream inputStream, DebugLogger? logger)
69
{
7-
var decompressedStream = TryDecompressLZ4(assemblyName, inputStream, logger); // Returns null if not compressed
10+
var decompressedStream = TryDecompress(assemblyName, inputStream, logger); // Returns null if not compressed
811
return new PEReader(decompressedStream ?? inputStream);
912
}
1013

@@ -18,21 +21,22 @@ internal static MemoryStream Extract(this ZipArchiveEntry zipEntry)
1821
}
1922

2023
/// <summary>
21-
/// The DLL may be LZ4 compressed, see https://github.com/xamarin/xamarin-android/pull/4686
24+
/// The DLL may be compressed, see https://github.com/xamarin/xamarin-android/pull/4686
2225
/// In particular: https://github.com/dotnet/android/blob/44c5c30d3da692c54ca27d4a41571ef20b73670f/src/Xamarin.Android.Build.Tasks/Utilities/AssemblyCompression.cs#L96-L104
2326
/// The format is:
24-
/// [ 4 byte magic header ] (XALZ)
27+
/// [ 4 byte magic header ] (XALZ for LZ4, XAZS for Zstandard)
2528
/// [ 4 byte descriptor header index ]
2629
/// [ 4 byte uncompressed payload length ]
27-
/// [rest: lz4 compressed payload]
30+
/// [rest: compressed payload]
31+
/// .NET 11 switched from LZ4 to Zstandard: https://github.com/dotnet/android/pull/11730
2832
/// </summary>
29-
/// <seealso href="https://github.com/xamarin/xamarin-android/blob/c92702619f5fabcff0ed88e09160baf9edd70f41/tools/decompress-assemblies/main.cs#L26" />
30-
private static Stream? TryDecompressLZ4(string assemblyName, MemoryStream inputStream, DebugLogger? logger)
33+
/// <seealso href="https://github.com/dotnet/android/blob/f1aecf9e6ae80fe3f3992ec1f52ef953dac7c06b/.github/skills/read-assembly-store/src/AssemblyStore/AssemblyCompression.cs" />
34+
private static Stream? TryDecompress(string assemblyName, MemoryStream inputStream, DebugLogger? logger)
3135
{
32-
const uint compressedDataMagic = 0x5A4C4158; // 'XALZ', little-endian
3336
const int payloadOffset = 12;
3437
var reader = new BinaryReader(inputStream);
35-
if (reader.ReadUInt32() != compressedDataMagic)
38+
var magic = reader.ReadUInt32();
39+
if (magic is not (Lz4Magic or ZstandardMagic))
3640
{
3741
// Restore the input stream to the beginning if we're not decompressing.
3842
inputStream.Position = 0;
@@ -42,8 +46,9 @@ internal static MemoryStream Extract(this ZipArchiveEntry zipEntry)
4246
var decompressedLength = reader.ReadInt32();
4347
Debug.Assert(inputStream.Position == payloadOffset);
4448
var inputLength = (int)(inputStream.Length - payloadOffset);
49+
var format = magic == Lz4Magic ? "LZ4" : "Zstandard";
4550

46-
logger?.Invoke(DebugLoggerLevel.Debug, "Decompressing assembly ({0} bytes uncompressed) using LZ4", decompressedLength);
51+
logger?.Invoke(DebugLoggerLevel.Debug, "Decompressing assembly ({0} bytes uncompressed) using {1}", decompressedLength, format);
4752

4853
var outputStream = new MemoryStream(decompressedLength);
4954

@@ -53,10 +58,23 @@ internal static MemoryStream Extract(this ZipArchiveEntry zipEntry)
5358

5459
var inputBuffer = inputStream is MemorySlice slice ? slice.FullBuffer : inputStream.GetBuffer();
5560
var offset = inputStream is MemorySlice memorySlice ? memorySlice.Offset + payloadOffset : payloadOffset;
56-
var decoded = LZ4Codec.Decode(inputBuffer, offset, inputLength, outputBuffer, 0, decompressedLength);
61+
int decoded;
62+
if (magic == Lz4Magic)
63+
{
64+
decoded = LZ4Codec.Decode(inputBuffer, offset, inputLength, outputBuffer, 0, decompressedLength);
65+
}
66+
else
67+
{
68+
#if NET11_0_OR_GREATER
69+
decoded = ZstandardDecoder.TryDecompress(inputBuffer.AsSpan(offset, inputLength),
70+
outputBuffer.AsSpan(0, decompressedLength), out var bytesWritten) ? bytesWritten : -1;
71+
#else
72+
throw new NotSupportedException($"Assembly {assemblyName} is Zstandard compressed, which requires .NET 11 or later");
73+
#endif
74+
}
5775
if (decoded != decompressedLength)
5876
{
59-
throw new Exception($"Failed to decompress LZ4 data of assembly {assemblyName} - decoded {decoded} instead of expected {decompressedLength} bytes");
77+
throw new Exception($"Failed to decompress {format} data of assembly {assemblyName} - decoded {decoded} instead of expected {decompressedLength} bytes");
6078
}
6179
return outputStream;
6280
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using System.Reflection.Metadata;
2+
using K4os.Compression.LZ4;
3+
4+
namespace Sentry.Android.AssemblyReader.Tests;
5+
6+
// Assembly.Location is empty on Android; AndroidAssemblyReaderTests covers decompression on device
7+
#if !ANDROID
8+
public class ArchiveUtilsTests
9+
{
10+
private static readonly byte[] Assembly = File.ReadAllBytes(typeof(ArchiveUtilsTests).Assembly.Location);
11+
12+
[Fact]
13+
public void CreatePEReader_Uncompressed_ReadsAssembly()
14+
{
15+
using var peReader = ArchiveUtils.CreatePEReader("test.dll", new MemoryStream(Assembly), null);
16+
17+
AssertIsThisAssembly(peReader);
18+
}
19+
20+
[Fact]
21+
public void CreatePEReader_Lz4_ReadsAssembly()
22+
{
23+
var compressed = new byte[LZ4Codec.MaximumOutputSize(Assembly.Length)];
24+
var length = LZ4Codec.Encode(Assembly, 0, Assembly.Length, compressed, 0, compressed.Length);
25+
26+
using var peReader = ArchiveUtils.CreatePEReader("test.dll", WithHeader(ArchiveUtils.Lz4Magic, compressed.AsSpan(0, length)), null);
27+
28+
AssertIsThisAssembly(peReader);
29+
}
30+
31+
#if NET11_0_OR_GREATER
32+
[Fact]
33+
public void CreatePEReader_Zstandard_ReadsAssembly()
34+
{
35+
var compressed = new byte[ZstandardEncoder.GetMaxCompressedLength(Assembly.Length)];
36+
ZstandardEncoder.TryCompress(Assembly, compressed, out var length).Should().BeTrue();
37+
38+
using var peReader = ArchiveUtils.CreatePEReader("test.dll", WithHeader(ArchiveUtils.ZstandardMagic, compressed.AsSpan(0, length)), null);
39+
40+
AssertIsThisAssembly(peReader);
41+
}
42+
43+
[Fact]
44+
public void CreatePEReader_CorruptZstandard_Throws()
45+
{
46+
var garbage = new byte[64];
47+
48+
var act = () => ArchiveUtils.CreatePEReader("test.dll", WithHeader(ArchiveUtils.ZstandardMagic, garbage), null);
49+
50+
act.Should().Throw<Exception>().WithMessage("*Zstandard*test.dll*");
51+
}
52+
#else
53+
[Fact]
54+
public void CreatePEReader_Zstandard_ThrowsNotSupported()
55+
{
56+
var act = () => ArchiveUtils.CreatePEReader("test.dll", WithHeader(ArchiveUtils.ZstandardMagic, new byte[64]), null);
57+
58+
act.Should().Throw<NotSupportedException>().WithMessage("*test.dll*Zstandard*");
59+
}
60+
#endif
61+
62+
[Fact]
63+
public void CreatePEReader_SliceOfLargerBuffer_ReadsAssembly()
64+
{
65+
var compressed = new byte[LZ4Codec.MaximumOutputSize(Assembly.Length)];
66+
var length = LZ4Codec.Encode(Assembly, 0, Assembly.Length, compressed, 0, compressed.Length);
67+
var entry = WithHeader(ArchiveUtils.Lz4Magic, compressed.AsSpan(0, length)).ToArray();
68+
69+
const int prefix = 100;
70+
var store = new MemoryStream();
71+
store.Write(new byte[prefix]);
72+
store.Write(entry);
73+
var slice = new ArchiveUtils.MemorySlice(store, prefix, entry.Length);
74+
75+
using var peReader = ArchiveUtils.CreatePEReader("test.dll", slice, null);
76+
77+
AssertIsThisAssembly(peReader);
78+
}
79+
80+
private static MemoryStream WithHeader(uint magic, ReadOnlySpan<byte> payload)
81+
{
82+
var stream = new MemoryStream();
83+
using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true))
84+
{
85+
writer.Write(magic);
86+
writer.Write(0u); // descriptor index
87+
writer.Write(Assembly.Length);
88+
writer.Write(payload);
89+
}
90+
stream.Position = 0;
91+
return stream;
92+
}
93+
94+
private static void AssertIsThisAssembly(PEReader peReader)
95+
{
96+
peReader.HasMetadata.Should().BeTrue();
97+
peReader.GetMetadataReader().GetAssemblyDefinition().GetAssemblyName().Name
98+
.Should().Be(typeof(ArchiveUtilsTests).Assembly.GetName().Name);
99+
}
100+
}
101+
#endif

0 commit comments

Comments
 (0)