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