From c17c71e344ab433ff61c1615e9831bdd0d783876 Mon Sep 17 00:00:00 2001 From: Miguel Beltran Date: Wed, 16 Oct 2024 11:13:54 +0200 Subject: [PATCH 1/9] solution structure --- src/Raygun.Blazor/Models/ErrorDetails.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Raygun.Blazor/Models/ErrorDetails.cs b/src/Raygun.Blazor/Models/ErrorDetails.cs index 49703ff..9aa3270 100644 --- a/src/Raygun.Blazor/Models/ErrorDetails.cs +++ b/src/Raygun.Blazor/Models/ErrorDetails.cs @@ -129,6 +129,22 @@ internal ErrorDetails(Exception ex) InnerError = new ErrorDetails(betterEx.InnerException); } } + + if (StackTrace != null) + { + // If we have a stack trace then grab the debug info images, and put them into an array + // for the outgoing payload + Images = GetDebugInfoForStackFrames(StackTrace); + } + } + + #endregion + + #region Privatate Methods + + private static List GetDebugInfoForStackFrames(IEnumerable frames) + { + throw new NotImplementedException(); } #endregion From 5d3abdbd97b6ba02d29a98a5633ec5c33ed54c21 Mon Sep 17 00:00:00 2001 From: Miguel Beltran Date: Wed, 16 Oct 2024 11:28:40 +0200 Subject: [PATCH 2/9] wip --- src/Raygun.Blazor/Models/ErrorDetails.cs | 56 ++++++++++++- .../PortableExecutableReaderExtensions.cs | 78 +++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/Raygun.Blazor/Models/PortableExecutableReaderExtensions.cs diff --git a/src/Raygun.Blazor/Models/ErrorDetails.cs b/src/Raygun.Blazor/Models/ErrorDetails.cs index 9aa3270..e4a5f7e 100644 --- a/src/Raygun.Blazor/Models/ErrorDetails.cs +++ b/src/Raygun.Blazor/Models/ErrorDetails.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Reflection.PortableExecutable; using System.Text.Json.Serialization; using KristofferStrube.Blazor.WebIDL.Exceptions; using Raygun.Blazor.Extensions; @@ -138,13 +140,65 @@ internal ErrorDetails(Exception ex) } } + #endregion + + #region Private Properties + + private static readonly ConcurrentDictionary DebugInformationCache = new(); + private static Func AssemblyReaderProvider { get; set; } = PortableExecutableReaderExtensions.GetFileSystemPEReader; + #endregion #region Privatate Methods private static List GetDebugInfoForStackFrames(IEnumerable frames) { - throw new NotImplementedException(); + if (DebugInformationCache.IsEmpty) + { + return []; + } + + var imageMap = DebugInformationCache.Values.Where(x => x?.Signature != null).ToDictionary(k => k!.Signature!); + var imageSet = new HashSet(); + + foreach (var stackFrame in frames) + { + if (stackFrame.ImageSignature != null && imageMap.TryGetValue(stackFrame.ImageSignature, out var image)) + { + if (image != null) + { + imageSet.Add(image); + } + } + } + + return imageSet.ToList(); + } + + private static PEDebugDetails? TryGetDebugInformation(string moduleName) + { + if (DebugInformationCache.TryGetValue(moduleName, out var cachedInfo)) + { + return cachedInfo; + } + + try + { + // Attempt to read out the Debug Info from the PE + var peReader = AssemblyReaderProvider(moduleName); + + // If we got this far, the assembly/module exists, so whatever the result + // put it in the cache to prevent reading the disk over and over + peReader.TryGetDebugInformation(out var debugInfo); + DebugInformationCache.TryAdd(moduleName, debugInfo); + return debugInfo; + } + catch (Exception ex) + { + Debug.WriteLine($"Could not load debug information: {ex}"); + } + + return null; } #endregion diff --git a/src/Raygun.Blazor/Models/PortableExecutableReaderExtensions.cs b/src/Raygun.Blazor/Models/PortableExecutableReaderExtensions.cs new file mode 100644 index 0000000..25aa96c --- /dev/null +++ b/src/Raygun.Blazor/Models/PortableExecutableReaderExtensions.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection.PortableExecutable; + +namespace Raygun.Blazor.Models; + +internal static class PortableExecutableReaderExtensions +{ + // ReSharper disable once InconsistentNaming + public static PEReader? GetFileSystemPEReader(string moduleName) + { + try + { + // Read into memory to avoid any premature stream closures + var bytes = ImmutableArray.Create(File.ReadAllBytes(moduleName)); + return new PEReader(bytes); + } + catch (Exception ex) + { + Debug.WriteLine($"Could not open module [{moduleName}] from disk: {ex}"); + return null; + } + } + + public static bool TryGetDebugInformation(this PEReader? peReader, out PEDebugDetails? debugInformation) + { + if (peReader is null) + { + debugInformation = null; + return false; + } + + try + { + debugInformation = GetDebugInformation(peReader); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"Error reading PE Debug Data: {ex}"); + } + + debugInformation = null; + return false; + } + + private static PEDebugDetails GetDebugInformation(this PEReader peReader) + { + var debugInfo = new PEDebugDetails + { + Timestamp = $"{peReader.PEHeaders.CoffHeader.TimeDateStamp:X8}" + }; + + foreach (var entry in peReader.ReadDebugDirectory()) + { + if (entry.Type == DebugDirectoryEntryType.CodeView) + { + // Read the CodeView data + var codeViewData = peReader.ReadCodeViewDebugDirectoryData(entry); + + debugInfo.File = codeViewData.Path; + debugInfo.Signature = codeViewData.Guid.ToString(); + } + + if (entry.Type == DebugDirectoryEntryType.PdbChecksum) + { + var checksumEntry = peReader.ReadPdbChecksumDebugDirectoryData(entry); + var checksumHex = BitConverter.ToString(checksumEntry.Checksum.ToArray()).Replace("-", "").ToUpperInvariant(); + debugInfo.Checksum = $"{checksumEntry.AlgorithmName}:{checksumHex}"; + } + } + + return debugInfo; + } +} \ No newline at end of file From c26ea00745c3cbef339d79fc8ae8818b8c61f64d Mon Sep 17 00:00:00 2001 From: Miguel Beltran Date: Thu, 17 Oct 2024 11:04:49 +0200 Subject: [PATCH 3/9] code simplification --- src/Raygun.Blazor/Models/ErrorDetails.cs | 41 +++++++++--------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/src/Raygun.Blazor/Models/ErrorDetails.cs b/src/Raygun.Blazor/Models/ErrorDetails.cs index e4a5f7e..1404860 100644 --- a/src/Raygun.Blazor/Models/ErrorDetails.cs +++ b/src/Raygun.Blazor/Models/ErrorDetails.cs @@ -130,45 +130,36 @@ internal ErrorDetails(Exception ex) { InnerError = new ErrorDetails(betterEx.InnerException); } - } - - if (StackTrace != null) - { - // If we have a stack trace then grab the debug info images, and put them into an array - // for the outgoing payload - Images = GetDebugInfoForStackFrames(StackTrace); + + Images = GetDebugInfoForStackFrames(new EnhancedStackTrace(ex).GetExternalFrames()); } } - + #endregion - + #region Private Properties - + private static readonly ConcurrentDictionary DebugInformationCache = new(); - private static Func AssemblyReaderProvider { get; set; } = PortableExecutableReaderExtensions.GetFileSystemPEReader; - + + private static Func AssemblyReaderProvider { get; set; } = + PortableExecutableReaderExtensions.GetFileSystemPEReader; + #endregion #region Privatate Methods - private static List GetDebugInfoForStackFrames(IEnumerable frames) + private static List GetDebugInfoForStackFrames(IEnumerable frames) { - if (DebugInformationCache.IsEmpty) - { - return []; - } - - var imageMap = DebugInformationCache.Values.Where(x => x?.Signature != null).ToDictionary(k => k!.Signature!); var imageSet = new HashSet(); - + foreach (var stackFrame in frames) { - if (stackFrame.ImageSignature != null && imageMap.TryGetValue(stackFrame.ImageSignature, out var image)) + var methodBase = stackFrame.GetMethod(); + if (methodBase == null) continue; + var debugInformation = TryGetDebugInformation(methodBase.Module.FullyQualifiedName); + if (debugInformation != null) { - if (image != null) - { - imageSet.Add(image); - } + imageSet.Add(debugInformation); } } From 9768df79f7dee98ba06f39fc82a66808c6e289ad Mon Sep 17 00:00:00 2001 From: Miguel Beltran Date: Fri, 18 Oct 2024 09:58:37 +0200 Subject: [PATCH 4/9] Add stacktrace and image tests --- .../RaygunExceptionCatcherTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Raygun.Tests.Blazor.Server/RaygunExceptionCatcherTests.cs b/src/Raygun.Tests.Blazor.Server/RaygunExceptionCatcherTests.cs index f1d552a..724d3f9 100644 --- a/src/Raygun.Tests.Blazor.Server/RaygunExceptionCatcherTests.cs +++ b/src/Raygun.Tests.Blazor.Server/RaygunExceptionCatcherTests.cs @@ -9,6 +9,8 @@ using Raygun.Tests.Blazor.Server.Components; using Raygun.Blazor.Models; using FluentAssertions; +using Raygun.Tests.Blazor.Server.Components.Pages; +using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; namespace Raygun.Tests.Blazor.Server { @@ -108,6 +110,23 @@ public async Task RaygunExceptionCatcher_WhenExceptionIsThrown_ExceptionIsCaught // Check error details raygunMsg.Details!.Error!.Message.Should().Be("Captured error!"); + + // Check stacktrace + raygunMsg.Details!.Error!.StackTrace.Should().NotBeNullOrEmpty(); + + // First frame should be the exception origin + var frame = raygunMsg.Details!.Error!.StackTrace!.First(); + frame.ClassName.Should().Be("Raygun.Tests.Blazor.Server.Components.Pages.TestComponent"); + frame.ColumnNumber.Should().Be(9); + frame.LineNumber.Should().Be(11); + frame.MethodName.Should().Be("ThrowException"); + frame.FileName.Should().EndWith("TestComponent.razor"); + + // Check PE Debug Image data + raygunMsg.Details!.Error!.Images.Should().NotBeNullOrEmpty(); + + // First Image data should contain the path to the test server debug data + raygunMsg.Details.Error.Images!.First().File.Should().EndWith("Raygun.Tests.Blazor.Server.pdb"); } #endregion } From bd96f92418f4fe69a4ac591fa3fa724b91c3ffd3 Mon Sep 17 00:00:00 2001 From: Miguel Beltran Date: Fri, 18 Oct 2024 11:26:22 +0200 Subject: [PATCH 5/9] Update README.md --- README.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/README.md b/README.md index a90d1de..b1c845e 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,95 @@ This method accepts the following arguments: - `category`: A custom value used to arbitrarily group this Breadcrumb. - `customData`: Any custom data you want to record about application state when the Breadcrumb was recorded. +### Stack traces and portable debug data + +Raygun for Blazor attaches both stack traces and portable debug data automatically. + +#### Blazor stack traces + +Exceptions originating within the Blazor environment should contain a stack trace attached. +For details, check the "Raw Data" tab on the Raygun error report. +The stack trace is contained inside `error.stackTrace` property. + +For example: + +```json +"error": { + "className": "System.DivideByZeroException", + "message": "Attempted to divide by zero.", + "stackTrace": [ + { + "className": "Raygun.Samples.Blazor.Server.Components.Pages.Sample", + "columnNumber": 75, + "fileName": "...\\src\\Raygun.Samples.Blazor.Server\\Components\\Pages\\Sample.razor", + "ilOffset": 5, + "lineNumber": 13, + "methodName": "", + "methodToken": 100663352 + }, + // ... + ] +}, +``` + +This also works for exceptions originating in Blazor WebAssembly applications. +For example, this exception captured on WebAssembly: + +```json +"error": { + "className": "System.DivideByZeroException", + "message": "Attempted to divide by zero.", + "stackTrace": [ + { + "className": "Raygun.Samples.Blazor.WebAssembly.ViewModels.CounterViewModel", + "columnNumber": 17, + "fileName": "...\\src\\Raygun.Samples.Blazor.WebAssembly\\ViewModels\\CounterViewModel.cs", + "ilOffset": 34, + "lineNumber": 48, + "methodName": "IncrementCountAsync", + "methodToken": 100663343 + }, + // ... + ] +}, +``` + +#### JavaScript stack traces + +Exceptions happening in the JavaScript side of a Blazor application should contain a stack trace referring to the JavaScript code that caused the error. + +For example: + +``` +ReferenceError: undefinedfunction3 is not defined +at causeErrors (https://localhost:7254/myfunctions.js:10:9) +at window.onmessage (https://localhost:7254/:21:17) +``` + +#### Portable PDB debug data + +Raygun for Blazor supports debugging reports using Portable PDB files when running on Blazor Server and MAUI applications. +The necessary image information will be attached automatically to error reports. + +The debug data can be found in the `error.images` property: + +```json +"error": { + "className": "System.DivideByZeroException", + "images": [ + { + "signature": "a93d65be-ba53-4743-a1a5-4743716b7a42", + "checksum": "SHA256:BE653DA953BA4337A1A54743716B7A42A678F57568949BE3C375DB0BABF8EC35", + "file": "...\\src\\Raygun.Samples.Blazor.Server\\obj\\Debug\\net8.0\\Raygun.Samples.Blazor.Server.pdb", + "timestamp": "A1E84548" + }, + // ... + ], +}, +``` + +You can learn more about Portable PDB Support [on Raygun's .Net Framework documentation](https://raygun.com/documentation/language-guides/dotnet/crash-reporting/net-framework/#portable-pdb-support). + ### Environment details Raygun for Blazor captures environment details differently depending on the platform where the error originated. From 76da870c515091d743c3129b6511c89c17ece5dc Mon Sep 17 00:00:00 2001 From: Miguel Beltran Date: Mon, 21 Oct 2024 10:42:28 +0200 Subject: [PATCH 6/9] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b1c845e..3b326cd 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ This method accepts the following arguments: ### Stack traces and portable debug data -Raygun for Blazor attaches both stack traces and portable debug data automatically. +Raygun for Blazor attaches both stack traces and the necessary info for portable debug data automatically. #### Blazor stack traces @@ -151,9 +151,9 @@ at causeErrors (https://localhost:7254/myfunctions.js:10:9) at window.onmessage (https://localhost:7254/:21:17) ``` -#### Portable PDB debug data +#### Portable debug data (PDB) -Raygun for Blazor supports debugging reports using Portable PDB files when running on Blazor Server and MAUI applications. +Raygun for Blazor supports debugging reports using PDB files when running on Blazor Server and MAUI applications. The necessary image information will be attached automatically to error reports. The debug data can be found in the `error.images` property: From ace3dc4b0d9dc14879f9b148ba8c73786d7e0556 Mon Sep 17 00:00:00 2001 From: Miguel Beltran Date: Thu, 31 Oct 2024 10:22:05 +0100 Subject: [PATCH 7/9] include ImageSignature in stacktrace if available --- src/Raygun.Blazor/Models/ErrorDetails.cs | 2 +- src/Raygun.Blazor/Models/StackTraceDetails.cs | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Raygun.Blazor/Models/ErrorDetails.cs b/src/Raygun.Blazor/Models/ErrorDetails.cs index 1404860..ed88649 100644 --- a/src/Raygun.Blazor/Models/ErrorDetails.cs +++ b/src/Raygun.Blazor/Models/ErrorDetails.cs @@ -166,7 +166,7 @@ private static List GetDebugInfoForStackFrames(IEnumerable From 7e08972a5803029fc617dd926c06d9914bcb136a Mon Sep 17 00:00:00 2001 From: Miguel Beltran Date: Fri, 1 Nov 2024 10:49:37 +0100 Subject: [PATCH 8/9] add Android assembly reader --- .../Control/RaygunErrorBoundary.cs | 4 +- .../Extensions/MauiExtensions.cs | 16 +- .../Platforms/Android/AndroidUtilities.cs | 84 ++++ .../Android/AssemblyBlobStoreReader.cs | 465 ++++++++++++++++++ .../Platforms/Android/AssemblyReader.cs | 63 +++ .../Android/AssemblyZipEntryReader.cs | 62 +++ .../Platforms/Android/IAssemblyReader.cs | 13 + .../Raygun.Blazor.Maui.csproj | 1 + src/Raygun.Blazor/Models/ErrorDetails.cs | 2 +- src/Raygun.Blazor/Raygun.Blazor.csproj | 6 + 10 files changed, 706 insertions(+), 10 deletions(-) create mode 100644 src/Raygun.Blazor.Maui/Platforms/Android/AndroidUtilities.cs create mode 100644 src/Raygun.Blazor.Maui/Platforms/Android/AssemblyBlobStoreReader.cs create mode 100644 src/Raygun.Blazor.Maui/Platforms/Android/AssemblyReader.cs create mode 100644 src/Raygun.Blazor.Maui/Platforms/Android/AssemblyZipEntryReader.cs create mode 100644 src/Raygun.Blazor.Maui/Platforms/Android/IAssemblyReader.cs diff --git a/src/Raygun.Blazor.Maui/Control/RaygunErrorBoundary.cs b/src/Raygun.Blazor.Maui/Control/RaygunErrorBoundary.cs index 434bb10..8f03d3d 100644 --- a/src/Raygun.Blazor.Maui/Control/RaygunErrorBoundary.cs +++ b/src/Raygun.Blazor.Maui/Control/RaygunErrorBoundary.cs @@ -1,4 +1,6 @@ -using Microsoft.AspNetCore.Components; +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.Options; diff --git a/src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs b/src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs index 8f31b4d..ce7fd93 100644 --- a/src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs +++ b/src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs @@ -1,14 +1,9 @@ using KristofferStrube.Blazor.Window; using Microsoft.Extensions.Options; -using Raygun.Blazor; -using Raygun.Blazor.Interfaces; -using Raygun.Blazor.Offline; -using Raygun.Blazor.Offline.SendStrategy; using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.Hosting; +using Raygun.Blazor.Models; namespace Raygun.Blazor.Maui { @@ -16,6 +11,11 @@ public static class MauiExtensions { public static MauiAppBuilder UseRaygunBlazorMaui(this MauiAppBuilder builder, string configSectionName = "Raygun") { + #if ANDROID + // Replace default AssemblyReaderProvider with the Android Assembly reader from Raygun4Maui + ErrorDetails.AssemblyReaderProvider = AndroidUtilities.CreateAssemblyReader()!.TryGetReader; + #endif + builder.Services.Configure(builder.Configuration.GetSection(configSectionName)); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Raygun.Blazor.Maui/Platforms/Android/AndroidUtilities.cs b/src/Raygun.Blazor.Maui/Platforms/Android/AndroidUtilities.cs new file mode 100644 index 0000000..740c2c8 --- /dev/null +++ b/src/Raygun.Blazor.Maui/Platforms/Android/AndroidUtilities.cs @@ -0,0 +1,84 @@ +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Text; + +namespace Raygun.Blazor.Maui; + +internal static class AndroidUtilities +{ + /// + /// Factory method to create the correct assembly reader for the current application + /// + /// + public static IAssemblyReader? CreateAssemblyReader() + { + var apkPath = Android.App.Application.Context.ApplicationInfo?.SourceDir; + var supportedAbis = new List(); + + if (Android.OS.Build.SupportedAbis != null) + { + supportedAbis.AddRange(Android.OS.Build.SupportedAbis); + } + + if (!File.Exists(apkPath)) + { + // No apk, so return nothing + return null; + } + + if (!IsAndroidArchive(apkPath)) + { + // Not a valid android archive so nothing to return + return null; + } + + // Open the apk file, and see if it has a manifest, if it does, + // we are using the new assembly store method, + // else it's just a normal zip with assemblies as archive entries + using var zipArchive = ZipFile.Open(apkPath, ZipArchiveMode.Read); + + if (zipArchive.GetEntry("assemblies/assemblies.manifest") != null) + { + return new AssemblyBlobStoreReader(zipArchive, supportedAbis); + } + + return new AssemblyZipEntryReader(zipArchive, supportedAbis); + } + + public static bool IsAndroidArchive(string filePath) + { + return filePath.EndsWith(".aab", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + } + + public static ReadOnlyMemory AsReadOnlyMemory(this Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + using var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + return new ReadOnlyMemory(memoryStream.ToArray()); + } + + public static byte[] ToArray(this ReadOnlyMemory memory) + { + if (!MemoryMarshal.TryGetArray(memory, out var segment)) + { + throw new InvalidOperationException("Could not get array segment from ReadOnlyMemory."); + } + + return segment.Array!; + } + + public static BinaryReader GetBinaryReader(this ReadOnlyMemory memory, Encoding? encoding = null) + { + return new BinaryReader(memory.AsStream(), encoding ?? Encoding.UTF8, false); + } + + + public static MemoryStream AsStream(this ReadOnlyMemory memory) + { + return new MemoryStream(memory.ToArray(), writable: false); + } +} \ No newline at end of file diff --git a/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyBlobStoreReader.cs b/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyBlobStoreReader.cs new file mode 100644 index 0000000..a749ccd --- /dev/null +++ b/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyBlobStoreReader.cs @@ -0,0 +1,465 @@ +using System.Diagnostics; +using System.Globalization; +using System.IO.Compression; +using System.Reflection.PortableExecutable; +using System.Text; + +namespace Raygun.Blazor.Maui; + +internal class AssemblyBlobStoreReader : AssemblyReader +{ + private readonly AssemblyStoreExplorer _assemblyStoreExplorer; + + public AssemblyBlobStoreReader(ZipArchive zipArchive, List supportedAbis) + { + ArgumentNullException.ThrowIfNull(zipArchive); + + _assemblyStoreExplorer = new AssemblyStoreExplorer(zipArchive, supportedAbis); + } + + public override PEReader? TryGetReader(string moduleName) + { + var assembly = TryFindAssembly(moduleName); + + if (assembly is null) + { + return null; + } + + var bytes = assembly.GetImageData(); + + return CreatePEReader(bytes); + } + + private AssemblyStoreAssembly? TryFindAssembly(string name) + { + if (_assemblyStoreExplorer.AssembliesByName.TryGetValue(name, out var assembly)) + { + return assembly; + } + + // Try get the assembly without the module extension + var pathLessExtension = Path.GetFileNameWithoutExtension(name); + if (_assemblyStoreExplorer.AssembliesByName.TryGetValue(pathLessExtension, out assembly)) + { + return assembly; + } + + return null; + } + + public override void Dispose() + { + } + + + /// + /// https://github.com/xamarin/xamarin-android/blob/c92702619f5fabcff0ed88e09160baf9edd70f41/tools/assembly-store-reader/AssemblyStoreExplorer.cs + /// + private class AssemblyStoreExplorer + { + private readonly AssemblyStoreManifestReader _manifest; + private AssemblyStore? _indexStore; + + public SortedDictionary AssembliesByName { get; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary AssembliesByHash32 { get; } = new(); + + public Dictionary AssembliesByHash64 { get; } = new(); + + public SortedDictionary> Stores { get; } = new(); + + public AssemblyStoreExplorer(ZipArchive zipArchive, List supportedAbis) + { + using var manifestStream = zipArchive.GetEntry("assemblies/assemblies.manifest")!.Open(); + _manifest = new AssemblyStoreManifestReader(manifestStream); + + TryAddStore(zipArchive, null); + foreach (var abi in supportedAbis) + { + if (!string.IsNullOrEmpty(abi)) + { + TryAddStore(zipArchive, abi); + } + } + + ProcessStores(); + } + + private void TryAddStore(ZipArchive zipArchive, string? abi) + { + var storeLocation = abi is null + ? "assemblies/assemblies.blob" + : $"assemblies/assemblies.{abi}.blob"; + + if (zipArchive.GetEntry(storeLocation) is { } zipEntry) + { + using var entry = zipEntry.Open(); + var store = new AssemblyStore(entry.AsReadOnlyMemory(), abi); + + if (store.HasGlobalIndex) + { + _indexStore = store; + } + + if (!Stores.TryGetValue(store.StoreId, out var storeList)) + { + storeList = new List(); + Stores.Add(store.StoreId, storeList); + } + + storeList.Add(store); + } + } + + // This is lifted directly from the xamarin tools code base + // Effectively this code re-processes all the stores, finds the assemblies by index + // and attempts to add them to the hash and name dictionaries for faster lookups + private void ProcessStores() + { + if (Stores.Count == 0 || _indexStore == null) + { + return; + } + + ProcessIndex(_indexStore.GlobalIndex32, (he, assembly) => + { + assembly.Hash32 = (uint)he.Hash; + assembly.RuntimeIndex = he.MappingIndex; + + if (_manifest.EntriesByHash32.TryGetValue(assembly.Hash32, out var me)) + { + assembly.Name = me.Name; + } + + if (!AssembliesByHash32.ContainsKey(assembly.Hash32)) + { + AssembliesByHash32.Add(assembly.Hash32, assembly); + } + }); + + ProcessIndex(_indexStore.GlobalIndex64, (he, assembly) => + { + assembly.Hash64 = he.Hash; + if (assembly.RuntimeIndex != he.MappingIndex) + { + Debug.WriteLine($"assembly with hashes 0x{assembly.Hash32} and 0x{assembly.Hash64} has a different 32-bit runtime index ({assembly.RuntimeIndex}) than the 64-bit runtime index({he.MappingIndex})"); + } + + if (_manifest.EntriesByHash64.TryGetValue(assembly.Hash64, out var me)) + { + if (string.IsNullOrEmpty(assembly.Name)) + { + Debug.WriteLine($"32-bit hash 0x{assembly.Hash32:x} did not match any assembly name in the manifest"); + assembly.Name = me.Name; + if (string.IsNullOrEmpty(assembly.Name)) + { + Debug.WriteLine($"64-bit hash 0x{assembly.Hash64:x} did not match any assembly name in the manifest"); + } + } + else if (!string.Equals(assembly.Name, me.Name, StringComparison.Ordinal)) + { + Debug.WriteLine($"32-bit hash 0x{assembly.Hash32:x} maps to assembly name '{assembly.Name}', however 64-bit hash 0x{assembly.Hash64:x} for the same entry matches assembly name '{me.Name}'"); + } + } + + if (!AssembliesByHash64.ContainsKey(assembly.Hash64)) + { + AssembliesByHash64.Add(assembly.Hash64, assembly); + } + }); + + foreach (var kvp in Stores) + { + var list = kvp.Value; + if (list.Count < 2) + { + continue; + } + + var template = list[0]; + for (var i = 1; i < list.Count; i++) + { + var other = list[i]; + if (!template.HasIdenticalContent(other)) + { + throw new Exception($"Store ID {template.StoreId} for architecture {other.Arch} is not identical to other stores with the same ID"); + } + } + } + + return; + + void ProcessIndex(List index, Action assemblyHandler) + { + foreach (var he in index) + { + if (!Stores.TryGetValue(he.StoreId, out var storeList)) + { + continue; + } + + foreach (var store in storeList) + { + if (he.LocalStoreIndex >= (uint)store.Assemblies.Count) + { + continue; + } + + var assembly = store.Assemblies[(int)he.LocalStoreIndex]; + assemblyHandler(he, assembly); + + AssembliesByName.TryAdd(assembly.Name, assembly); + } + } + } + } + } + + // https://github.com/xamarin/xamarin-android/blob/main/tools/assembly-store-reader/AssemblyStoreManifestReader.cs + private class AssemblyStoreManifestReader + { + public List Entries { get; } = new(); + + public Dictionary EntriesByHash32 { get; } = new(); + + public Dictionary EntriesByHash64 { get; } = new(); + + public AssemblyStoreManifestReader(Stream manifest) + { + using var sr = new StreamReader(manifest, Encoding.UTF8, detectEncodingFromByteOrderMarks: false); + ReadManifest(sr); + } + + private void ReadManifest(StreamReader reader) + { + // First line is ignored, it contains headers + reader.ReadLine(); + + // Each subsequent line consists of fields separated with any number of spaces (for the pleasure of a human being reading the manifest) + while (!reader.EndOfStream) + { + var fields = reader.ReadLine()?.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (fields == null) + { + continue; + } + + var entry = new AssemblyStoreManifestEntry(fields); + Entries.Add(entry); + if (entry.Hash32 != 0) + { + EntriesByHash32.Add(entry.Hash32, entry); + } + + if (entry.Hash64 != 0) + { + EntriesByHash64.Add(entry.Hash64, entry); + } + } + } + } + + /// + /// https://github.com/xamarin/xamarin-android/blob/c92702619f5fabcff0ed88e09160baf9edd70f41/tools/assembly-store-reader/AssemblyStoreHashEntry.cs + /// + private class AssemblyStoreHashEntry + { + public bool Is32Bit { get; } + public ulong Hash { get; } + public uint MappingIndex { get; } + public uint LocalStoreIndex { get; } + public uint StoreId { get; } + + internal AssemblyStoreHashEntry(BinaryReader reader, bool is32Bit) + { + Is32Bit = is32Bit; + + Hash = reader.ReadUInt64(); + MappingIndex = reader.ReadUInt32(); + LocalStoreIndex = reader.ReadUInt32(); + StoreId = reader.ReadUInt32(); + } + } + + /// + /// https://github.com/xamarin/xamarin-android/blob/c92702619f5fabcff0ed88e09160baf9edd70f41/tools/assembly-store-reader/AssemblyStoreManifestEntry.cs + /// + private class AssemblyStoreManifestEntry + { + // Fields are: + // Hash 32 | Hash 64 | Store ID | Store idx | Name + private const int NumberOfFields = 5; + private const int Hash32FieldIndex = 0; + private const int Hash64FieldIndex = 1; + private const int StoreIdFieldIndex = 2; + private const int StoreIndexFieldIndex = 3; + private const int NameFieldIndex = 4; + + public uint Hash32 { get; } + public ulong Hash64 { get; } + public uint StoreId { get; } + public uint IndexInStore { get; } + public string Name { get; } + + public AssemblyStoreManifestEntry(string[] fields) + { + if (fields.Length != NumberOfFields) + { + throw new ArgumentOutOfRangeException(nameof(fields), "Invalid number of fields"); + } + + Hash32 = GetUInt32(fields[Hash32FieldIndex]); + Hash64 = GetUInt64(fields[Hash64FieldIndex]); + StoreId = GetUInt32(fields[StoreIdFieldIndex]); + IndexInStore = GetUInt32(fields[StoreIndexFieldIndex]); + Name = fields[NameFieldIndex].Trim(); + } + + private static uint GetUInt32(string value) + { + return uint.TryParse(PrepHexValue(value), NumberStyles.HexNumber, null, out uint hash) ? hash : 0; + } + + private static ulong GetUInt64(string value) + { + return ulong.TryParse(PrepHexValue(value), NumberStyles.HexNumber, null, out ulong hash) ? hash : 0; + } + + private static string PrepHexValue(string value) + { + return value.StartsWith("0x", StringComparison.Ordinal) ? value[2..] : value; + } + } + + // https://github.com/xamarin/xamarin-android/blob/main/tools/assembly-store-reader/AssemblyStoreAssembly.cs + private class AssemblyStoreAssembly + { + private readonly Func> _storeDataFunc; + public uint DataOffset { get; } + public uint DataSize { get; } + public uint DebugDataOffset { get; } + public uint DebugDataSize { get; } + public uint ConfigDataOffset { get; } + public uint ConfigDataSize { get; } + + public uint Hash32 { get; set; } + public ulong Hash64 { get; set; } + public string Name { get; set; } = String.Empty; + public uint RuntimeIndex { get; set; } + + public AssemblyStoreAssembly(BinaryReader reader, Func> storeDataFunc) + { + _storeDataFunc = storeDataFunc; + DataOffset = reader.ReadUInt32(); + DataSize = reader.ReadUInt32(); + DebugDataOffset = reader.ReadUInt32(); + DebugDataSize = reader.ReadUInt32(); + ConfigDataOffset = reader.ReadUInt32(); + ConfigDataSize = reader.ReadUInt32(); + } + + public ReadOnlyMemory GetImageData() + { + return _storeDataFunc(DataOffset, DataSize); + } + } + + // https://github.com/xamarin/xamarin-android/blob/main/tools/assembly-store-reader/AssemblyStoreReader.cs + private sealed class AssemblyStore + { + // These two constants must be identical to the native ones in src/monodroid/jni/xamarin-app.hh + private const uint AssemblyStoreMagic = 0x41424158; // 'XABA', little-endian + private const uint AssemblyStoreFormatVersion = 1; + + private readonly ReadOnlyMemory _storeMemory; + + public uint Version { get; private set; } + public uint LocalEntryCount { get; private set; } + public uint GlobalEntryCount { get; private set; } + public uint StoreId { get; private set; } + + public bool HasGlobalIndex => StoreId == 0; + public List Assemblies { get; } = new(); + public List GlobalIndex32 { get; } = new(); + public List GlobalIndex64 { get; } = new(); + + public string Arch { get; } + + public AssemblyStore(ReadOnlyMemory storeMemory, string? arch) + { + _storeMemory = storeMemory; + Arch = arch ?? string.Empty; + + // Need to use a reader here, because the reader advances through the memory + // and the structure is based off the current position + using var reader = _storeMemory.GetBinaryReader(); + ReadHeader(reader); + LoadAssemblies(reader); + + if (HasGlobalIndex) + { + ReadGlobalIndex(reader); + } + } + + private void ReadGlobalIndex(BinaryReader reader) + { + // Read 32 bit index + for (uint i = 0; i < GlobalEntryCount; i++) + { + GlobalIndex32.Add(new AssemblyStoreHashEntry(reader, true)); + } + + // Read 64 bit index + for (uint i = 0; i < GlobalEntryCount; i++) + { + GlobalIndex64.Add(new AssemblyStoreHashEntry(reader, false)); + } + } + + private void ReadHeader(BinaryReader reader) + { + if (reader.ReadUInt32() != AssemblyStoreMagic) + { + throw new InvalidOperationException("Invalid header magic number"); + } + + Version = reader.ReadUInt32(); + if (Version != AssemblyStoreFormatVersion) + { + throw new InvalidOperationException("Invalid Store Version"); + } + + LocalEntryCount = reader.ReadUInt32(); + GlobalEntryCount = reader.ReadUInt32(); + StoreId = reader.ReadUInt32(); + } + + private void LoadAssemblies(BinaryReader reader) + { + for (uint i = 0; i < LocalEntryCount; i++) + { + var assembly = new AssemblyStoreAssembly(reader, GetMemorySlice); + Assemblies.Add(assembly); + } + } + + private ReadOnlyMemory GetMemorySlice(uint offset, uint length) + { + return _storeMemory.Slice((int)offset, (int)length); + } + + public bool HasIdenticalContent(AssemblyStore other) + { + return + other.Version == Version && + other.LocalEntryCount == LocalEntryCount && + other.GlobalEntryCount == GlobalEntryCount && + other.StoreId == StoreId && + other.Assemblies.Count == Assemblies.Count && + other.GlobalIndex32.Count == GlobalIndex32.Count && + other.GlobalIndex64.Count == GlobalIndex64.Count; + } + } +} \ No newline at end of file diff --git a/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyReader.cs b/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyReader.cs new file mode 100644 index 0000000..660ea55 --- /dev/null +++ b/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyReader.cs @@ -0,0 +1,63 @@ +using System.Buffers.Binary; +using System.Collections.Immutable; +using System.Reflection.PortableExecutable; +using K4os.Compression.LZ4; + +namespace Raygun.Blazor.Maui; + +internal abstract class AssemblyReader : IAssemblyReader +{ + private const uint CompressedDataMagic = 0x5A4C4158; // 'XALZ', little-endian + + public virtual void Dispose() + { + } + + public abstract PEReader? TryGetReader(string moduleName); + + protected PEReader CreatePEReader(ReadOnlyMemory rawBytes) + { + // Try to decompress the array from LZ4 as it could be LZ4 compressed + var actualBytes = TryDecompressLZ4AssemblyBytes(rawBytes, out var decompressedBytes) + ? decompressedBytes + : rawBytes; + + return new PEReader(actualBytes.Span.ToImmutableArray()); + } + + // Decompression reference : https://github.com/xamarin/xamarin-android/blob/c92702619f5fabcff0ed88e09160baf9edd70f41/tools/decompress-assemblies/main.cs + private bool TryDecompressLZ4AssemblyBytes(ReadOnlyMemory encodedAssembly, out ReadOnlyMemory decompressedBytes) + { + var magicHeader = BinaryPrimitives.ReadUInt32LittleEndian(encodedAssembly.Span[..4]); + var decompressedLength = BinaryPrimitives.ReadInt32LittleEndian(encodedAssembly.Span[8..12]); + + if (magicHeader != CompressedDataMagic) + { + decompressedBytes = ReadOnlyMemory.Empty; + return false; + } + + // Skip the header and move to the compressed bytes + var assemblyBytes = encodedAssembly.Slice(12); + var decompressedArray = new byte[decompressedLength]; + + try + { + var actualDecompressedSize = LZ4Codec.Decode(assemblyBytes.Span, decompressedArray); + + if (actualDecompressedSize != decompressedLength) + { + throw new Exception($"Could not decompress bytes. Lengths do not match, expected {decompressedLength} but got {actualDecompressedSize}"); + } + + decompressedBytes = new ReadOnlyMemory(decompressedArray); + return true; + } + catch + { + decompressedBytes = ReadOnlyMemory.Empty; + } + + return false; + } +} \ No newline at end of file diff --git a/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyZipEntryReader.cs b/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyZipEntryReader.cs new file mode 100644 index 0000000..66f085d --- /dev/null +++ b/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyZipEntryReader.cs @@ -0,0 +1,62 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO.Compression; +using System.Reflection.PortableExecutable; + +namespace Raygun.Blazor.Maui; + +internal class AssemblyZipEntryReader : AssemblyReader +{ + private readonly ZipArchive _zipArchive; + private readonly List _supportedAbis; + + public AssemblyZipEntryReader(ZipArchive? zipArchive, List supportedAbis) + { + _zipArchive = zipArchive ?? throw new ArgumentNullException(nameof(zipArchive)); + _supportedAbis = supportedAbis; + } + + public override void Dispose() + { + _zipArchive.Dispose(); + } + + public override PEReader? TryGetReader(string moduleName) + { + // Check the file system first + if (File.Exists(moduleName)) + { + var peBytes = File.ReadAllBytes(moduleName).ToImmutableArray(); + return new PEReader(peBytes); + } + + var zipEntry = FindZipEntryForModule(moduleName); + if (zipEntry is null) + { + Debug.WriteLine("Cannot find module {0}", moduleName); + return null; + } + + using var zipStream = zipEntry.Open(); + return CreatePEReader(zipStream.AsReadOnlyMemory()); + } + + private ZipArchiveEntry? FindZipEntryForModule(string moduleName) + { + var zipEntry = _zipArchive.GetEntry($"assemblies/{moduleName}"); + + if (zipEntry is not null) + return zipEntry; + + foreach (var abi in _supportedAbis) + { + zipEntry = _zipArchive.GetEntry($"assemblies/{abi}/{moduleName}"); + if (zipEntry is not null) + { + return zipEntry; + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/Raygun.Blazor.Maui/Platforms/Android/IAssemblyReader.cs b/src/Raygun.Blazor.Maui/Platforms/Android/IAssemblyReader.cs new file mode 100644 index 0000000..539a26a --- /dev/null +++ b/src/Raygun.Blazor.Maui/Platforms/Android/IAssemblyReader.cs @@ -0,0 +1,13 @@ +using System.Reflection.PortableExecutable; + +namespace Raygun.Blazor.Maui; + +internal interface IAssemblyReader : IDisposable +{ + /// + /// Try to find the module, and create PEReader for the module if it exists + /// + /// + /// A reader for the PE or null + PEReader? TryGetReader(string moduleName); +} \ No newline at end of file diff --git a/src/Raygun.Blazor.Maui/Raygun.Blazor.Maui.csproj b/src/Raygun.Blazor.Maui/Raygun.Blazor.Maui.csproj index 8ccc39c..90d2fdf 100644 --- a/src/Raygun.Blazor.Maui/Raygun.Blazor.Maui.csproj +++ b/src/Raygun.Blazor.Maui/Raygun.Blazor.Maui.csproj @@ -46,6 +46,7 @@ + diff --git a/src/Raygun.Blazor/Models/ErrorDetails.cs b/src/Raygun.Blazor/Models/ErrorDetails.cs index ed88649..65d3b94 100644 --- a/src/Raygun.Blazor/Models/ErrorDetails.cs +++ b/src/Raygun.Blazor/Models/ErrorDetails.cs @@ -141,7 +141,7 @@ internal ErrorDetails(Exception ex) private static readonly ConcurrentDictionary DebugInformationCache = new(); - private static Func AssemblyReaderProvider { get; set; } = + internal static Func AssemblyReaderProvider { get; set; } = PortableExecutableReaderExtensions.GetFileSystemPEReader; #endregion diff --git a/src/Raygun.Blazor/Raygun.Blazor.csproj b/src/Raygun.Blazor/Raygun.Blazor.csproj index 964dc31..353e9b2 100644 --- a/src/Raygun.Blazor/Raygun.Blazor.csproj +++ b/src/Raygun.Blazor/Raygun.Blazor.csproj @@ -66,6 +66,12 @@ + + + <_Parameter1>Raygun.Blazor.Maui, $(StrongNamePublicKey) + + + <_Parameter1>Raygun.Tests.Blazor, $(StrongNamePublicKey) From 3c67f470817eb3d535981f1df5092265b83108db Mon Sep 17 00:00:00 2001 From: Miguel Beltran Date: Fri, 1 Nov 2024 11:11:49 +0100 Subject: [PATCH 9/9] format --- src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs | 6 +++--- .../Platforms/Android/AndroidUtilities.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs b/src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs index ce7fd93..e30323f 100644 --- a/src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs +++ b/src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs @@ -11,11 +11,11 @@ public static class MauiExtensions { public static MauiAppBuilder UseRaygunBlazorMaui(this MauiAppBuilder builder, string configSectionName = "Raygun") { - #if ANDROID +#if ANDROID // Replace default AssemblyReaderProvider with the Android Assembly reader from Raygun4Maui ErrorDetails.AssemblyReaderProvider = AndroidUtilities.CreateAssemblyReader()!.TryGetReader; - #endif - +#endif + builder.Services.Configure(builder.Configuration.GetSection(configSectionName)); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Raygun.Blazor.Maui/Platforms/Android/AndroidUtilities.cs b/src/Raygun.Blazor.Maui/Platforms/Android/AndroidUtilities.cs index 740c2c8..3781526 100644 --- a/src/Raygun.Blazor.Maui/Platforms/Android/AndroidUtilities.cs +++ b/src/Raygun.Blazor.Maui/Platforms/Android/AndroidUtilities.cs @@ -31,7 +31,7 @@ internal static class AndroidUtilities // Not a valid android archive so nothing to return return null; } - + // Open the apk file, and see if it has a manifest, if it does, // we are using the new assembly store method, // else it's just a normal zip with assemblies as archive entries