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
40 changes: 29 additions & 11 deletions src/Sentry.Android.AssemblyReader/ArchiveUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

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

/// <summary>
/// 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
/// </summary>
/// <seealso href="https://github.com/xamarin/xamarin-android/blob/c92702619f5fabcff0ed88e09160baf9edd70f41/tools/decompress-assemblies/main.cs#L26" />
private static Stream? TryDecompressLZ4(string assemblyName, MemoryStream inputStream, DebugLogger? logger)
/// <seealso href="https://github.com/dotnet/android/blob/f1aecf9e6ae80fe3f3992ec1f52ef953dac7c06b/.github/skills/read-assembly-store/src/AssemblyStore/AssemblyCompression.cs" />
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;
Expand All @@ -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);

Expand All @@ -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;
}
Expand Down
101 changes: 101 additions & 0 deletions test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs
Original file line number Diff line number Diff line change
@@ -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<Exception>().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<NotSupportedException>().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<byte> 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
Loading