From 593c3f6c7011a06410b8057f4fdbf1078a792758 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 14 Sep 2026 12:42:05 +1200 Subject: [PATCH 01/16] feat: Accept Android assembly store v4 versions and read content_id header v4 stores (CoreCLR, .NET 11) add a content_id field to the header, so the index starts 8 bytes later. Header.NativeSize is now derived from the format number rather than being a constant. Refs #5454 Co-Authored-By: Claude Opus 5 --- .../V2/StoreReader.Classes.cs | 12 ++- .../V2/StoreReader.cs | 33 ++++---- .../StoreReaderTests.cs | 83 +++++++++++++++++++ 3 files changed, 111 insertions(+), 17 deletions(-) diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs index 2776a21081..09ebb4265e 100644 --- a/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs @@ -2,6 +2,8 @@ * Adapted from https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/tools/assembly-store-reader-mk2/AssemblyStore/StoreReader_V2.Classes.cs * Updated from https://github.com/dotnet/android/blob/64018e13e53cec7246e54866b520d3284de344e0/tools/assembly-store-reader-mk2/AssemblyStore/StoreReader_V2.Classes.cs * - Adding support for AssemblyStore v3 format that shipped in .NET 10 (https://github.com/dotnet/android/pull/10249) + * Updated from https://github.com/dotnet/android/blob/f1aecf9e6ae80fe3f3992ec1f52ef953dac7c06b/.github/skills/read-assembly-store/src/AssemblyStore/StoreReader_V2.Classes.cs + * - Adding support for AssemblyStore v4 format (CoreCLR) that ships in .NET 11 * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) */ @@ -11,8 +13,6 @@ internal partial class StoreReader { private sealed class Header { - public const uint NativeSize = 5 * sizeof(uint); - public readonly uint magic; public readonly uint version; public readonly uint entry_count; @@ -21,13 +21,19 @@ private sealed class Header // Index size in bytes public readonly uint index_size; - public Header(uint magic, uint version, uint entry_count, uint index_entry_count, uint index_size) + // Only present in v4+ stores + public readonly ulong content_id; + + public uint NativeSize => 5 * sizeof(uint) + ((version & ASSEMBLY_STORE_FORMAT_NUMBER_MASK) >= 4 ? sizeof(ulong) : 0u); + + public Header(uint magic, uint version, uint entry_count, uint index_entry_count, uint index_size, ulong content_id) { this.magic = magic; this.version = version; this.entry_count = entry_count; this.index_entry_count = index_entry_count; this.index_size = index_size; + this.content_id = content_id; } } diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs index e023893e1f..bb979ab60c 100644 --- a/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs @@ -2,6 +2,8 @@ * Adapted from https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/tools/assembly-store-reader-mk2/AssemblyStore/StoreReader_V2.cs * Updated from https://github.com/dotnet/android/blob/64018e13e53cec7246e54866b520d3284de344e0/tools/assembly-store-reader-mk2/AssemblyStore/StoreReader_V2.cs * - Adding support for AssemblyStore v3 format that shipped in .NET 10 (https://github.com/dotnet/android/pull/10249) + * Updated from https://github.com/dotnet/android/blob/f1aecf9e6ae80fe3f3992ec1f52ef953dac7c06b/.github/skills/read-assembly-store/src/AssemblyStore/StoreReader_V2.cs + * - Adding support for AssemblyStore v4 format (CoreCLR) that ships in .NET 11 * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) */ @@ -10,15 +12,13 @@ namespace Sentry.Android.AssemblyReader.V2; internal partial class StoreReader : AssemblyStoreReader { // Bit 31 is set for 64-bit platforms, cleared for the 32-bit ones - // Each .NET release bumps the assembly store format: v2 in .NET 9, v3 in .NET 10, v4 in - // .NET 11. v4 changes the header/index layout as well as the version number, so it needs - // the upstream reader changes ported, not just a new constant - bumping the version alone - // gets past IsSupported() and then fails with EndOfStreamException in Prepare(). - // Until that port lands, .NET 11 stores are reported as unsupported, which - // AndroidHelpers.GetAndroidAssemblyReader handles by logging and returning null. - private const uint ASSEMBLY_STORE_FORMAT_VERSION_64BIT = 0x80000003; // Must match the ASSEMBLY_STORE_FORMAT_VERSION native constant - private const uint ASSEMBLY_STORE_FORMAT_VERSION_32BIT = 0x00000003; + // Each .NET release bumps the assembly store format: v3 in .NET 10, v4 (CoreCLR) in .NET 11. + private const uint ASSEMBLY_STORE_FORMAT_VERSION_64BIT_V3 = 0x80000003; + private const uint ASSEMBLY_STORE_FORMAT_VERSION_32BIT_V3 = 0x00000003; + private const uint ASSEMBLY_STORE_FORMAT_VERSION_CORECLR_64BIT_V4 = 0x80000004; // Must match the ASSEMBLY_STORE_FORMAT_VERSION native constant + private const uint ASSEMBLY_STORE_FORMAT_VERSION_CORECLR_32BIT_V4 = 0x00000004; private const uint ASSEMBLY_STORE_FORMAT_VERSION_MASK = 0xF0000000; + private const uint ASSEMBLY_STORE_FORMAT_NUMBER_MASK = 0x0000FFFF; private const uint ASSEMBLY_STORE_ABI_AARCH64 = 0x00010000; private const uint ASSEMBLY_STORE_ABI_ARM = 0x00020000; private const uint ASSEMBLY_STORE_ABI_X64 = 0x00030000; @@ -89,10 +89,14 @@ public StoreReader(Stream store, string path, DebugLogger? logger) : base(store, path, logger) { supportedVersions = new HashSet { - ASSEMBLY_STORE_FORMAT_VERSION_64BIT | ASSEMBLY_STORE_ABI_AARCH64, - ASSEMBLY_STORE_FORMAT_VERSION_64BIT | ASSEMBLY_STORE_ABI_X64, - ASSEMBLY_STORE_FORMAT_VERSION_32BIT | ASSEMBLY_STORE_ABI_ARM, - ASSEMBLY_STORE_FORMAT_VERSION_32BIT | ASSEMBLY_STORE_ABI_X86, + ASSEMBLY_STORE_FORMAT_VERSION_64BIT_V3 | ASSEMBLY_STORE_ABI_AARCH64, + ASSEMBLY_STORE_FORMAT_VERSION_64BIT_V3 | ASSEMBLY_STORE_ABI_X64, + ASSEMBLY_STORE_FORMAT_VERSION_32BIT_V3 | ASSEMBLY_STORE_ABI_ARM, + ASSEMBLY_STORE_FORMAT_VERSION_32BIT_V3 | ASSEMBLY_STORE_ABI_X86, + ASSEMBLY_STORE_FORMAT_VERSION_CORECLR_64BIT_V4 | ASSEMBLY_STORE_ABI_AARCH64, + ASSEMBLY_STORE_FORMAT_VERSION_CORECLR_64BIT_V4 | ASSEMBLY_STORE_ABI_X64, + ASSEMBLY_STORE_FORMAT_VERSION_CORECLR_32BIT_V4 | ASSEMBLY_STORE_ABI_ARM, + ASSEMBLY_STORE_FORMAT_VERSION_CORECLR_32BIT_V4 | ASSEMBLY_STORE_ABI_X86, }; } @@ -157,8 +161,9 @@ protected internal override bool IsSupported() var entry_count = reader.ReadUInt32(); var index_entry_count = reader.ReadUInt32(); var index_size = reader.ReadUInt32(); + var content_id = (version & ASSEMBLY_STORE_FORMAT_NUMBER_MASK) >= 4 ? reader.ReadUInt64() : 0; - header = new Header(magic, version, entry_count, index_entry_count, index_size); + header = new Header(magic, version, entry_count, index_entry_count, index_size, content_id); return true; } } @@ -185,7 +190,7 @@ protected override void Prepare() AssemblyCount = header.entry_count; IndexEntryCount = header.index_entry_count; - StoreStream.Seek((long)elfOffset + Header.NativeSize, SeekOrigin.Begin); + StoreStream.Seek((long)elfOffset + header.NativeSize, SeekOrigin.Begin); using var reader = CreateReader(); var index = new List(); diff --git a/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs b/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs index 22bf8af4e0..4171912059 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs +++ b/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs @@ -4,6 +4,89 @@ namespace Sentry.Android.AssemblyReader.Tests; public class StoreReaderTests { + [Theory] + [InlineData(0x80000003u | 0x00010000u, true)] // v3, 64-bit, arm64 + [InlineData(0x00000003u | 0x00020000u, false)] // v3, 32-bit, arm + [InlineData(0x80000004u | 0x00030000u, true)] // v4, 64-bit, x86_64 + [InlineData(0x00000004u | 0x00040000u, false)] // v4, 32-bit, x86 + public void Create_SupportedVersion_ReadsStore(uint version, bool is64Bit) + { + // Arrange + using var stream = CreateStore(version, is64Bit, "testAssembly.dll"); + + // Act + var reader = AssemblyStoreReader.Create(stream, "testStore", null); + + // Assert + reader.Should().NotBeNull(); + reader!.Is64Bit.Should().Be(is64Bit); + reader.Assemblies.Should().ContainSingle().Which.Name.Should().Be("testAssembly.dll"); + } + + [Theory] + [InlineData(0x80000005u | 0x00010000u)] // v5 + [InlineData(0x80000002u | 0x00010000u)] // v2 + public void Create_UnsupportedVersion_ReturnsNull(uint version) + { + // Arrange + using var stream = CreateStore(version, is64Bit: true, "testAssembly.dll"); + + // Act + var reader = AssemblyStoreReader.Create(stream, "testStore", null); + + // Assert + reader.Should().BeNull(); + } + + /// + /// Builds a minimal assembly store with a single assembly, using the v3/v4 layout. + /// + private static MemoryStream CreateStore(uint version, bool is64Bit, string assemblyName) + { + var stream = new MemoryStream(); + using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + var indexEntrySize = (is64Bit ? sizeof(ulong) : sizeof(uint)) + sizeof(uint) + sizeof(byte); + + // Header + writer.Write(Utils.AssemblyStoreMagic); + writer.Write(version); + writer.Write(1u); // entry_count + writer.Write(1u); // index_entry_count + writer.Write((uint)indexEntrySize); // index_size + if ((version & 0xFFFF) >= 4) + { + writer.Write(0x0123456789ABCDEFul); // content_id + } + + // Index + if (is64Bit) + { + writer.Write(0xDEADBEEFDEADBEEFul); // name_hash + } + else + { + writer.Write(0xDEADBEEFu); // name_hash + } + writer.Write(0u); // descriptor_index + writer.Write((byte)0); // ignore + + // Descriptor: mapping_index, data offset/size, debug offset/size, config offset/size + for (var i = 0; i < 7; i++) + { + writer.Write(0u); + } + + // Names + var nameBytes = Encoding.UTF8.GetBytes(assemblyName); + writer.Write((uint)nameBytes.Length); + writer.Write(nameBytes); + } + + stream.Position = 0; + return stream; + } + [Fact] public void IsSupported_Concurrent_IsThreadSafe() { From a804f368e9434fa6cd2779a0f0244a6455d4ad7c Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 14 Sep 2026 13:16:50 +1200 Subject: [PATCH 02/16] 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 c4670a038197379de43f3cefd3b9e26b038f92ae Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 14 Sep 2026 01:28:15 +0000 Subject: [PATCH 03/16] Accept API verifier changes --- .../ApiApprovalTests.Run.DotNet11_0.verified.txt | 2 ++ test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt index 9e7456b450..8934fb57a7 100644 --- a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt +++ b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt @@ -59,6 +59,8 @@ namespace Sentry.NLog public NLog.Layouts.Layout? BreadcrumbCategory { get; set; } public NLog.Layouts.Layout? BreadcrumbLayout { get; set; } public NLog.Layouts.Layout? Dsn { get; set; } + [System.Obsolete("Logs are always enabled. This option is ignored and will be removed in a future m" + + "ajor version. To drop logs, use SetBeforeSendLog and return null.")] public bool EnableLogs { get; set; } public NLog.Layouts.Layout? Environment { get; set; } public int FlushTimeoutSeconds { get; set; } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt index 5f150f965e..803c8f81b3 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt @@ -837,6 +837,8 @@ namespace Sentry public string? Distribution { get; set; } public string? Dsn { get; set; } public bool EnableBackpressureHandling { get; set; } + [System.Obsolete("Logs are always enabled. This option is ignored and will be removed in a future m" + + "ajor version. To drop logs, use SetBeforeSendLog and return null.")] public bool EnableLogs { get; set; } [System.Obsolete("Metrics are always enabled. This option is ignored and will be removed in version" + " 7.0.0. To drop metrics, use SetBeforeSendMetric and return null.")] From d054bf3411082f7ebfc0839166ed6bb928943aaa Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 14 Sep 2026 01:43:47 +0000 Subject: [PATCH 04/16] Accept API verifier changes --- .../ApiApprovalTests.Run.DotNet11_0.verified.txt | 2 ++ test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt index 9e7456b450..8934fb57a7 100644 --- a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt +++ b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt @@ -59,6 +59,8 @@ namespace Sentry.NLog public NLog.Layouts.Layout? BreadcrumbCategory { get; set; } public NLog.Layouts.Layout? BreadcrumbLayout { get; set; } public NLog.Layouts.Layout? Dsn { get; set; } + [System.Obsolete("Logs are always enabled. This option is ignored and will be removed in a future m" + + "ajor version. To drop logs, use SetBeforeSendLog and return null.")] public bool EnableLogs { get; set; } public NLog.Layouts.Layout? Environment { get; set; } public int FlushTimeoutSeconds { get; set; } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt index 5f150f965e..803c8f81b3 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt @@ -837,6 +837,8 @@ namespace Sentry public string? Distribution { get; set; } public string? Dsn { get; set; } public bool EnableBackpressureHandling { get; set; } + [System.Obsolete("Logs are always enabled. This option is ignored and will be removed in a future m" + + "ajor version. To drop logs, use SetBeforeSendLog and return null.")] public bool EnableLogs { get; set; } [System.Obsolete("Metrics are always enabled. This option is ignored and will be removed in version" + " 7.0.0. To drop metrics, use SetBeforeSendMetric and return null.")] From aeda4bbf42b3969db743e3651fbc277904e648fc Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 14 Sep 2026 16:51:56 +1200 Subject: [PATCH 05/16] feat: Derive Android assembly store index entry size from the header The index entry layout was inferred from the ABI bitness (plus a compile-time TFM check for the ignore flag). CoreCLR v4 stores use 32-bit CRC32 name hashes on every ABI, so on 64-bit ABIs the reader read the index 4 bytes per entry too far and failed in Prepare(). The entry size is now index_size / index_entry_count, and anything other than the two v3/v4 layouts is rejected as corrupt. With this, .NET 11 APKs are readable, so the net11 APK tests are re-enabled. Refs #5454 Co-Authored-By: Claude Opus 5 --- .../V2/StoreReader.Classes.cs | 4 ++ .../V2/StoreReader.cs | 35 ++++++---- .../AndroidAssemblyReaderTests.cs | 20 +----- ...Sentry.Android.AssemblyReader.Tests.csproj | 3 +- .../StoreReaderTests.cs | 67 +++++++++++++------ 5 files changed, 76 insertions(+), 53 deletions(-) diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs index 09ebb4265e..8273fe8320 100644 --- a/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs @@ -39,6 +39,10 @@ public Header(uint magic, uint version, uint entry_count, uint index_entry_count internal sealed class IndexEntry { + // The ignore flag is a bool, written to the binary as a single byte + public const uint NativeSize32 = 2 * sizeof(uint) + sizeof(byte); + public const uint NativeSize64 = sizeof(ulong) + sizeof(uint) + sizeof(byte); + public readonly ulong name_hash; public readonly uint descriptor_index; public readonly bool ignore; diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs index bb979ab60c..390cde0c46 100644 --- a/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs @@ -193,25 +193,18 @@ protected override void Prepare() StoreStream.Seek((long)elfOffset + header.NativeSize, SeekOrigin.Begin); using var reader = CreateReader(); + var indexEntrySize = GetIndexEntrySize(header); var index = new List(); for (uint i = 0; i < header.index_entry_count; i++) { - ulong name_hash; - if (Is64Bit) + var name_hash = indexEntrySize switch { - name_hash = reader.ReadUInt64(); - } - else - { - name_hash = (ulong)reader.ReadUInt32(); - } - + IndexEntry.NativeSize64 => reader.ReadUInt64(), + IndexEntry.NativeSize32 => reader.ReadUInt32(), + _ => throw new InvalidOperationException($"Assembly store '{StorePath}' index entry size {indexEntrySize} is not supported.") + }; uint descriptor_index = reader.ReadUInt32(); -#if NET10_0_OR_GREATER bool ignore = reader.ReadByte() != 0; -#else - bool ignore = false; -#endif index.Add(new IndexEntry(name_hash, descriptor_index, ignore)); } @@ -273,4 +266,20 @@ protected override void Prepare() Assemblies = storeItems.AsReadOnly(); } } + + // Name hash width depends on the runtime, not the ABI: CoreCLR stores use 32-bit CRC32 hashes on every ABI + private uint GetIndexEntrySize(Header header) + { + if (header.index_entry_count == 0) + { + return 0; + } + + if (header.index_size % header.index_entry_count != 0) + { + throw new InvalidOperationException($"Assembly store '{StorePath}' index is corrupted: index size {header.index_size} is not evenly divisible by entry count {header.index_entry_count}."); + } + + return header.index_size / header.index_entry_count; + } } diff --git a/test/Sentry.Android.AssemblyReader.Tests/AndroidAssemblyReaderTests.cs b/test/Sentry.Android.AssemblyReader.Tests/AndroidAssemblyReaderTests.cs index 9a48637529..abf02e983d 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/AndroidAssemblyReaderTests.cs +++ b/test/Sentry.Android.AssemblyReader.Tests/AndroidAssemblyReaderTests.cs @@ -15,18 +15,6 @@ public class AndroidAssemblyReaderTests #error "Target Framework not yet supported for AndroidAssemblyReader" #endif - // .NET 11 Android moved to CoreCLR and emits v4 assembly stores, which our vendored - // reader does not understand yet - it also changes ELF payload discovery, so even the - // non-store APKs fail. Tracked by https://github.com/getsentry/sentry-dotnet/issues/5454; - // re-enable these once that port lands. - private const string StoreV4SkipReason = - "Android assembly store v4 (.NET 11) is not supported yet - see getsentry/sentry-dotnet#5454"; -#if NET11_0_OR_GREATER - private const bool StoreV4Unsupported = true; -#else - private const bool StoreV4Unsupported = false; -#endif - public AndroidAssemblyReaderTests(ITestOutputHelper output) { _output = output; @@ -57,7 +45,6 @@ private IAndroidAssemblyReader GetSut(bool isAot, bool isAssemblyStore, bool isC [SkippableFact] public void CreatesCorrectStoreReader() { - Skip.If(StoreV4Unsupported, StoreV4SkipReason); #if ANDROID Skip.If(true, "It's unknown whether the current Android app APK is an assembly store or not."); #endif @@ -78,7 +65,6 @@ public void CreatesCorrectStoreReader() [SkippableFact] public void CreatesCorrectArchiveReader() { - Skip.If(StoreV4Unsupported, StoreV4SkipReason); #if ANDROID Skip.If(true, "It's unknown whether the current Android app APK is an assembly store or not."); #endif @@ -86,7 +72,9 @@ public void CreatesCorrectArchiveReader() switch (TargetFramework) { case "net11.0": - Assert.IsType(sut); + // CoreCLR always loads assemblies from the store, so AndroidUseAssemblyStore=false has no effect: + // https://github.com/dotnet/android/pull/12033 + Assert.IsType(sut); break; case "net10.0": Assert.IsType(sut); @@ -101,7 +89,6 @@ public void CreatesCorrectArchiveReader() [InlineData(true)] public void ReturnsNullIfAssemblyDoesntExist(bool isAssemblyStore) { - Skip.If(StoreV4Unsupported, StoreV4SkipReason); using var sut = GetSut(isAot: false, isAssemblyStore, isCompressed: true); Assert.Null(sut.TryReadAssembly("NonExistent.dll")); } @@ -117,7 +104,6 @@ public void ReturnsNullIfAssemblyDoesntExist(bool isAssemblyStore) [MemberData(nameof(ReadsAssemblyPermutations))] public void ReadsAssembly(bool isAot, bool isAssemblyStore, bool isCompressed, string assemblyName) { - Skip.If(StoreV4Unsupported, StoreV4SkipReason); #if ANDROID // No need to run all combinations - we only test the current APK which is likely JIT compressed assembly store. Skip.If(isAot); diff --git a/test/Sentry.Android.AssemblyReader.Tests/Sentry.Android.AssemblyReader.Tests.csproj b/test/Sentry.Android.AssemblyReader.Tests/Sentry.Android.AssemblyReader.Tests.csproj index 169fd703f1..164efe10b0 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/Sentry.Android.AssemblyReader.Tests.csproj +++ b/test/Sentry.Android.AssemblyReader.Tests/Sentry.Android.AssemblyReader.Tests.csproj @@ -43,8 +43,7 @@ <_TestAPK Include="3" Properties="_Aot=False;_Store=True;_Compressed=False" /> <_TestAPK Include="4" Properties="_Aot=False;_Store=True;_Compressed=True" /> + apps from .NET 11 on. --> <_TestAPK Include="5" Properties="_Aot=True;_Store=False;_Compressed=False" Condition="'$(TargetFramework)' == 'net10.0-android'" /> <_TestAPK Include="6" Properties="_Aot=True;_Store=False;_Compressed=True" Condition="'$(TargetFramework)' == 'net10.0-android'" /> <_TestAPK Include="7" Properties="_Aot=True;_Store=True;_Compressed=False" Condition="'$(TargetFramework)' == 'net10.0-android'" /> diff --git a/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs b/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs index 4171912059..54994f27cc 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs +++ b/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs @@ -5,14 +5,15 @@ namespace Sentry.Android.AssemblyReader.Tests; public class StoreReaderTests { [Theory] - [InlineData(0x80000003u | 0x00010000u, true)] // v3, 64-bit, arm64 - [InlineData(0x00000003u | 0x00020000u, false)] // v3, 32-bit, arm - [InlineData(0x80000004u | 0x00030000u, true)] // v4, 64-bit, x86_64 - [InlineData(0x00000004u | 0x00040000u, false)] // v4, 32-bit, x86 - public void Create_SupportedVersion_ReadsStore(uint version, bool is64Bit) + [InlineData(0x80000003u | 0x00010000u, true, sizeof(ulong))] // v3, 64-bit, arm64 + [InlineData(0x00000003u | 0x00020000u, false, sizeof(uint))] // v3, 32-bit, arm + [InlineData(0x80000004u | 0x00030000u, true, sizeof(ulong))] // v4, 64-bit, x86_64 (MonoVM) + [InlineData(0x80000004u | 0x00030000u, true, sizeof(uint))] // v4, 64-bit, x86_64 (CoreCLR) + [InlineData(0x00000004u | 0x00040000u, false, sizeof(uint))] // v4, 32-bit, x86 + public void Create_SupportedVersion_ReadsStore(uint version, bool is64Bit, int nameHashSize) { // Arrange - using var stream = CreateStore(version, is64Bit, "testAssembly.dll"); + using var stream = CreateStore(version, nameHashSize, "testAssembly.dll"); // Act var reader = AssemblyStoreReader.Create(stream, "testStore", null); @@ -29,7 +30,7 @@ public void Create_SupportedVersion_ReadsStore(uint version, bool is64Bit) public void Create_UnsupportedVersion_ReturnsNull(uint version) { // Arrange - using var stream = CreateStore(version, is64Bit: true, "testAssembly.dll"); + using var stream = CreateStore(version, sizeof(ulong), "testAssembly.dll"); // Act var reader = AssemblyStoreReader.Create(stream, "testStore", null); @@ -38,29 +39,40 @@ public void Create_UnsupportedVersion_ReturnsNull(uint version) reader.Should().BeNull(); } + [Theory] + [InlineData(12u, 1u)] // v2 layout: 64-bit hash, no ignore flag + [InlineData(17u, 2u)] // not evenly divisible + public void Create_InvalidIndexSize_Throws(uint indexSize, uint indexEntryCount) + { + // Arrange + using var stream = new MemoryStream(); + using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + WriteHeader(writer, 0x80000004u | 0x00010000u, indexEntryCount, indexSize); + writer.Write(new byte[indexSize]); + } + stream.Position = 0; + + // Act + var act = () => AssemblyStoreReader.Create(stream, "testStore", null); + + // Assert + act.Should().Throw().WithMessage("*testStore*index*"); + } + /// /// Builds a minimal assembly store with a single assembly, using the v3/v4 layout. /// - private static MemoryStream CreateStore(uint version, bool is64Bit, string assemblyName) + private static MemoryStream CreateStore(uint version, int nameHashSize, string assemblyName) { var stream = new MemoryStream(); using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) { - var indexEntrySize = (is64Bit ? sizeof(ulong) : sizeof(uint)) + sizeof(uint) + sizeof(byte); - - // Header - writer.Write(Utils.AssemblyStoreMagic); - writer.Write(version); - writer.Write(1u); // entry_count - writer.Write(1u); // index_entry_count - writer.Write((uint)indexEntrySize); // index_size - if ((version & 0xFFFF) >= 4) - { - writer.Write(0x0123456789ABCDEFul); // content_id - } + var indexEntrySize = nameHashSize + sizeof(uint) + sizeof(byte); + WriteHeader(writer, version, indexEntryCount: 1, (uint)indexEntrySize); // Index - if (is64Bit) + if (nameHashSize == sizeof(ulong)) { writer.Write(0xDEADBEEFDEADBEEFul); // name_hash } @@ -87,6 +99,19 @@ private static MemoryStream CreateStore(uint version, bool is64Bit, string assem return stream; } + private static void WriteHeader(BinaryWriter writer, uint version, uint indexEntryCount, uint indexSize) + { + writer.Write(Utils.AssemblyStoreMagic); + writer.Write(version); + writer.Write(1u); // entry_count + writer.Write(indexEntryCount); + writer.Write(indexSize); + if ((version & 0xFFFF) >= 4) + { + writer.Write(0x0123456789ABCDEFul); // content_id + } + } + [Fact] public void IsSupported_Concurrent_IsThreadSafe() { From 18585cd9c872908d57fa62cc366ebc523ba8c482 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 14 Sep 2026 16:52:33 +1200 Subject: [PATCH 06/16] chore: Refresh Android assembly reader upstream attribution Co-Authored-By: Claude Opus 5 --- src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt | 8 ++++++++ src/Sentry.Android.AssemblyReader/V2/StoreReader.cs | 1 + 2 files changed, 9 insertions(+) diff --git a/src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt b/src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt index e782059c7d..0456205d9b 100644 --- a/src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt +++ b/src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt @@ -1,6 +1,14 @@ Parts of the code in this subdirectory have been adapted from https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/tools/assembly-store-reader-mk2/assembly-store-reader.csproj +and subsequently updated from: +- https://github.com/dotnet/android/tree/64018e13e53cec7246e54866b520d3284de344e0/tools/assembly-store-reader-mk2 + (assembly store v3, .NET 10) +- https://github.com/dotnet/android/tree/f1aecf9e6ae80fe3f3992ec1f52ef953dac7c06b/.github/skills/read-assembly-store + (assembly store v4 and index entry sizing, .NET 11) + +Individual files note which of these they were updated from. + The original license is as follows: The MIT License (MIT) diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs index 390cde0c46..e4193a7f0a 100644 --- a/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs @@ -4,6 +4,7 @@ * - Adding support for AssemblyStore v3 format that shipped in .NET 10 (https://github.com/dotnet/android/pull/10249) * Updated from https://github.com/dotnet/android/blob/f1aecf9e6ae80fe3f3992ec1f52ef953dac7c06b/.github/skills/read-assembly-store/src/AssemblyStore/StoreReader_V2.cs * - Adding support for AssemblyStore v4 format (CoreCLR) that ships in .NET 11 + * - Deriving the index entry size from the header rather than the ABI * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) */ From e7db72a3900ac1bc1e4209561f1450d637845f24 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 14 Sep 2026 18:38:36 +1200 Subject: [PATCH 07/16] 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 4ee11d6541d7306628466dab1436fb2b12d7cf83 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 15 Sep 2026 16:52:13 +1200 Subject: [PATCH 08/16] chore: Trim comments Co-Authored-By: Claude Opus 5 --- src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs | 1 - src/Sentry.Android.AssemblyReader/V2/StoreReader.cs | 1 - test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs | 3 --- 3 files changed, 5 deletions(-) diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs index 09ebb4265e..36394c5cae 100644 --- a/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs @@ -21,7 +21,6 @@ private sealed class Header // Index size in bytes public readonly uint index_size; - // Only present in v4+ stores public readonly ulong content_id; public uint NativeSize => 5 * sizeof(uint) + ((version & ASSEMBLY_STORE_FORMAT_NUMBER_MASK) >= 4 ? sizeof(ulong) : 0u); diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs index bb979ab60c..7e56ddb0d3 100644 --- a/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs @@ -12,7 +12,6 @@ namespace Sentry.Android.AssemblyReader.V2; internal partial class StoreReader : AssemblyStoreReader { // Bit 31 is set for 64-bit platforms, cleared for the 32-bit ones - // Each .NET release bumps the assembly store format: v3 in .NET 10, v4 (CoreCLR) in .NET 11. private const uint ASSEMBLY_STORE_FORMAT_VERSION_64BIT_V3 = 0x80000003; private const uint ASSEMBLY_STORE_FORMAT_VERSION_32BIT_V3 = 0x00000003; private const uint ASSEMBLY_STORE_FORMAT_VERSION_CORECLR_64BIT_V4 = 0x80000004; // Must match the ASSEMBLY_STORE_FORMAT_VERSION native constant diff --git a/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs b/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs index 4171912059..d0f8bcd569 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs +++ b/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs @@ -38,9 +38,6 @@ public void Create_UnsupportedVersion_ReturnsNull(uint version) reader.Should().BeNull(); } - /// - /// Builds a minimal assembly store with a single assembly, using the v3/v4 layout. - /// private static MemoryStream CreateStore(uint version, bool is64Bit, string assemblyName) { var stream = new MemoryStream(); From f1830d455708462f9f10612ad0f99522952eb2ae Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 15 Sep 2026 16:52:13 +1200 Subject: [PATCH 09/16] fix: Resolve debug images for Android assemblies without a file location On .NET 11 (CoreCLR) Android, assemblies loaded from the assembly store report Module.FullyQualifiedName as , so DebugStackTrace bailed out before calling the assembly reader and events had no debug images. When an assembly reader is configured, fall back to Module.ScopeName, which is the file name the store is indexed by. Co-Authored-By: Claude Opus 5 --- src/Sentry/Internal/DebugStackTrace.cs | 16 ++++++------ .../Internals/DebugStackTraceTests.verify.cs | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/Sentry/Internal/DebugStackTrace.cs b/src/Sentry/Internal/DebugStackTrace.cs index b4f358a463..270ec45a44 100644 --- a/src/Sentry/Internal/DebugStackTrace.cs +++ b/src/Sentry/Internal/DebugStackTrace.cs @@ -518,19 +518,21 @@ private static void DemangleLambdaReturnType(SentryStackFrame frame) { try { - assemblyName = module.FullyQualifiedName; - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (assemblyName is null or ModuleExtensions.UnknownLocation) + var location = module.FullyQualifiedName is { } name and not ModuleExtensions.UnknownLocation ? name : null; + if (options.AssemblyReader is { } reader) + { + // CoreCLR on Android loads assemblies from the APK, so they have no location + assemblyName = location ?? module.ScopeName; + return reader.Invoke(assemblyName); + } + if (location is null) { // When publishing as a single file or compiling AOT FullyQualifiedName will be null. This logic // compensates for the UnconditionalSuppressMessage attribute applied to this method. assemblyName = null; return null; } - if (options.AssemblyReader is { } reader) - { - return reader.Invoke(assemblyName); - } + assemblyName = location; if (options.FileSystem.FileExists(assemblyName)) { diff --git a/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs b/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs index 087cf6500f..3c054cedd2 100644 --- a/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs +++ b/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs @@ -112,6 +112,23 @@ public void DemangleAsyncFunctionName_NullModule_ContinuesNull() Assert.Null(stackFrame.Module); } + [Theory] + [InlineData("", "Foo.dll")] + [InlineData("/data/app/Foo.dll", "/data/app/Foo.dll")] + public void GetManagedModuleDebugImage_AssemblyReader_ReadsByLocationOrScopeName(string fullyQualifiedName, string expectedName) + { + string? requestedName = null; + _fixture.SentryOptions.AssemblyReader = name => + { + requestedName = name; + return null; + }; + + DebugStackTrace.GetManagedModuleDebugImage(new StubModule(fullyQualifiedName, "Foo.dll"), _fixture.SentryOptions); + + requestedName.Should().Be(expectedName); + } + [Fact] public void MergeDebugImages_Empty() { @@ -264,6 +281,14 @@ public void Inject(int identifier) }); } } + private class StubModule(string fullyQualifiedName, string scopeName) : Module + { + public override string FullyQualifiedName => fullyQualifiedName; + public override string Name => fullyQualifiedName; + public override string ScopeName => scopeName; + public override Guid ModuleVersionId { get; } = Guid.NewGuid(); + } + internal class StubNativeAOTStackFrame : IStackFrame { internal string? Function; From 082cf3e29ed7ac5782717f6674d312b0e8cdec6f Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 15 Sep 2026 16:52:14 +1200 Subject: [PATCH 10/16] 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 203a8e05b3d40c51678ae5217d572cb9eeb50aca Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 14 Sep 2026 13:16:50 +1200 Subject: [PATCH 11/16] 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 12/16] 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 13/16] 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 14/16] 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(); From 0ffeb40e74acb95c6b7c986159298d20e4f6c5ba Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 24 Sep 2026 11:10:30 +1200 Subject: [PATCH 15/16] refactor: Address review feedback on .NET 11 assembly store support Names the magic values in the test fixtures, shares the format number mask with the reader, skips the output buffer allocation when a Zstandard payload can't be decompressed, and logs why reading an assembly failed. Co-Authored-By: Claude Opus 5 --- .../ArchiveUtils.cs | 20 ++++++------- .../V2/StoreReader.cs | 2 +- src/Sentry/Internal/DebugStackTrace.cs | 4 +-- .../ArchiveUtilsTests.cs | 3 +- .../StoreReaderTests.cs | 28 +++++++++++-------- 5 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs b/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs index fd6c477813..9bc5a0e028 100644 --- a/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs +++ b/src/Sentry.Android.AssemblyReader/ArchiveUtils.cs @@ -42,6 +42,12 @@ internal static MemoryStream Extract(this ZipArchiveEntry zipEntry) inputStream.Position = 0; return null; } +#if !NET11_0_OR_GREATER + if (magic == ZstandardMagic) + { + throw new NotSupportedException($"Assembly {assemblyName} is Zstandard compressed, which requires .NET 11 or later"); + } +#endif reader.ReadUInt32(); // ignore descriptor index, we don't need it var decompressedLength = reader.ReadInt32(); Debug.Assert(inputStream.Position == payloadOffset); @@ -58,20 +64,14 @@ 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; - 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), + var decoded = magic == Lz4Magic + ? LZ4Codec.Decode(inputBuffer, offset, inputLength, outputBuffer, 0, decompressedLength) + : 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"); + var decoded = LZ4Codec.Decode(inputBuffer, offset, inputLength, outputBuffer, 0, decompressedLength); #endif - } if (decoded != decompressedLength) { throw new Exception($"Failed to decompress {format} data of assembly {assemblyName} - decoded {decoded} instead of expected {decompressedLength} bytes"); diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs index f6f7f2ad3c..c822468fa9 100644 --- a/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs @@ -18,7 +18,7 @@ internal partial class StoreReader : AssemblyStoreReader private const uint ASSEMBLY_STORE_FORMAT_VERSION_CORECLR_64BIT_V4 = 0x80000004; // Must match the ASSEMBLY_STORE_FORMAT_VERSION native constant private const uint ASSEMBLY_STORE_FORMAT_VERSION_CORECLR_32BIT_V4 = 0x00000004; private const uint ASSEMBLY_STORE_FORMAT_VERSION_MASK = 0xF0000000; - private const uint ASSEMBLY_STORE_FORMAT_NUMBER_MASK = 0x0000FFFF; + internal const uint ASSEMBLY_STORE_FORMAT_NUMBER_MASK = 0x0000FFFF; private const uint ASSEMBLY_STORE_ABI_AARCH64 = 0x00010000; private const uint ASSEMBLY_STORE_ABI_ARM = 0x00020000; private const uint ASSEMBLY_STORE_ABI_X64 = 0x00030000; diff --git a/src/Sentry/Internal/DebugStackTrace.cs b/src/Sentry/Internal/DebugStackTrace.cs index 270ec45a44..a94b8e3e89 100644 --- a/src/Sentry/Internal/DebugStackTrace.cs +++ b/src/Sentry/Internal/DebugStackTrace.cs @@ -540,9 +540,9 @@ private static void DemangleLambdaReturnType(SentryStackFrame frame) return new PEReader(assembly); } } - catch + catch (Exception e) { - // Swallow and return null below + options.LogDebug("Failed to read assembly for module '{0}': {1}", module.GetNameOrScopeName(), e.Message); } assemblyName = null; return null; diff --git a/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs b/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs index b7a21578ee..b22c8adf24 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs +++ b/test/Sentry.Android.AssemblyReader.Tests/ArchiveUtilsTests.cs @@ -82,8 +82,9 @@ private static MemoryStream WithHeader(uint magic, ReadOnlySpan payload) var stream = new MemoryStream(); using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) { + const uint descriptorIndex = 0; writer.Write(magic); - writer.Write(0u); // descriptor index + writer.Write(descriptorIndex); writer.Write(Assembly.Length); writer.Write(payload); } diff --git a/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs b/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs index 325af2cf76..0f13479c47 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs +++ b/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs @@ -68,25 +68,28 @@ private static MemoryStream CreateStore(uint version, int nameHashSize, string a var indexEntrySize = nameHashSize + sizeof(uint) + sizeof(byte); WriteHeader(writer, version, indexEntryCount: 1, (uint)indexEntrySize); - // Index + const ulong nameHash64 = 0xDEADBEEFDEADBEEF; + const uint nameHash32 = 0xDEADBEEF; + const uint descriptorIndex = 0; + const byte ignore = 0; + const int descriptorFieldCount = 7; + if (nameHashSize == sizeof(ulong)) { - writer.Write(0xDEADBEEFDEADBEEFul); // name_hash + writer.Write(nameHash64); } else { - writer.Write(0xDEADBEEFu); // name_hash + writer.Write(nameHash32); } - writer.Write(0u); // descriptor_index - writer.Write((byte)0); // ignore + writer.Write(descriptorIndex); + writer.Write(ignore); - // Descriptor: mapping_index, data offset/size, debug offset/size, config offset/size - for (var i = 0; i < 7; i++) + for (var i = 0; i < descriptorFieldCount; i++) { writer.Write(0u); } - // Names var nameBytes = Encoding.UTF8.GetBytes(assemblyName); writer.Write((uint)nameBytes.Length); writer.Write(nameBytes); @@ -98,14 +101,17 @@ private static MemoryStream CreateStore(uint version, int nameHashSize, string a private static void WriteHeader(BinaryWriter writer, uint version, uint indexEntryCount, uint indexSize) { + const uint entryCount = 1; + const ulong contentId = 0x0123456789ABCDEF; + writer.Write(Utils.AssemblyStoreMagic); writer.Write(version); - writer.Write(1u); // entry_count + writer.Write(entryCount); writer.Write(indexEntryCount); writer.Write(indexSize); - if ((version & 0xFFFF) >= 4) + if ((version & StoreReader.ASSEMBLY_STORE_FORMAT_NUMBER_MASK) >= 4) { - writer.Write(0x0123456789ABCDEFul); // content_id + writer.Write(contentId); } } From 79b8f747c764ef8471cbdd9f608ed2ebfb05a309 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 24 Sep 2026 11:11:19 +1200 Subject: [PATCH 16/16] ref: Record that assembly store v4 never shipped in .NET 11 (#5609) dotnet/android#12780 removed the opt-in decompression cache and restored assembly store format version 3 for CoreCLR, so v4 only ever existed in .NET 11 previews. Correct the vendored file headers and ATTRIBUTION, which claimed v4 ships in .NET 11. .NET 11 GA emits v3 with CoreCLR's 32-bit CRC32 name hashes on 64-bit ABIs - a shape the synthetic store tests did not cover. Add it. Co-authored-by: Claude Opus 5 --- src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt | 4 +++- .../V2/StoreReader.Classes.cs | 4 +++- src/Sentry.Android.AssemblyReader/V2/StoreReader.cs | 7 +++++-- .../StoreReaderTests.cs | 9 +++++---- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt b/src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt index 0456205d9b..18700c9250 100644 --- a/src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt +++ b/src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt @@ -5,7 +5,9 @@ and subsequently updated from: - https://github.com/dotnet/android/tree/64018e13e53cec7246e54866b520d3284de344e0/tools/assembly-store-reader-mk2 (assembly store v3, .NET 10) - https://github.com/dotnet/android/tree/f1aecf9e6ae80fe3f3992ec1f52ef953dac7c06b/.github/skills/read-assembly-store - (assembly store v4 and index entry sizing, .NET 11) + (assembly store v4 and index entry sizing, .NET 11 previews) +- https://github.com/dotnet/android/commit/8f7c4d4fa53c6682f2c4f2d2caf08e9fb4d8cd60 + (v4 reverted to v3 before .NET 11 GA) Individual files note which of these they were updated from. diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs index 0e1604bbe0..472bf8f324 100644 --- a/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReader.Classes.cs @@ -3,7 +3,9 @@ * Updated from https://github.com/dotnet/android/blob/64018e13e53cec7246e54866b520d3284de344e0/tools/assembly-store-reader-mk2/AssemblyStore/StoreReader_V2.Classes.cs * - Adding support for AssemblyStore v3 format that shipped in .NET 10 (https://github.com/dotnet/android/pull/10249) * Updated from https://github.com/dotnet/android/blob/f1aecf9e6ae80fe3f3992ec1f52ef953dac7c06b/.github/skills/read-assembly-store/src/AssemblyStore/StoreReader_V2.Classes.cs - * - Adding support for AssemblyStore v4 format (CoreCLR) that ships in .NET 11 + * - Adding support for AssemblyStore v4 format (CoreCLR), which only ever shipped in .NET 11 previews + * Reviewed against https://github.com/dotnet/android/commit/8f7c4d4fa53c6682f2c4f2d2caf08e9fb4d8cd60 + * - v4 was reverted before .NET 11 GA (dotnet/android#12780); CoreCLR emits v3 again * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) */ diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs index c822468fa9..f5464affdd 100644 --- a/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReader.cs @@ -3,8 +3,10 @@ * Updated from https://github.com/dotnet/android/blob/64018e13e53cec7246e54866b520d3284de344e0/tools/assembly-store-reader-mk2/AssemblyStore/StoreReader_V2.cs * - Adding support for AssemblyStore v3 format that shipped in .NET 10 (https://github.com/dotnet/android/pull/10249) * Updated from https://github.com/dotnet/android/blob/f1aecf9e6ae80fe3f3992ec1f52ef953dac7c06b/.github/skills/read-assembly-store/src/AssemblyStore/StoreReader_V2.cs - * - Adding support for AssemblyStore v4 format (CoreCLR) that ships in .NET 11 + * - Adding support for AssemblyStore v4 format (CoreCLR), which only ever shipped in .NET 11 previews * - Deriving the index entry size from the header rather than the ABI + * Reviewed against https://github.com/dotnet/android/commit/8f7c4d4fa53c6682f2c4f2d2caf08e9fb4d8cd60 + * - v4 was reverted before .NET 11 GA (dotnet/android#12780); CoreCLR emits v3 again * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) */ @@ -15,7 +17,8 @@ internal partial class StoreReader : AssemblyStoreReader // Bit 31 is set for 64-bit platforms, cleared for the 32-bit ones private const uint ASSEMBLY_STORE_FORMAT_VERSION_64BIT_V3 = 0x80000003; private const uint ASSEMBLY_STORE_FORMAT_VERSION_32BIT_V3 = 0x00000003; - private const uint ASSEMBLY_STORE_FORMAT_VERSION_CORECLR_64BIT_V4 = 0x80000004; // Must match the ASSEMBLY_STORE_FORMAT_VERSION native constant + // v4 was only emitted by .NET 11 previews; it was reverted to v3 before GA by dotnet/android#12780 + private const uint ASSEMBLY_STORE_FORMAT_VERSION_CORECLR_64BIT_V4 = 0x80000004; private const uint ASSEMBLY_STORE_FORMAT_VERSION_CORECLR_32BIT_V4 = 0x00000004; private const uint ASSEMBLY_STORE_FORMAT_VERSION_MASK = 0xF0000000; internal const uint ASSEMBLY_STORE_FORMAT_NUMBER_MASK = 0x0000FFFF; diff --git a/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs b/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs index 0f13479c47..159cae2661 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs +++ b/test/Sentry.Android.AssemblyReader.Tests/StoreReaderTests.cs @@ -5,11 +5,12 @@ namespace Sentry.Android.AssemblyReader.Tests; public class StoreReaderTests { [Theory] - [InlineData(0x80000003u | 0x00010000u, true, sizeof(ulong))] // v3, 64-bit, arm64 + [InlineData(0x80000003u | 0x00010000u, true, sizeof(ulong))] // v3, 64-bit, arm64 (MonoVM) + [InlineData(0x80000003u | 0x00010000u, true, sizeof(uint))] // v3, 64-bit, arm64 (CoreCLR) [InlineData(0x00000003u | 0x00020000u, false, sizeof(uint))] // v3, 32-bit, arm - [InlineData(0x80000004u | 0x00030000u, true, sizeof(ulong))] // v4, 64-bit, x86_64 (MonoVM) - [InlineData(0x80000004u | 0x00030000u, true, sizeof(uint))] // v4, 64-bit, x86_64 (CoreCLR) - [InlineData(0x00000004u | 0x00040000u, false, sizeof(uint))] // v4, 32-bit, x86 + [InlineData(0x80000004u | 0x00030000u, true, sizeof(ulong))] // v4, 64-bit, x86_64 (MonoVM, .NET 11 previews) + [InlineData(0x80000004u | 0x00030000u, true, sizeof(uint))] // v4, 64-bit, x86_64 (CoreCLR, .NET 11 previews) + [InlineData(0x00000004u | 0x00040000u, false, sizeof(uint))] // v4, 32-bit, x86 (.NET 11 previews) public void Create_SupportedVersion_ReadsStore(uint version, bool is64Bit, int nameHashSize) { // Arrange