diff --git a/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs b/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs
index d3b2f53e46..fd6c477813 100644
--- a/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs
+++ b/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs
@@ -2,9 +2,12 @@ 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 = 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 +21,22 @@ 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 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 +46,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,10 +58,23 @@ 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);
+ 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 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;
}
diff --git a/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs b/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs
new file mode 100644
index 0000000000..b7a21578ee
--- /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;
+
+// Assembly.Location is empty on Android; AndroidAssemblyReaderTests covers decompression on device
+#if !ANDROID
+public class ArchiveUtilsTests
+{
+ 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(ArchiveUtils.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(ArchiveUtils.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(ArchiveUtils.ZstandardMagic, garbage), null);
+
+ act.Should().Throw().WithMessage("*Zstandard*test.dll*");
+ }
+#else
+ [Fact]
+ public void CreatePEReader_Zstandard_ThrowsNotSupported()
+ {
+ var act = () => ArchiveUtils.CreatePEReader("test.dll", WithHeader(ArchiveUtils.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(ArchiveUtils.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);
+ }
+}
+#endif