From 203a8e05b3d40c51678ae5217d572cb9eeb50aca Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 14 Sep 2026 13:16:50 +1200 Subject: [PATCH 1/4] feat: Decompress Zstandard-compressed Android assemblies .NET 11 compresses assemblies with Zstandard (XAZS) instead of LZ4 (XALZ). The 12-byte header is unchanged, so only the magic and the codec differ. Zstandard uses the BCL ZstandardDecoder on net11.0; the net10.0 build throws NotSupportedException, since only .NET 11 apps produce XAZS. Refs #5346 Co-Authored-By: Claude Opus 5 --- .../ArchiveUtils.cs | 36 +++++-- .../ArchiveUtilsTests.cs | 101 ++++++++++++++++++ 2 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs diff --git a/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs b/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs index d3b2f53e46..f189559c14 100644 --- a/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs +++ b/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs @@ -4,7 +4,7 @@ internal static class ArchiveUtils { internal static PEReader CreatePEReader(string assemblyName, MemoryStream inputStream, DebugLogger? logger) { - var decompressedStream = TryDecompressLZ4(assemblyName, inputStream, logger); // Returns null if not compressed + var decompressedStream = TryDecompress(assemblyName, inputStream, logger); // Returns null if not compressed return new PEReader(decompressedStream ?? inputStream); } @@ -18,21 +18,24 @@ internal static MemoryStream Extract(this ZipArchiveEntry zipEntry) } /// - /// The DLL may be LZ4 compressed, see https://github.com/xamarin/xamarin-android/pull/4686 + /// The DLL may be compressed, see https://github.com/xamarin/xamarin-android/pull/4686 /// In particular: https://github.com/dotnet/android/blob/44c5c30d3da692c54ca27d4a41571ef20b73670f/src/Xamarin.Android.Build.Tasks/Utilities/AssemblyCompression.cs#L96-L104 /// The format is: - /// [ 4 byte magic header ] (XALZ) + /// [ 4 byte magic header ] (XALZ for LZ4, XAZS for Zstandard) /// [ 4 byte descriptor header index ] /// [ 4 byte uncompressed payload length ] - /// [rest: lz4 compressed payload] + /// [rest: compressed payload] + /// .NET 11 switched from LZ4 to Zstandard: https://github.com/dotnet/android/pull/11730 /// - /// - private static Stream? TryDecompressLZ4(string assemblyName, MemoryStream inputStream, DebugLogger? logger) + /// + private static Stream? TryDecompress(string assemblyName, MemoryStream inputStream, DebugLogger? logger) { - const uint compressedDataMagic = 0x5A4C4158; // 'XALZ', little-endian + const uint lz4Magic = 0x5A4C4158; // 'XALZ', little-endian + const uint zstandardMagic = 0x535A4158; // 'XAZS', little-endian const int payloadOffset = 12; var reader = new BinaryReader(inputStream); - if (reader.ReadUInt32() != compressedDataMagic) + var magic = reader.ReadUInt32(); + if (magic is not (lz4Magic or zstandardMagic)) { // Restore the input stream to the beginning if we're not decompressing. inputStream.Position = 0; @@ -42,8 +45,9 @@ internal static MemoryStream Extract(this ZipArchiveEntry zipEntry) var decompressedLength = reader.ReadInt32(); Debug.Assert(inputStream.Position == payloadOffset); var inputLength = (int)(inputStream.Length - payloadOffset); + var format = magic == lz4Magic ? "LZ4" : "Zstandard"; - logger?.Invoke(DebugLoggerLevel.Debug, "Decompressing assembly ({0} bytes uncompressed) using LZ4", decompressedLength); + logger?.Invoke(DebugLoggerLevel.Debug, "Decompressing assembly ({0} bytes uncompressed) using {1}", decompressedLength, format); var outputStream = new MemoryStream(decompressedLength); @@ -53,14 +57,24 @@ internal static MemoryStream Extract(this ZipArchiveEntry zipEntry) var inputBuffer = inputStream is MemorySlice slice ? slice.FullBuffer : inputStream.GetBuffer(); var offset = inputStream is MemorySlice memorySlice ? memorySlice.Offset + payloadOffset : payloadOffset; - var decoded = LZ4Codec.Decode(inputBuffer, offset, inputLength, outputBuffer, 0, decompressedLength); + var decoded = magic == lz4Magic + ? LZ4Codec.Decode(inputBuffer, offset, inputLength, outputBuffer, 0, decompressedLength) + : DecompressZstandard(assemblyName, inputBuffer.AsSpan(offset, inputLength), outputBuffer.AsSpan(0, decompressedLength)); if (decoded != decompressedLength) { - throw new Exception($"Failed to decompress LZ4 data of assembly {assemblyName} - decoded {decoded} instead of expected {decompressedLength} bytes"); + throw new Exception($"Failed to decompress {format} data of assembly {assemblyName} - decoded {decoded} instead of expected {decompressedLength} bytes"); } return outputStream; } +#if NET11_0_OR_GREATER + private static int DecompressZstandard(string assemblyName, ReadOnlySpan source, Span destination) => + ZstandardDecoder.TryDecompress(source, destination, out var bytesWritten) ? bytesWritten : -1; +#else + private static int DecompressZstandard(string assemblyName, ReadOnlySpan source, Span destination) => + throw new NotSupportedException($"Assembly {assemblyName} is Zstandard compressed, which requires .NET 11 or later"); +#endif + // Allows consumer to access the underlying buffer even if the MemoryStream is created as a slice over another. // Plain MemoryStream would throw "MemoryStream's internal buffer cannot be accessed." internal class MemorySlice(MemoryStream other, int offset, int size) diff --git a/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs b/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs new file mode 100644 index 0000000000..bfd52a7ee0 --- /dev/null +++ b/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs @@ -0,0 +1,101 @@ +using System.Reflection.Metadata; +using K4os.Compression.LZ4; + +namespace Sentry.Android.AssemblyReader.Tests; + +public class ArchiveUtilsTests +{ + private const uint Lz4Magic = 0x5A4C4158; // 'XALZ' + private const uint ZstandardMagic = 0x535A4158; // 'XAZS' + + private static readonly byte[] Assembly = File.ReadAllBytes(typeof(ArchiveUtilsTests).Assembly.Location); + + [Fact] + public void CreatePEReader_Uncompressed_ReadsAssembly() + { + using var peReader = ArchiveUtils.CreatePEReader("test.dll", new MemoryStream(Assembly), null); + + AssertIsThisAssembly(peReader); + } + + [Fact] + public void CreatePEReader_Lz4_ReadsAssembly() + { + var compressed = new byte[LZ4Codec.MaximumOutputSize(Assembly.Length)]; + var length = LZ4Codec.Encode(Assembly, 0, Assembly.Length, compressed, 0, compressed.Length); + + using var peReader = ArchiveUtils.CreatePEReader("test.dll", WithHeader(Lz4Magic, compressed.AsSpan(0, length)), null); + + AssertIsThisAssembly(peReader); + } + +#if NET11_0_OR_GREATER + [Fact] + public void CreatePEReader_Zstandard_ReadsAssembly() + { + var compressed = new byte[ZstandardEncoder.GetMaxCompressedLength(Assembly.Length)]; + ZstandardEncoder.TryCompress(Assembly, compressed, out var length).Should().BeTrue(); + + using var peReader = ArchiveUtils.CreatePEReader("test.dll", WithHeader(ZstandardMagic, compressed.AsSpan(0, length)), null); + + AssertIsThisAssembly(peReader); + } + + [Fact] + public void CreatePEReader_CorruptZstandard_Throws() + { + var garbage = new byte[64]; + + var act = () => ArchiveUtils.CreatePEReader("test.dll", WithHeader(ZstandardMagic, garbage), null); + + act.Should().Throw().WithMessage("*Zstandard*test.dll*"); + } +#else + [Fact] + public void CreatePEReader_Zstandard_ThrowsNotSupported() + { + var act = () => ArchiveUtils.CreatePEReader("test.dll", WithHeader(ZstandardMagic, new byte[64]), null); + + act.Should().Throw().WithMessage("*test.dll*Zstandard*"); + } +#endif + + [Fact] + public void CreatePEReader_SliceOfLargerBuffer_ReadsAssembly() + { + var compressed = new byte[LZ4Codec.MaximumOutputSize(Assembly.Length)]; + var length = LZ4Codec.Encode(Assembly, 0, Assembly.Length, compressed, 0, compressed.Length); + var entry = WithHeader(Lz4Magic, compressed.AsSpan(0, length)).ToArray(); + + const int prefix = 100; + var store = new MemoryStream(); + store.Write(new byte[prefix]); + store.Write(entry); + var slice = new ArchiveUtils.MemorySlice(store, prefix, entry.Length); + + using var peReader = ArchiveUtils.CreatePEReader("test.dll", slice, null); + + AssertIsThisAssembly(peReader); + } + + private static MemoryStream WithHeader(uint magic, ReadOnlySpan payload) + { + var stream = new MemoryStream(); + using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + writer.Write(magic); + writer.Write(0u); // descriptor index + writer.Write(Assembly.Length); + writer.Write(payload); + } + stream.Position = 0; + return stream; + } + + private static void AssertIsThisAssembly(PEReader peReader) + { + peReader.HasMetadata.Should().BeTrue(); + peReader.GetMetadataReader().GetAssemblyDefinition().GetAssemblyName().Name + .Should().Be(typeof(ArchiveUtilsTests).Assembly.GetName().Name); + } +} From 672ae6ba340305f2aa4a3c260bbedc10f98122d9 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 14 Sep 2026 18:38:36 +1200 Subject: [PATCH 2/4] test: Don't compile ArchiveUtilsTests for Android Assembly.Location is empty on Android, so the static initializer threw and every test in the class failed on the device runs. Co-Authored-By: Claude Opus 5 --- test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs b/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs index bfd52a7ee0..6c4888fecb 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs +++ b/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs @@ -3,6 +3,9 @@ namespace Sentry.Android.AssemblyReader.Tests; +// Assembly.Location is empty on Android, where assemblies load from the APK. The device run covers +// decompression through AndroidAssemblyReaderTests.ReadsAssembly instead. +#if !ANDROID public class ArchiveUtilsTests { private const uint Lz4Magic = 0x5A4C4158; // 'XALZ' @@ -99,3 +102,4 @@ private static void AssertIsThisAssembly(PEReader peReader) .Should().Be(typeof(ArchiveUtilsTests).Assembly.GetName().Name); } } +#endif From 027018ea4ad27b7f0a17ce7bc98923a36eb9f665 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 15 Sep 2026 16:52:14 +1200 Subject: [PATCH 3/4] chore: Trim comments Co-Authored-By: Claude Opus 5 --- test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs b/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs index 6c4888fecb..bbda0e5370 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs +++ b/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs @@ -3,8 +3,7 @@ namespace Sentry.Android.AssemblyReader.Tests; -// Assembly.Location is empty on Android, where assemblies load from the APK. The device run covers -// decompression through AndroidAssemblyReaderTests.ReadsAssembly instead. +// Assembly.Location is empty on Android; AndroidAssemblyReaderTests covers decompression on device #if !ANDROID public class ArchiveUtilsTests { From 69f6718b6ac70df44895de4c7cb518d2294bcb13 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 16 Sep 2026 16:50:54 +1200 Subject: [PATCH 4/4] refactor: Address review feedback on Zstandard decompression Magic numbers become internal constants the tests share, and the DecompressZstandard helper is inlined - its assemblyName parameter was unused on .NET 11. Co-Authored-By: Claude Opus 5 --- .../ArchiveUtils.cs | 34 +++++++++++-------- .../ArchiveUtilsTests.cs | 13 +++---- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs b/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs index f189559c14..fd6c477813 100644 --- a/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs +++ b/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs @@ -2,6 +2,9 @@ namespace Sentry.Android.AssemblyReader; internal static class ArchiveUtils { + internal const uint Lz4Magic = 0x5A4C4158; // 'XALZ', little-endian + internal const uint ZstandardMagic = 0x535A4158; // 'XAZS', little-endian + internal static PEReader CreatePEReader(string assemblyName, MemoryStream inputStream, DebugLogger? logger) { var decompressedStream = TryDecompress(assemblyName, inputStream, logger); // Returns null if not compressed @@ -30,12 +33,10 @@ internal static MemoryStream Extract(this ZipArchiveEntry zipEntry) /// private static Stream? TryDecompress(string assemblyName, MemoryStream inputStream, DebugLogger? logger) { - const uint lz4Magic = 0x5A4C4158; // 'XALZ', little-endian - const uint zstandardMagic = 0x535A4158; // 'XAZS', little-endian const int payloadOffset = 12; var reader = new BinaryReader(inputStream); var magic = reader.ReadUInt32(); - if (magic is not (lz4Magic or zstandardMagic)) + if (magic is not (Lz4Magic or ZstandardMagic)) { // Restore the input stream to the beginning if we're not decompressing. inputStream.Position = 0; @@ -45,7 +46,7 @@ internal static MemoryStream Extract(this ZipArchiveEntry zipEntry) var decompressedLength = reader.ReadInt32(); Debug.Assert(inputStream.Position == payloadOffset); var inputLength = (int)(inputStream.Length - payloadOffset); - var format = magic == lz4Magic ? "LZ4" : "Zstandard"; + var format = magic == Lz4Magic ? "LZ4" : "Zstandard"; logger?.Invoke(DebugLoggerLevel.Debug, "Decompressing assembly ({0} bytes uncompressed) using {1}", decompressedLength, format); @@ -57,9 +58,20 @@ internal static MemoryStream Extract(this ZipArchiveEntry zipEntry) var inputBuffer = inputStream is MemorySlice slice ? slice.FullBuffer : inputStream.GetBuffer(); var offset = inputStream is MemorySlice memorySlice ? memorySlice.Offset + payloadOffset : payloadOffset; - var decoded = magic == lz4Magic - ? LZ4Codec.Decode(inputBuffer, offset, inputLength, outputBuffer, 0, decompressedLength) - : DecompressZstandard(assemblyName, inputBuffer.AsSpan(offset, inputLength), outputBuffer.AsSpan(0, decompressedLength)); + int decoded; + if (magic == Lz4Magic) + { + decoded = LZ4Codec.Decode(inputBuffer, offset, inputLength, outputBuffer, 0, decompressedLength); + } + else + { +#if NET11_0_OR_GREATER + decoded = ZstandardDecoder.TryDecompress(inputBuffer.AsSpan(offset, inputLength), + outputBuffer.AsSpan(0, decompressedLength), out var bytesWritten) ? bytesWritten : -1; +#else + throw new NotSupportedException($"Assembly {assemblyName} is Zstandard compressed, which requires .NET 11 or later"); +#endif + } if (decoded != decompressedLength) { throw new Exception($"Failed to decompress {format} data of assembly {assemblyName} - decoded {decoded} instead of expected {decompressedLength} bytes"); @@ -67,14 +79,6 @@ internal static MemoryStream Extract(this ZipArchiveEntry zipEntry) return outputStream; } -#if NET11_0_OR_GREATER - private static int DecompressZstandard(string assemblyName, ReadOnlySpan source, Span destination) => - ZstandardDecoder.TryDecompress(source, destination, out var bytesWritten) ? bytesWritten : -1; -#else - private static int DecompressZstandard(string assemblyName, ReadOnlySpan source, Span destination) => - throw new NotSupportedException($"Assembly {assemblyName} is Zstandard compressed, which requires .NET 11 or later"); -#endif - // Allows consumer to access the underlying buffer even if the MemoryStream is created as a slice over another. // Plain MemoryStream would throw "MemoryStream's internal buffer cannot be accessed." internal class MemorySlice(MemoryStream other, int offset, int size) diff --git a/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs b/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs index bbda0e5370..b7a21578ee 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs +++ b/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs @@ -7,9 +7,6 @@ namespace Sentry.Android.AssemblyReader.Tests; #if !ANDROID public class ArchiveUtilsTests { - private const uint Lz4Magic = 0x5A4C4158; // 'XALZ' - private const uint ZstandardMagic = 0x535A4158; // 'XAZS' - private static readonly byte[] Assembly = File.ReadAllBytes(typeof(ArchiveUtilsTests).Assembly.Location); [Fact] @@ -26,7 +23,7 @@ public void CreatePEReader_Lz4_ReadsAssembly() var compressed = new byte[LZ4Codec.MaximumOutputSize(Assembly.Length)]; var length = LZ4Codec.Encode(Assembly, 0, Assembly.Length, compressed, 0, compressed.Length); - using var peReader = ArchiveUtils.CreatePEReader("test.dll", WithHeader(Lz4Magic, compressed.AsSpan(0, length)), null); + using var peReader = ArchiveUtils.CreatePEReader("test.dll", WithHeader(ArchiveUtils.Lz4Magic, compressed.AsSpan(0, length)), null); AssertIsThisAssembly(peReader); } @@ -38,7 +35,7 @@ public void CreatePEReader_Zstandard_ReadsAssembly() var compressed = new byte[ZstandardEncoder.GetMaxCompressedLength(Assembly.Length)]; ZstandardEncoder.TryCompress(Assembly, compressed, out var length).Should().BeTrue(); - using var peReader = ArchiveUtils.CreatePEReader("test.dll", WithHeader(ZstandardMagic, compressed.AsSpan(0, length)), null); + using var peReader = ArchiveUtils.CreatePEReader("test.dll", WithHeader(ArchiveUtils.ZstandardMagic, compressed.AsSpan(0, length)), null); AssertIsThisAssembly(peReader); } @@ -48,7 +45,7 @@ public void CreatePEReader_CorruptZstandard_Throws() { var garbage = new byte[64]; - var act = () => ArchiveUtils.CreatePEReader("test.dll", WithHeader(ZstandardMagic, garbage), null); + var act = () => ArchiveUtils.CreatePEReader("test.dll", WithHeader(ArchiveUtils.ZstandardMagic, garbage), null); act.Should().Throw().WithMessage("*Zstandard*test.dll*"); } @@ -56,7 +53,7 @@ public void CreatePEReader_CorruptZstandard_Throws() [Fact] public void CreatePEReader_Zstandard_ThrowsNotSupported() { - var act = () => ArchiveUtils.CreatePEReader("test.dll", WithHeader(ZstandardMagic, new byte[64]), null); + var act = () => ArchiveUtils.CreatePEReader("test.dll", WithHeader(ArchiveUtils.ZstandardMagic, new byte[64]), null); act.Should().Throw().WithMessage("*test.dll*Zstandard*"); } @@ -67,7 +64,7 @@ public void CreatePEReader_SliceOfLargerBuffer_ReadsAssembly() { var compressed = new byte[LZ4Codec.MaximumOutputSize(Assembly.Length)]; var length = LZ4Codec.Encode(Assembly, 0, Assembly.Length, compressed, 0, compressed.Length); - var entry = WithHeader(Lz4Magic, compressed.AsSpan(0, length)).ToArray(); + var entry = WithHeader(ArchiveUtils.Lz4Magic, compressed.AsSpan(0, length)).ToArray(); const int prefix = 100; var store = new MemoryStream();