diff --git a/.github/workflows/device-tests-android.yml b/.github/workflows/device-tests-android.yml index 64749e1b8f..9a00195412 100644 --- a/.github/workflows/device-tests-android.yml +++ b/.github/workflows/device-tests-android.yml @@ -11,7 +11,12 @@ on: jobs: build: + name: Build (${{ matrix.tfm }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + tfm: [net8.0, net9.0] env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 DOTNET_NOLOGO: 1 @@ -32,18 +37,27 @@ jobs: uses: ./.github/actions/buildnative - name: Build Android Test App - run: pwsh ./scripts/device-test.ps1 android -Build + run: pwsh ./scripts/device-test.ps1 android -Build -Tfm ${{ matrix.tfm }} + + - name: Upload Android Test App (net8.0) + if: matrix.tfm == 'net8.0' + uses: actions/upload-artifact@v4 + with: + name: device-test-android-net8.0 + if-no-files-found: error + path: test/Sentry.Maui.Device.TestApp/bin/Release/net8.0-android/android-x64/io.sentry.dotnet.maui.device.testapp-Signed.apk - - name: Upload Android Test App + - name: Upload Android Test App (net9.0) + if: matrix.tfm == 'net9.0' uses: actions/upload-artifact@v4 with: - name: device-test-android + name: device-test-android-net9.0 if-no-files-found: error - path: test/Sentry.Maui.Device.TestApp/bin/Release/net8.0-android34.0/android-x64/io.sentry.dotnet.maui.device.testapp-Signed.apk + path: test/Sentry.Maui.Device.TestApp/bin/Release/net9.0-android/android-x64/io.sentry.dotnet.maui.device.testapp-Signed.apk android: needs: [build] - name: Run Android API-${{ matrix.api-level }} Test + name: Run Android API-${{ matrix.api-level }} Test (${{ matrix.tfm }}) # Requires a "larger runner", for nested virtualization support runs-on: ubuntu-latest-4-cores @@ -51,6 +65,7 @@ jobs: strategy: fail-fast: false matrix: + tfm: [net8.0, net9.0] # We run against both an older and a newer API api-level: [27, 33] env: @@ -70,7 +85,7 @@ jobs: - name: Download test app artifact uses: actions/download-artifact@v4 with: - name: device-test-android + name: device-test-android-${{ matrix.tfm }} path: bin - name: Setup Gradle @@ -78,7 +93,6 @@ jobs: # Cached AVD setup per https://github.com/ReactiveCircus/android-emulator-runner/blob/main/README.md - - name: Run Tests timeout-minutes: 40 uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # Tag: v2.33.0 @@ -92,11 +106,11 @@ jobs: disk-size: 4096M emulator-options: -no-snapshot-save -no-window -accel on -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: false - script: pwsh scripts/device-test.ps1 android -Run + script: pwsh scripts/device-test.ps1 android -Run -Tfm ${{ matrix.tfm }} - name: Upload results if: success() || failure() uses: actions/upload-artifact@v4 with: - name: device-test-android-${{ matrix.api-level }}-results + name: device-test-android-${{ matrix.api-level }}-${{ matrix.tfm }}-results path: test_output diff --git a/CHANGELOG.md b/CHANGELOG.md index bd7277914e..83528a33df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,16 @@ ## Unreleased +### Features + +- Exception.HResult is now included in the mechanism data for all exceptions ([#4029](https://github.com/getsentry/sentry-dotnet/pull/4029)) + ### Fixes +- Fixed symbolication and source context for net9.0-android ([#4033](https://github.com/getsentry/sentry-dotnet/pull/4033)) - Target `net9.0` on Sentry.Google.Cloud.Functions to avoid conflict with Sentry.AspNetCore ([#4039](https://github.com/getsentry/sentry-dotnet/pull/4039)) - Changed default value for `SentryOptions.EnableAppHangTrackingV2` to `false` ([#4042](https://github.com/getsentry/sentry-dotnet/pull/4042)) -### Features - -- Exception.HResult is now included in the mechanism data for all exceptions ([#4029](https://github.com/getsentry/sentry-dotnet/pull/4029)) - ### Dependencies - Bump CLI from v2.42.2 to v2.42.4 ([#4036](https://github.com/getsentry/sentry-dotnet/pull/4036), [#4049](https://github.com/getsentry/sentry-dotnet/pull/4049)) diff --git a/ELFPayloadError.cs b/ELFPayloadError.cs new file mode 100644 index 0000000000..932f151c80 --- /dev/null +++ b/ELFPayloadError.cs @@ -0,0 +1,11 @@ +namespace Xamarin.Android.AssemblyStore; + +enum ELFPayloadError +{ + None, + NotELF, + LoadFailed, + NotSharedLibrary, + NotLittleEndian, + NoPayloadSection, +} diff --git a/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj b/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj index 6cf009ec38..bdb8ed918a 100644 --- a/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj +++ b/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj @@ -9,7 +9,7 @@ - + diff --git a/scripts/device-test.ps1 b/scripts/device-test.ps1 index 6812c42dff..c9e03ffdd8 100644 --- a/scripts/device-test.ps1 +++ b/scripts/device-test.ps1 @@ -5,7 +5,8 @@ param( [String] $Platform, [Switch] $Build, - [Switch] $Run + [Switch] $Run, + [String] $Tfm ) Set-StrictMode -Version latest @@ -21,13 +22,16 @@ $CI = Test-Path env:CI Push-Location $PSScriptRoot/.. try { - $tfm = 'net8.0-' + if (!$Tfm) + { + $Tfm = 'net8.0' + } $arch = (!$IsWindows -and $(uname -m) -eq 'arm64') ? 'arm64' : 'x64' if ($Platform -eq 'android') { - $tfm += 'android34.0' + $Tfm += '-android' $group = 'android' - $buildDir = $CI ? 'bin' : "test/Sentry.Maui.Device.TestApp/bin/Release/$tfm/android-$arch" + $buildDir = $CI ? 'bin' : "test/Sentry.Maui.Device.TestApp/bin/Release/$Tfm/android-$arch" $arguments = @( '--app', "$buildDir/io.sentry.dotnet.maui.device.testapp-Signed.apk", '--package-name', 'io.sentry.dotnet.maui.device.testapp', @@ -43,11 +47,11 @@ try } elseif ($Platform -eq 'ios') { - $tfm += 'ios17.0' + $Tfm += '-ios' $group = 'apple' # Always use x64 on iOS, since arm64 doesn't support JIT, which is required for tests using NSubstitute $arch = 'x64' - $buildDir = "test/Sentry.Maui.Device.TestApp/bin/Release/$tfm/iossimulator-$arch" + $buildDir = "test/Sentry.Maui.Device.TestApp/bin/Release/$Tfm/iossimulator-$arch" $envValue = $CI ? 'true' : 'false' $arguments = @( '--app', "$buildDir/Sentry.Maui.Device.TestApp.app", @@ -60,7 +64,7 @@ try if ($Build) { # We disable AOT for device tests: https://github.com/nsubstitute/NSubstitute/issues/834 - dotnet build -f $tfm -c Release -p:EnableAot=false -p:NoSymbolStrip=true test/Sentry.Maui.Device.TestApp + dotnet build -f $Tfm -c Release -p:EnableAot=false -p:NoSymbolStrip=true test/Sentry.Maui.Device.TestApp if ($LASTEXITCODE -ne 0) { throw 'Failed to build Sentry.Maui.Device.TestApp' diff --git a/src/Sentry.Android.AssemblyReader/AndroidAssemblyReader.cs b/src/Sentry.Android.AssemblyReader/AndroidAssemblyReader.cs index 746b11cbe2..728c07e4ed 100644 --- a/src/Sentry.Android.AssemblyReader/AndroidAssemblyReader.cs +++ b/src/Sentry.Android.AssemblyReader/AndroidAssemblyReader.cs @@ -18,9 +18,9 @@ public void Dispose() ZipArchive.Dispose(); } - protected PEReader CreatePEReader(string assemblyName, MemoryStream inputStream) + internal static PEReader CreatePEReader(string assemblyName, MemoryStream inputStream, DebugLogger? logger) { - var decompressedStream = TryDecompressLZ4(assemblyName, inputStream); + var decompressedStream = TryDecompressLZ4(assemblyName, inputStream, logger); // Use the decompressed stream, or if null, i.e. it wasn't compressed, use the original. return new PEReader(decompressedStream ?? inputStream); @@ -35,7 +35,7 @@ protected PEReader CreatePEReader(string assemblyName, MemoryStream inputStream) /// [rest: lz4 compressed payload] /// /// - private Stream? TryDecompressLZ4(string assemblyName, MemoryStream inputStream) + private static Stream? TryDecompressLZ4(string assemblyName, MemoryStream inputStream, DebugLogger? logger) { const uint compressedDataMagic = 0x5A4C4158; // 'XALZ', little-endian const int payloadOffset = 12; @@ -51,7 +51,7 @@ protected PEReader CreatePEReader(string assemblyName, MemoryStream inputStream) Debug.Assert(inputStream.Position == payloadOffset); var inputLength = (int)(inputStream.Length - payloadOffset); - Logger?.Invoke("Decompressing assembly ({0} bytes uncompressed) using LZ4", decompressedLength); + logger?.Invoke("Decompressing assembly ({0} bytes uncompressed) using LZ4", decompressedLength); var outputStream = new MemoryStream(decompressedLength); diff --git a/src/Sentry.Android.AssemblyReader/AndroidAssemblyReaderFactory.cs b/src/Sentry.Android.AssemblyReader/AndroidAssemblyReaderFactory.cs index 399f8622e3..62670899a9 100644 --- a/src/Sentry.Android.AssemblyReader/AndroidAssemblyReaderFactory.cs +++ b/src/Sentry.Android.AssemblyReader/AndroidAssemblyReaderFactory.cs @@ -1,3 +1,6 @@ +using Sentry.Android.AssemblyReader.V1; +using Sentry.Android.AssemblyReader.V2; + namespace Sentry.Android.AssemblyReader; /// @@ -15,15 +18,29 @@ public static class AndroidAssemblyReaderFactory public static IAndroidAssemblyReader Open(string apkPath, IList supportedAbis, DebugLogger? logger = null) { logger?.Invoke("Opening APK: {0}", apkPath); - var zipArchive = ZipFile.Open(apkPath, ZipArchiveMode.Read); +#if NET9_0 + logger?.Invoke("Reading files using V2 APK layout."); + if (AndroidAssemblyStoreReaderV2.TryReadStore(apkPath, supportedAbis, logger, out var readerV2)) + { + logger?.Invoke("APK uses AssemblyStore V2"); + return readerV2; + } + + logger?.Invoke("APK doesn't use AssemblyStore"); + return new AndroidAssemblyDirectoryReaderV2(apkPath, supportedAbis, logger); +#else + logger?.Invoke("Reading files using V1 APK layout."); + + var zipArchive = ZipFile.OpenRead(apkPath); if (zipArchive.GetEntry("assemblies/assemblies.manifest") is not null) { - logger?.Invoke("APK uses AssemblyStore"); - return new AndroidAssemblyStoreReader(zipArchive, supportedAbis, logger); + logger?.Invoke("APK uses AssemblyStore V1"); + return new AndroidAssemblyStoreReaderV1(zipArchive, supportedAbis, logger); } logger?.Invoke("APK doesn't use AssemblyStore"); - return new AndroidAssemblyDirectoryReader(zipArchive, supportedAbis, logger); + return new AndroidAssemblyDirectoryReaderV1(zipArchive, supportedAbis, logger); +#endif } } diff --git a/src/Sentry.Android.AssemblyReader/Sentry.Android.AssemblyReader.csproj b/src/Sentry.Android.AssemblyReader/Sentry.Android.AssemblyReader.csproj index f64bf0f045..dac014317f 100644 --- a/src/Sentry.Android.AssemblyReader/Sentry.Android.AssemblyReader.csproj +++ b/src/Sentry.Android.AssemblyReader/Sentry.Android.AssemblyReader.csproj @@ -1,15 +1,12 @@ - netstandard2.0;net8.0;net9.0 + net8.0;net9.0 .NET assembly reader for Android - - - - + diff --git a/src/Sentry.Android.AssemblyReader/ATTRIBUTION.txt b/src/Sentry.Android.AssemblyReader/V1/ATTRIBUTION.txt similarity index 100% rename from src/Sentry.Android.AssemblyReader/ATTRIBUTION.txt rename to src/Sentry.Android.AssemblyReader/V1/ATTRIBUTION.txt diff --git a/src/Sentry.Android.AssemblyReader/AndroidAssemblyDirectoryReader.cs b/src/Sentry.Android.AssemblyReader/V1/AndroidAssemblyDirectoryReaderV1.cs similarity index 73% rename from src/Sentry.Android.AssemblyReader/AndroidAssemblyDirectoryReader.cs rename to src/Sentry.Android.AssemblyReader/V1/AndroidAssemblyDirectoryReaderV1.cs index b32a11112f..eaa000638f 100644 --- a/src/Sentry.Android.AssemblyReader/AndroidAssemblyDirectoryReader.cs +++ b/src/Sentry.Android.AssemblyReader/V1/AndroidAssemblyDirectoryReaderV1.cs @@ -1,9 +1,11 @@ -namespace Sentry.Android.AssemblyReader; +using Sentry.Android.AssemblyReader.V2; + +namespace Sentry.Android.AssemblyReader.V1; // The "Old" app type - where each DLL is placed in the 'assemblies' directory as an individual file. -internal sealed class AndroidAssemblyDirectoryReader : AndroidAssemblyReader, IAndroidAssemblyReader +internal sealed class AndroidAssemblyDirectoryReaderV1 : AndroidAssemblyReader, IAndroidAssemblyReader { - public AndroidAssemblyDirectoryReader(ZipArchive zip, IList supportedAbis, DebugLogger? logger) + public AndroidAssemblyDirectoryReaderV1(ZipArchive zip, IList supportedAbis, DebugLogger? logger) : base(zip, supportedAbis, logger) { } public PEReader? TryReadAssembly(string name) @@ -25,13 +27,8 @@ public AndroidAssemblyDirectoryReader(ZipArchive zip, IList supportedAbi Logger?.Invoke("Resolved assembly {0} in the APK at {1}", name, zipEntry.FullName); // We need a seekable stream for the PEReader (or even to check whether the DLL is compressed), so make a copy. - var memStream = new MemoryStream((int)zipEntry.Length); - using (var zipStream = zipEntry.Open()) - { - zipStream.CopyTo(memStream); - memStream.Position = 0; - } - return CreatePEReader(name, memStream); + var memStream = zipEntry.Extract(); + return CreatePEReader(name, memStream, Logger); } private ZipArchiveEntry? FindAssembly(string name) diff --git a/src/Sentry.Android.AssemblyReader/AndroidAssemblyStoreReader.cs b/src/Sentry.Android.AssemblyReader/V1/AndroidAssemblyStoreReaderV1.cs similarity index 98% rename from src/Sentry.Android.AssemblyReader/AndroidAssemblyStoreReader.cs rename to src/Sentry.Android.AssemblyReader/V1/AndroidAssemblyStoreReaderV1.cs index dbdb5451ba..914fe5c97a 100644 --- a/src/Sentry.Android.AssemblyReader/AndroidAssemblyStoreReader.cs +++ b/src/Sentry.Android.AssemblyReader/V1/AndroidAssemblyStoreReaderV1.cs @@ -1,11 +1,11 @@ -namespace Sentry.Android.AssemblyReader; +namespace Sentry.Android.AssemblyReader.V1; // See https://devblogs.microsoft.com/dotnet/performance-improvements-in-dotnet-maui/#single-file-assembly-stores -internal sealed class AndroidAssemblyStoreReader : AndroidAssemblyReader, IAndroidAssemblyReader +internal sealed class AndroidAssemblyStoreReaderV1 : AndroidAssemblyReader, IAndroidAssemblyReader { private readonly AssemblyStoreExplorer _explorer; - public AndroidAssemblyStoreReader(ZipArchive zip, IList supportedAbis, DebugLogger? logger) + public AndroidAssemblyStoreReaderV1(ZipArchive zip, IList supportedAbis, DebugLogger? logger) : base(zip, supportedAbis, logger) { _explorer = new(zip, supportedAbis, logger); @@ -29,7 +29,7 @@ public AndroidAssemblyStoreReader(ZipArchive zip, IList supportedAbis, D return null; } - return CreatePEReader(name, stream); + return CreatePEReader(name, stream, Logger); } private AssemblyStoreAssembly? TryFindAssembly(string name) diff --git a/src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt b/src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt new file mode 100644 index 0000000000..e782059c7d --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/ATTRIBUTION.txt @@ -0,0 +1,28 @@ +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 + +The original license is as follows: + +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Sentry.Android.AssemblyReader/V2/AndroidAssemblyDirectoryReaderV2.cs b/src/Sentry.Android.AssemblyReader/V2/AndroidAssemblyDirectoryReaderV2.cs new file mode 100644 index 0000000000..1ea64b2471 --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/AndroidAssemblyDirectoryReaderV2.cs @@ -0,0 +1,50 @@ +using Sentry.Android.AssemblyReader.V2; + +namespace Sentry.Android.AssemblyReader.V2; + +// The "Old" app type - where each DLL is placed in the 'assemblies' directory as an individual file. +internal sealed class AndroidAssemblyDirectoryReaderV2 : IAndroidAssemblyReader +{ + private DebugLogger? Logger { get; } + private HashSet SupportedArchitectures { get; } = new(); + private readonly ArchiveAssemblyHelper _archiveAssemblyHelper; + + public AndroidAssemblyDirectoryReaderV2(string apkPath, IList supportedAbis, DebugLogger? logger) + { + Logger = logger; + foreach (var abi in supportedAbis) + { + SupportedArchitectures.Add(abi.AbiToDeviceArchitecture()); + } + _archiveAssemblyHelper = new ArchiveAssemblyHelper(apkPath, logger, false); + } + + public PEReader? TryReadAssembly(string name) + { + if (File.Exists(name)) + { + // The assembly is already extracted to the file system. Just read it. + var stream = File.OpenRead(name); + return new PEReader(stream); + } + + foreach (var arch in SupportedArchitectures) + { + if (_archiveAssemblyHelper.ReadEntry($"assemblies/{name}", arch) is not { } memStream) + { + continue; + } + + Logger?.Invoke("Resolved assembly {0} in the APK", name); + return AndroidAssemblyReader.CreatePEReader(name, memStream, Logger); + } + + Logger?.Invoke("Couldn't find assembly {0} in the APK", name); + return null; + } + + public void Dispose() + { + // No-op + } +} diff --git a/src/Sentry.Android.AssemblyReader/V2/AndroidAssemblyStoreReaderV2.cs b/src/Sentry.Android.AssemblyReader/V2/AndroidAssemblyStoreReaderV2.cs new file mode 100644 index 0000000000..edcbd1e51e --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/AndroidAssemblyStoreReaderV2.cs @@ -0,0 +1,143 @@ +namespace Sentry.Android.AssemblyReader.V2; + +internal class AndroidAssemblyStoreReaderV2 : IAndroidAssemblyReader +{ + private readonly IList _explorers; + private readonly DebugLogger? _logger; + + private AndroidAssemblyStoreReaderV2(IList explorers, DebugLogger? logger) + { + _explorers = explorers; + _logger = logger; + } + + public static bool TryReadStore(string inputFile, IList supportedAbis, DebugLogger? logger, [NotNullWhen(true)] out AndroidAssemblyStoreReaderV2? reader) + { + var (explorers, errorMessage) = AssemblyStoreExplorer.Open(inputFile, logger); + if (errorMessage != null) + { + logger?.Invoke(errorMessage); + reader = null; + return false; + } + + List supportedExplorers = []; + if (explorers is not null) + { + foreach (var explorer in explorers) + { + if (explorer.TargetArch is null) + { + continue; + } + + foreach (var supportedAbi in supportedAbis) + { + if (supportedAbi.AbiToDeviceArchitecture() == explorer.TargetArch) + { + supportedExplorers.Add(explorer); + } + } + } + } + + if (supportedExplorers.Count == 0) + { + logger?.Invoke("Could not find V2 AssemblyStoreExplorer for the supported ABIs: {0}", string.Join(", ", supportedAbis)); + reader = null; + return false; + } + + reader = new AndroidAssemblyStoreReaderV2(supportedExplorers, logger); + return true; + } + + public PEReader? TryReadAssembly(string name) + { + var explorerAssembly = TryFindAssembly(name); + if (explorerAssembly is null) + { + _logger?.Invoke("Couldn't find assembly {0} in the APK AssemblyStore", name); + return null; + } + + var (explorer, storeItem) = explorerAssembly; + _logger?.Invoke("Resolved assembly {0} in the APK {1} AssemblyStore", name, storeItem.TargetArch); + + var stream = explorer.ReadImageData(storeItem, false); + if (stream is null) + { + _logger?.Invoke("Couldn't access assembly {0} image stream", name); + return null; + } + + return AndroidAssemblyReader.CreatePEReader(name, stream, _logger); + } + + private ExplorerStoreItem? TryFindAssembly(string name) + { + if (FindBestAssembly(name, out var assembly)) + { + return assembly; + } + + if (name.EndsWith(".dll", ignoreCase: true, CultureInfo.InvariantCulture) || + name.EndsWith(".exe", ignoreCase: true, CultureInfo.InvariantCulture)) + { + if (FindBestAssembly(name.Substring(0, name.Length - 4), out assembly)) + { + return assembly; + } + } + + return null; + } + + private bool FindBestAssembly(string name, out ExplorerStoreItem? explorerAssembly) + { + foreach (var explorer in _explorers) + { + if (explorer.AssembliesByName?.TryGetValue(name, out var assembly) is true) + { + explorerAssembly = new(explorer, assembly); + return true; + } + } + explorerAssembly = null; + return false; + } + + private record ExplorerStoreItem(AssemblyStoreExplorer Explorer, AssemblyStoreItem StoreItem); + + public void Dispose() + { + // No-op + } +} + +internal static class DeviceArchitectureExtensions +{ + public static bool IsSupportedBy(this Span abis, AndroidTargetArch targetArch) + { + foreach (var abi in abis) + { + if (abi.AbiToDeviceArchitecture() == targetArch) + { + return true; + } + } + + return false; + } + + public static AndroidTargetArch AbiToDeviceArchitecture(this string abi) => + abi switch + { + "armeabi-v7a" => AndroidTargetArch.Arm, + "arm64-v8a" => AndroidTargetArch.Arm64, + "x86" => AndroidTargetArch.X86, + "x86_64" => AndroidTargetArch.X86_64, + "mips" => AndroidTargetArch.Mips, + _ => AndroidTargetArch.Other, + }; +} diff --git a/src/Sentry.Android.AssemblyReader/V2/AndroidTargetArch.cs b/src/Sentry.Android.AssemblyReader/V2/AndroidTargetArch.cs new file mode 100644 index 0000000000..8564e04e6f --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/AndroidTargetArch.cs @@ -0,0 +1,18 @@ +/* + * Adapted from https://github.com/dotnet/android-tools/blob/ab2165daf27d4fcb29e88bc022e0ab0be33aff69/src/Xamarin.Android.Tools.AndroidSdk/AndroidTargetArch.cs + * Original code licensed under the MIT License (https://github.com/dotnet/android-tools/blob/ab2165daf27d4fcb29e88bc022e0ab0be33aff69/LICENSE) + */ + +namespace Sentry.Android.AssemblyReader.V2; + +[Flags] +internal enum AndroidTargetArch +{ + None = 0, + Arm = 1, + X86 = 2, + Mips = 4, + Arm64 = 8, + X86_64 = 16, + Other = 0x10000 // hope it's not too optimistic +} diff --git a/src/Sentry.Android.AssemblyReader/V2/ArchiveAssemblyHelper.cs b/src/Sentry.Android.AssemblyReader/V2/ArchiveAssemblyHelper.cs new file mode 100644 index 0000000000..7dae377b06 --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/ArchiveAssemblyHelper.cs @@ -0,0 +1,709 @@ +/* + * Adapted from https://github.com/dotnet/android/blob/6394773fad5108b0d7b4e6f087dc3e6ea997401a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/ArchiveAssemblyHelper.cs + * Original code licensed under the MIT License (https://github.com/dotnet/android-tools/blob/ab2165daf27d4fcb29e88bc022e0ab0be33aff69/LICENSE) + */ +namespace Sentry.Android.AssemblyReader.V2; + +internal class ArchiveAssemblyHelper +{ + private const string DefaultAssemblyStoreEntryPrefix = "{storeReader}"; + + private static readonly HashSet SpecialExtensions = new(StringComparer.OrdinalIgnoreCase) { + ".dll", + ".config", + ".pdb", + }; + + private static readonly ArrayPool Buffers = ArrayPool.Shared; + + private readonly string _archivePath; + private readonly DebugLogger? _logger; + private readonly string _assembliesRootDir; + private readonly bool _useAssemblyStores; + private List? _archiveContents; + + public ArchiveAssemblyHelper(string archivePath, DebugLogger? logger, bool useAssemblyStores = true) + { + if (string.IsNullOrEmpty(archivePath)) + { + throw new ArgumentException("must not be null or empty", nameof(archivePath)); + } + + _archivePath = archivePath; + _logger = logger; + _useAssemblyStores = useAssemblyStores; + + var extension = Path.GetExtension(archivePath) ?? string.Empty; + if (string.Compare(".aab", extension, StringComparison.OrdinalIgnoreCase) == 0) + { + _assembliesRootDir = "base/lib/"; + } + else if (string.Compare(".apk", extension, StringComparison.OrdinalIgnoreCase) == 0) + { + _assembliesRootDir = "lib/"; + } + else if (string.Compare(".zip", extension, StringComparison.OrdinalIgnoreCase) == 0) + { + _assembliesRootDir = "lib/"; + } + else + { + _assembliesRootDir = string.Empty; + } + } + + public MemoryStream? ReadEntry(string path, AndroidTargetArch arch = AndroidTargetArch.None, bool uncompressIfNecessary = false) + { + var ret = _useAssemblyStores + ? ReadStoreEntry(path, arch, uncompressIfNecessary) + : ReadZipEntry(path, arch); + + if (ret == null) + { + return null; + } + + ret.Flush(); + ret.Seek(0, SeekOrigin.Begin); + var (elfPayloadOffset, elfPayloadSize, error) = Utils.FindELFPayloadSectionOffsetAndSize(ret); + + if (error != ELFPayloadError.None) + { + var message = error switch + { + ELFPayloadError.NotELF => $"Entry '{path}' is not a valid ELF binary", + ELFPayloadError.LoadFailed => $"Entry '{path}' could not be loaded", + ELFPayloadError.NotSharedLibrary => $"Entry '{path}' is not a shared ELF library", + ELFPayloadError.NotLittleEndian => $"Entry '{path}' is not a little-endian ELF image", + ELFPayloadError.NoPayloadSection => $"Entry '{path}' does not contain the 'payload' section", + _ => $"Unknown ELF payload section error for entry '{path}': {error}" + }; + Console.WriteLine(message); + } + else + { + Console.WriteLine($"Extracted content from ELF image '{path}'"); + } + + if (elfPayloadOffset == 0) + { + ret.Seek(0, SeekOrigin.Begin); + return ret; + } + + // Make a copy of JUST the payload section, so that it contains only the data the tests expect and support + var payload = new MemoryStream(); + var data = Buffers.Rent(16384); + var toRead = data.Length; + var nRead = 0; + var remaining = elfPayloadSize; + + ret.Seek((long)elfPayloadOffset, SeekOrigin.Begin); + while (remaining > 0 && (nRead = ret.Read(data, 0, toRead)) > 0) + { + payload.Write(data, 0, nRead); + remaining -= (ulong)nRead; + + if (remaining < (ulong)data.Length) + { + // Make sure the last chunk doesn't gobble in more than we need + toRead = (int)remaining; + } + } + Buffers.Return(data); + + payload.Flush(); + ret.Dispose(); + + payload.Seek(0, SeekOrigin.Begin); + return payload; + } + + private MemoryStream? ReadZipEntry(string path, AndroidTargetArch arch) + { + var potentialEntries = TransformArchiveAssemblyPath(path, arch); + if (potentialEntries == null || potentialEntries.Count == 0) + { + return null; + } + + using var zip = ZipFile.OpenRead(_archivePath); + foreach (var assemblyPath in potentialEntries) + { + if (zip.GetEntry(assemblyPath) is not { } entry) + { + continue; + } + + var ret = entry.Extract(); + ret.Flush(); + return ret; + } + + return null; + } + + private MemoryStream? ReadStoreEntry(string path, AndroidTargetArch arch, bool uncompressIfNecessary) + { + var name = Path.GetFileNameWithoutExtension(path); + var (explorers, errorMessage) = AssemblyStoreExplorer.Open(_archivePath, _logger); + var explorer = SelectExplorer(explorers, arch); + if (explorer == null) + { + Console.WriteLine($"Failed to read assembly '{name}' from '{_archivePath}'. {errorMessage}"); + return null; + } + + if (arch == AndroidTargetArch.None) + { + if (explorer.TargetArch == null) + { + throw new InvalidOperationException($"Internal error: explorer should not have its TargetArch unset"); + } + + arch = (AndroidTargetArch)explorer.TargetArch; + } + + Console.WriteLine($"Trying to read store entry: {name}"); + var assemblies = explorer.Find(name, arch); + if (assemblies == null) + { + Console.WriteLine($"Failed to locate assembly '{name}' in assembly store for architecture '{arch}', in archive '{_archivePath}'"); + return null; + } + + AssemblyStoreItem? assembly = null; + foreach (var item in assemblies) + { + if (arch == AndroidTargetArch.None || item.TargetArch == arch) + { + assembly = item; + break; + } + } + + if (assembly == null) + { + Console.WriteLine($"Failed to find assembly '{name}' in assembly store for architecture '{arch}', in archive '{_archivePath}'"); + return null; + } + + return explorer.ReadImageData(assembly, uncompressIfNecessary); + } + + public List ListArchiveContents(string storeEntryPrefix = DefaultAssemblyStoreEntryPrefix, bool forceRefresh = false, AndroidTargetArch arch = AndroidTargetArch.None) + { + if (!forceRefresh && _archiveContents != null) + { + return _archiveContents; + } + + if (string.IsNullOrEmpty(storeEntryPrefix)) + { + throw new ArgumentException(nameof(storeEntryPrefix), "must not be null or empty"); + } + + var entries = new List(); + using (var zip = ZipFile.OpenRead(_archivePath)) + { + foreach (var entry in zip.Entries) + { + entries.Add(entry.FullName); + } + } + + _archiveContents = entries; + if (!_useAssemblyStores) + { + Console.WriteLine("Not using assembly stores"); + return entries; + } + + Console.WriteLine($"Creating AssemblyStoreExplorer for archive '{_archivePath}'"); + var (explorers, errorMessage) = AssemblyStoreExplorer.Open(_archivePath, _logger); + + if (arch == AndroidTargetArch.None) + { + if (explorers == null || explorers.Count == 0) + { + return entries; + } + + foreach (var explorer in explorers) + { + SynthetizeAssemblies(explorer); + } + } + else + { + SynthetizeAssemblies(SelectExplorer(explorers, arch)); + } + + Console.WriteLine("Archive entries with synthetised assembly storeReader entries:"); + foreach (string e in entries) + { + Console.WriteLine($" {e}"); + } + + return entries; + + void SynthetizeAssemblies(AssemblyStoreExplorer? explorer) + { + if (explorer == null) + { + return; + } + + Console.WriteLine($"Explorer for {explorer.TargetArch} found {explorer.AssemblyCount} assemblies"); + if (explorer.Assemblies is null) + { + return; + } + + foreach (var asm in explorer.Assemblies) + { + var prefix = storeEntryPrefix; + var abi = MonoAndroidHelper.ArchToAbi(asm.TargetArch); + prefix = $"{prefix}{abi}/"; + + var cultureIndex = asm.Name.IndexOf('/'); + string? culture = null; + string name; + + if (cultureIndex > 0) + { + culture = asm.Name.Substring(0, cultureIndex); + name = asm.Name.Substring(cultureIndex + 1); + } + else + { + name = asm.Name; + } + + // Mangle name in the same fashion the discrete assembly entries are named, makes other + // code in this class simpler. + var mangledName = MonoAndroidHelper.MakeDiscreteAssembliesEntryName(name, culture); + entries.Add($"{prefix}{mangledName}"); + if (asm.DebugOffset > 0) + { + mangledName = MonoAndroidHelper.MakeDiscreteAssembliesEntryName(Path.ChangeExtension(name, "pdb")); + entries.Add($"{prefix}{mangledName}"); + } + + if (asm.ConfigOffset > 0) + { + mangledName = MonoAndroidHelper.MakeDiscreteAssembliesEntryName(Path.ChangeExtension(name, "config")); + entries.Add($"{prefix}{mangledName}"); + } + } + } + } + + internal AssemblyStoreExplorer? SelectExplorer(IList? explorers, string rid) + { + return SelectExplorer(explorers, MonoAndroidHelper.RidToArch(rid)); + } + + internal AssemblyStoreExplorer? SelectExplorer(IList? explorers, AndroidTargetArch arch) + { + if (explorers == null || explorers.Count == 0) + { + return null; + } + + // If we don't care about target architecture, we check the first store, since all of them will have the same + // assemblies. Otherwise, we try to locate the correct store. + if (arch == AndroidTargetArch.None) + { + return explorers[0]; + } + + foreach (var e in explorers) + { + if (e.TargetArch == null || e.TargetArch != arch) + { + continue; + } + return e; + } + + Console.WriteLine($"Failed to find assembly store for architecture '{arch}' in archive '{_archivePath}'"); + return null; + } + + /// + /// Takes "old style" `assemblies/assembly.dll` path and returns (if possible) a set of paths that reflect the new + /// location of `lib/{ARCH}/assembly.dll.so`. A list is returned because, if `arch` is `None`, we'll return all + /// the possible architectural paths. + /// An exception is thrown if we cannot transform the path for some reason. It should **not** be handled. + /// + private static List? TransformArchiveAssemblyPath(string path, AndroidTargetArch arch) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentException(nameof(path), "must not be null or empty"); + } + + if (!path.StartsWith("assemblies/", StringComparison.Ordinal)) + { + return new List { path }; + } + + var parts = path.Split('/'); + if (parts.Length < 2) + { + throw new InvalidOperationException($"Path '{path}' must consist of at least two segments separated by `/`"); + } + + // We accept: + // assemblies/assembly.dll + // assemblies/{CULTURE}/assembly.dll + // assemblies/{ABI}/assembly.dll + // assemblies/{ABI}/{CULTURE}/assembly.dll + if (parts.Length > 4) + { + throw new InvalidOperationException($"Path '{path}' must not consist of more than 4 segments separated by `/`"); + } + + string? fileName = null; + string? culture = null; + string? abi = null; + + switch (parts.Length) + { + // Full satellite assembly path, with abi + case 4: + abi = parts[1]; + culture = parts[2]; + fileName = parts[3]; + break; + + // Assembly path with abi or culture + case 3: + // If the middle part isn't a valid abi, we treat it as a culture name + if (MonoAndroidHelper.IsValidAbi(parts[1])) + { + abi = parts[1]; + } + else + { + culture = parts[1]; + } + fileName = parts[2]; + break; + + // Assembly path without abi or culture + case 2: + fileName = parts[1]; + break; + } + + var fileTypeMarker = MonoAndroidHelper.MANGLED_ASSEMBLY_REGULAR_ASSEMBLY_MARKER; + var abis = new List(); + if (!string.IsNullOrEmpty(abi)) + { + abis.Add(abi); + } + else if (arch == AndroidTargetArch.None) + { + foreach (AndroidTargetArch targetArch in MonoAndroidHelper.SupportedTargetArchitectures) + { + abis.Add(MonoAndroidHelper.ArchToAbi(targetArch)); + } + } + else + { + abis.Add(MonoAndroidHelper.ArchToAbi(arch)); + } + + if (!string.IsNullOrEmpty(culture)) + { + // Android doesn't allow us to put satellite assemblies in lib/{CULTURE}/assembly.dll.so, we must instead + // mangle the name. + fileTypeMarker = MonoAndroidHelper.MANGLED_ASSEMBLY_SATELLITE_ASSEMBLY_MARKER; + fileName = $"{culture}{MonoAndroidHelper.SATELLITE_CULTURE_END_MARKER_CHAR}{fileName}"; + } + + var ret = new List(); + var newParts = new List { + string.Empty, // ABI placeholder + $"{fileTypeMarker}{fileName}.so", + }; + + foreach (var a in abis) + { + newParts[0] = a; + ret.Add(MonoAndroidHelper.MakeZipArchivePath("lib", newParts)); + } + + return ret; + } + + internal static bool ArchiveContains(List archiveContents, string entryPath, AndroidTargetArch arch) + { + if (archiveContents.Count == 0) + { + return false; + } + + var potentialEntries = TransformArchiveAssemblyPath(entryPath, arch); + if (potentialEntries == null || potentialEntries.Count == 0) + { + return false; + } + + foreach (var wantedEntry in potentialEntries) + { + Console.WriteLine($"Wanted entry: {wantedEntry}"); + foreach (var existingEntry in archiveContents) + { + if (string.Compare(existingEntry, wantedEntry, StringComparison.Ordinal) == 0) + { + return true; + } + } + } + + return false; + } + + /// + /// Checks whether exists in the archive or assembly store. The path should use the + /// "old style" `assemblies/{ABI}/assembly.dll` format. + /// + public bool Exists(string entryPath, bool forceRefresh = false, AndroidTargetArch arch = AndroidTargetArch.None) + { + return ArchiveContains(ListArchiveContents(_assembliesRootDir, forceRefresh), entryPath, arch); + } + + public void Contains(ICollection fileNames, out List existingFiles, out List missingFiles, out List additionalFiles, IEnumerable? targetArches = null) + { + if (fileNames == null) + { + throw new ArgumentNullException(nameof(fileNames)); + } + + if (fileNames.Count == 0) + { + throw new ArgumentException("must not be empty", nameof(fileNames)); + } + + if (_useAssemblyStores) + { + StoreContains(fileNames, out existingFiles, out missingFiles, out additionalFiles, targetArches); + } + else + { + ArchiveContains(fileNames, out existingFiles, out missingFiles, out additionalFiles, targetArches); + } + } + + private List GetSupportedArches(IEnumerable? runtimeIdentifiers) + { + var rids = new List(); + if (runtimeIdentifiers != null) + { + rids.AddRange(runtimeIdentifiers); + } + + if (rids.Count == 0) + { + rids.AddRange(MonoAndroidHelper.SupportedTargetArchitectures); + } + + return rids; + +<<<<<<< TODO: Unmerged change from project 'Sentry.Android.AssemblyReader(net9.0)', Before: + void ListFiles(List existingFiles, List missingFiles, List additionalFiles) +======= + private void ListFiles (List existingFiles, List missingFiles, List additionalFiles) +>>>>>>> After + } + + private void ListFiles(List existingFiles, List missingFiles, List additionalFiles) + { + Console.WriteLine("Archive contents:"); + ListFiles("existing files", existingFiles); + ListFiles("missing files", missingFiles); + ListFiles("additional files", additionalFiles); + + void ListFiles(string label, List list) + { + Console.WriteLine($" {label}:"); + if (list.Count == 0) + { + Console.WriteLine(" none"); + return; + } + + foreach (string file in list) + { + Console.WriteLine($" {file}"); + +<<<<<<< TODO: Unmerged change from project 'Sentry.Android.AssemblyReader(net9.0)', Before: + (string prefixAssemblies, string prefixLib) GetArchivePrefixes(string abi) => ($"{MonoAndroidHelper.MakeZipArchivePath(_assembliesRootDir, abi)}/", $"lib/{abi}/"); +======= + private (string prefixAssemblies, string prefixLib) GetArchivePrefixes (string abi) => ($"{MonoAndroidHelper.MakeZipArchivePath (_assembliesRootDir, abi)}/", $"lib/{abi}/"); +>>>>>>> After + } + } + } + + private (string prefixAssemblies, string prefixLib) GetArchivePrefixes(string abi) => ($"{MonoAndroidHelper.MakeZipArchivePath(_assembliesRootDir, abi)}/", $"lib/{abi}/"); + + internal void ArchiveContains(ICollection fileNames, out List existingFiles, out List missingFiles, out List additionalFiles, IEnumerable? targetArches = null) + { + using var zip = ZipFile.OpenRead(_archivePath); + existingFiles = zip.Entries.Where(a => a.FullName.StartsWith(_assembliesRootDir, StringComparison.InvariantCultureIgnoreCase)).Select(a => a.FullName).ToList(); + existingFiles.AddRange(zip.Entries.Where(a => a.FullName.StartsWith("lib/", StringComparison.OrdinalIgnoreCase)).Select(a => a.FullName)); + + var arches = GetSupportedArches(targetArches); + + missingFiles = []; + additionalFiles = []; + foreach (var arch in arches) + { + string abi = MonoAndroidHelper.ArchToAbi(arch); + missingFiles.AddRange(GetMissingFilesForAbi(abi)); + additionalFiles.AddRange(GetAdditionalFilesForAbi(abi, existingFiles)); + } + ListFiles(existingFiles, missingFiles, additionalFiles); + return; + + IEnumerable GetMissingFilesForAbi(string abi) + { + var (prefixAssemblies, prefixLib) = GetArchivePrefixes(abi); + return fileNames.Where(x => + { + string? culture = null; + var fileName = x; + var slashIndex = x.IndexOf('/'); + if (slashIndex > 0) + { + culture = x.Substring(0, slashIndex); + fileName = x.Substring(slashIndex + 1); + } + + return !zip.ContainsEntry(MonoAndroidHelper.MakeZipArchivePath(prefixAssemblies, x)) && + !zip.ContainsEntry(MonoAndroidHelper.MakeZipArchivePath(prefixLib, x)) && + !zip.ContainsEntry(MonoAndroidHelper.MakeZipArchivePath(prefixAssemblies, MonoAndroidHelper.MakeDiscreteAssembliesEntryName(fileName, culture))) && + !zip.ContainsEntry(MonoAndroidHelper.MakeZipArchivePath(prefixLib, MonoAndroidHelper.MakeDiscreteAssembliesEntryName(fileName, culture))); + }); + } + + IEnumerable GetAdditionalFilesForAbi(string abi, List existingFiles) + { + var (prefixAssemblies, prefixLib) = GetArchivePrefixes(abi); + return existingFiles.Where(x => !fileNames.Contains(x.Replace(prefixAssemblies, string.Empty)) && !fileNames.Contains(x.Replace(prefixLib, string.Empty))); + } + } + + internal void StoreContains(ICollection fileNames, out List existingFiles, out List missingFiles, out List additionalFiles, IEnumerable? targetArches = null) + { + var assemblyNames = fileNames.Where(x => x.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)).ToList(); + var configFiles = fileNames.Where(x => x.EndsWith(".config", StringComparison.OrdinalIgnoreCase)).ToList(); + var debugFiles = fileNames.Where(x => x.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase)).ToList(); + var otherFiles = fileNames.Where(x => !SpecialExtensions.Contains(Path.GetExtension(x))).ToList(); + + existingFiles = new List(); + missingFiles = new List(); + additionalFiles = new List(); + + using var zip = ZipFile.OpenRead(_archivePath); + + var arches = GetSupportedArches(targetArches); + var (explorers, errorMessage) = AssemblyStoreExplorer.Open(_archivePath, _logger); + + foreach (var arch in arches) + { + var explorer = SelectExplorer(explorers, arch); + if (explorer == null) + { + continue; + } + + if (otherFiles.Count > 0) + { + var (prefixAssemblies, prefixLib) = GetArchivePrefixes(MonoAndroidHelper.ArchToAbi(arch)); + + foreach (string file in otherFiles) + { + var fullPath = prefixAssemblies + file; + if (zip.ContainsEntry(fullPath)) + { + existingFiles.Add(file); + } + + fullPath = prefixLib + file; + if (zip.ContainsEntry(fullPath)) + { + existingFiles.Add(file); + } + } + } + + if (explorer.AssembliesByName is not null) + { + foreach (var f in explorer.AssembliesByName) + { + Console.WriteLine($"DEBUG!\tKey:{f.Key}"); + } + + if (explorer.AssembliesByName.Count != 0) + { + existingFiles.AddRange(explorer.AssembliesByName.Keys); + + // We need to fake config and debug files since they have no named entries in the storeReader + foreach (var file in configFiles) + { + var asm = GetStoreAssembly(explorer, file); + if (asm == null) + { + continue; + } + + if (asm.ConfigOffset > 0) + { + existingFiles.Add(file); + } + } + + foreach (string file in debugFiles) + { + var asm = GetStoreAssembly(explorer, file); + if (asm == null) + { + continue; + } + + if (asm.DebugOffset > 0) + { + existingFiles.Add(file); + } + } + } + } + } + + foreach (string file in fileNames) + { + if (existingFiles.Contains(file)) + { + continue; + } + missingFiles.Add(file); + } + + additionalFiles = existingFiles.Where(x => !fileNames.Contains(x)).ToList(); + ListFiles(existingFiles, missingFiles, additionalFiles); + return; + + AssemblyStoreItem? GetStoreAssembly(AssemblyStoreExplorer explorer, string file) + { + var assemblyName = Path.GetFileNameWithoutExtension(file); + return explorer.AssembliesByName?.TryGetValue(assemblyName, out var asm) is true + ? asm + : null; + } + } +} diff --git a/src/Sentry.Android.AssemblyReader/V2/AssemblyStoreExplorer.cs b/src/Sentry.Android.AssemblyReader/V2/AssemblyStoreExplorer.cs new file mode 100644 index 0000000000..01cd3e37fb --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/AssemblyStoreExplorer.cs @@ -0,0 +1,229 @@ +/* + * Adapted from https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/tools/assembly-store-reader-mk2/AssemblyStore/AssemblyStoreExplorer.cs + * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) + */ + +namespace Sentry.Android.AssemblyReader.V2; + +internal class AssemblyStoreExplorer +{ + private readonly AssemblyStoreReader reader; + + public string StorePath { get; } + public AndroidTargetArch? TargetArch { get; } + public uint AssemblyCount { get; } + public uint IndexEntryCount { get; } + public IList? Assemblies { get; } + public IDictionary? AssembliesByName { get; } + public bool Is64Bit { get; } + + protected AssemblyStoreExplorer(Stream storeStream, string path, DebugLogger? logger) + { + StorePath = path; + var storeReader = AssemblyStoreReader.Create(storeStream, path, logger); + if (storeReader == null) + { + storeStream.Dispose(); + throw new NotSupportedException($"Format of assembly store '{path}' is unsupported"); + } + + reader = storeReader; + TargetArch = reader.TargetArch; + AssemblyCount = reader.AssemblyCount; + IndexEntryCount = reader.IndexEntryCount; + Assemblies = reader.Assemblies; + Is64Bit = reader.Is64Bit; + + var dict = new Dictionary(StringComparer.Ordinal); + if (Assemblies is not null) + { + foreach (var item in Assemblies) + { + dict.Add(item.Name, item); + } + } + AssembliesByName = dict.AsReadOnly(); + } + + protected AssemblyStoreExplorer(FileInfo storeInfo, DebugLogger? logger) + : this(storeInfo.OpenRead(), storeInfo.FullName, logger) + { } + + public static (IList? explorers, string? errorMessage) Open(string inputFile, DebugLogger? logger) + { + (FileFormat format, FileInfo? info) = Utils.DetectFileFormat(inputFile); + if (info == null) + { + return (null, $"File '{inputFile}' does not exist."); + } + + switch (format) + { + case FileFormat.Unknown: + return (null, $"File '{inputFile}' has an unknown format."); + + case FileFormat.Zip: + return (null, $"File '{inputFile}' is a ZIP archive, but not an Android one."); + + case FileFormat.AssemblyStore: + case FileFormat.ELF: + return (new List { new AssemblyStoreExplorer(info, logger) }, null); + + case FileFormat.Aab: + return OpenAab(info, logger); + + case FileFormat.AabBase: + return OpenAabBase(info, logger); + + case FileFormat.Apk: + return OpenApk(info, logger); + + default: + return (null, $"File '{inputFile}' has an unsupported format '{format}'"); + } + } + + private static (IList? explorers, string? errorMessage) OpenAab(FileInfo fi, DebugLogger? logger) + { + return OpenCommon( + fi, + new List> { + StoreReaderV2.AabPaths, + StoreReader_V1.AabPaths, + }, + logger + ); + } + + private static (IList? explorers, string? errorMessage) OpenAabBase(FileInfo fi, DebugLogger? logger) + { + return OpenCommon( + fi, + new List> { + StoreReaderV2.AabBasePaths, + StoreReader_V1.AabBasePaths, + }, + logger + ); + } + + private static (IList? explorers, string? errorMessage) OpenApk(FileInfo fi, DebugLogger? logger) + { + return OpenCommon( + fi, + new List> { + StoreReaderV2.ApkPaths, + StoreReader_V1.ApkPaths, + }, + logger + ); + } + + private static (IList? explorers, string? errorMessage) OpenCommon(FileInfo fi, List> pathLists, DebugLogger? logger) + { + using var zip = ZipFile.OpenRead(fi.FullName); + + foreach (var paths in pathLists) + { + var (explorers, errorMessage, pathsFound) = TryLoad(fi, zip, paths, logger); + if (pathsFound) + { + return (explorers, errorMessage); + } + } + + return (null, "Unable to find any blob entries"); + } + + private static (IList? explorers, string? errorMessage, bool pathsFound) TryLoad(FileInfo fi, ZipArchive zip, IList paths, DebugLogger? logger) + { + var ret = new List(); + + foreach (var path in paths) + { + if (zip.GetEntry(path) is not { } entry) + { + continue; + } + + var stream = entry.Extract(); + ret.Add(new AssemblyStoreExplorer(stream, $"{fi.FullName}!{path}", logger)); + } + + if (ret.Count == 0) + { + return (null, null, false); + } + + return (ret, null, true); + } + + public MemoryStream? ReadImageData(AssemblyStoreItem item, bool uncompressIfNeeded = false) + { + return reader.ReadEntryImageData(item, uncompressIfNeeded); + } + + private string EnsureCorrectAssemblyName(string assemblyName) + { + assemblyName = Path.GetFileName(assemblyName); + if (reader.NeedsExtensionInName) + { + if (!assemblyName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + return $"{assemblyName}.dll"; + } + } + else + { + if (assemblyName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + return Path.GetFileNameWithoutExtension(assemblyName); + } + } + + return assemblyName; + } + + public IList? Find(string assemblyName, AndroidTargetArch? targetArch = null) + { + if (Assemblies == null) + { + return null; + } + + assemblyName = EnsureCorrectAssemblyName(assemblyName); + var items = new List(); + foreach (AssemblyStoreItem item in Assemblies) + { + if (string.CompareOrdinal(assemblyName, item.Name) != 0) + { + continue; + } + + if (targetArch != null && item.TargetArch != targetArch) + { + continue; + } + + items.Add(item); + } + + if (items.Count == 0) + { + return null; + } + + return items; + } + + public bool Contains(string assemblyName, AndroidTargetArch? targetArch = null) + { + IList? items = Find(assemblyName, targetArch); + if (items == null || items.Count == 0) + { + return false; + } + + return true; + } +} diff --git a/src/Sentry.Android.AssemblyReader/V2/AssemblyStoreItem.cs b/src/Sentry.Android.AssemblyReader/V2/AssemblyStoreItem.cs new file mode 100644 index 0000000000..a132f51ead --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/AssemblyStoreItem.cs @@ -0,0 +1,27 @@ +/* + * Adapted from https://github.com/dotnet/android/blob/86260ed36dfe1a90c8ed6a2bb1cd0607d637f403/tools/assembly-store-reader-mk2/AssemblyStore/AssemblyStoreItem.cs + * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) + */ + +namespace Sentry.Android.AssemblyReader.V2; + +internal abstract class AssemblyStoreItem +{ + public string Name { get; } + public IList Hashes { get; } + public bool Is64Bit { get; } + public uint DataOffset { get; protected set; } + public uint DataSize { get; protected set; } + public uint DebugOffset { get; protected set; } + public uint DebugSize { get; protected set; } + public uint ConfigOffset { get; protected set; } + public uint ConfigSize { get; protected set; } + public AndroidTargetArch TargetArch { get; protected set; } + + protected AssemblyStoreItem(string name, bool is64Bit, List hashes) + { + Name = name; + Hashes = hashes.AsReadOnly(); + Is64Bit = is64Bit; + } +} diff --git a/src/Sentry.Android.AssemblyReader/V2/AssemblyStoreReader.cs b/src/Sentry.Android.AssemblyReader/V2/AssemblyStoreReader.cs new file mode 100644 index 0000000000..856f7c35ea --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/AssemblyStoreReader.cs @@ -0,0 +1,93 @@ +/* + * Adapted from https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/tools/assembly-store-reader-mk2/AssemblyStore/AssemblyStoreReader.cs + * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) + */ + +namespace Sentry.Android.AssemblyReader.V2; + +internal abstract class AssemblyStoreReader +{ + protected DebugLogger? Logger { get; } + + private static readonly UTF8Encoding ReaderEncoding = new UTF8Encoding(false); + + protected Stream StoreStream { get; } + + public abstract string Description { get; } + public abstract bool NeedsExtensionInName { get; } + public string StorePath { get; } + + public AndroidTargetArch TargetArch { get; protected set; } = AndroidTargetArch.Arm; + public uint AssemblyCount { get; protected set; } + public uint IndexEntryCount { get; protected set; } + public IList? Assemblies { get; protected set; } + public bool Is64Bit { get; protected set; } + + protected AssemblyStoreReader(Stream store, string path, DebugLogger? logger) + { + StoreStream = store; + StorePath = path; + Logger = logger; + } + + public static AssemblyStoreReader? Create(Stream store, string path, DebugLogger? logger) + { + AssemblyStoreReader? reader = MakeReaderReady(new StoreReader_V1(store, path, logger)); + if (reader != null) + { + return reader; + } + + reader = MakeReaderReady(new StoreReaderV2(store, path, logger)); + if (reader != null) + { + return reader; + } + + return null; + } + + private static AssemblyStoreReader? MakeReaderReady(AssemblyStoreReader reader) + { + if (!reader.IsSupported()) + { + return null; + } + + reader.Prepare(); + return reader; + } + + protected BinaryReader CreateReader() => new BinaryReader(StoreStream, ReaderEncoding, leaveOpen: true); + + protected abstract bool IsSupported(); + protected abstract void Prepare(); + protected abstract ulong GetStoreStartDataOffset(); + + public MemoryStream ReadEntryImageData(AssemblyStoreItem entry, bool uncompressIfNeeded = false) + { + ulong startOffset = GetStoreStartDataOffset(); + StoreStream.Seek((uint)startOffset + entry.DataOffset, SeekOrigin.Begin); + var stream = new MemoryStream(); + + if (uncompressIfNeeded) + { + throw new NotImplementedException(); + } + + const long BufferSize = 65535; + byte[] buffer = Utils.BytePool.Rent((int)BufferSize); + long remainingToRead = entry.DataSize; + + while (remainingToRead > 0) + { + int nread = StoreStream.Read(buffer, 0, (int)Math.Min(BufferSize, remainingToRead)); + stream.Write(buffer, 0, nread); + remainingToRead -= (long)nread; + } + stream.Flush(); + stream.Seek(0, SeekOrigin.Begin); + + return stream; + } +} diff --git a/src/Sentry.Android.AssemblyReader/V2/ELFPayloadError.cs b/src/Sentry.Android.AssemblyReader/V2/ELFPayloadError.cs new file mode 100644 index 0000000000..b7644d68af --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/ELFPayloadError.cs @@ -0,0 +1,16 @@ +/* + * Adapted from https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/tools/assembly-store-reader-mk2/AssemblyStore/ELFPayloadError.cs + * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) + */ + +namespace Sentry.Android.AssemblyReader.V2; + +internal enum ELFPayloadError +{ + None, + NotELF, + LoadFailed, + NotSharedLibrary, + NotLittleEndian, + NoPayloadSection, +} diff --git a/src/Sentry.Android.AssemblyReader/V2/FileFormat.cs b/src/Sentry.Android.AssemblyReader/V2/FileFormat.cs new file mode 100644 index 0000000000..d1cef2ad05 --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/FileFormat.cs @@ -0,0 +1,17 @@ +/* + * Adapted from https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/tools/assembly-store-reader-mk2/AssemblyStore/FileFormat.cs + * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) + */ + +namespace Sentry.Android.AssemblyReader.V2; + +internal enum FileFormat +{ + Aab, + AabBase, + Apk, + AssemblyStore, + ELF, + Zip, + Unknown, +} diff --git a/src/Sentry.Android.AssemblyReader/V2/MonoAndroidHelper.Basic.cs b/src/Sentry.Android.AssemblyReader/V2/MonoAndroidHelper.Basic.cs new file mode 100644 index 0000000000..aa665e5dd9 --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/MonoAndroidHelper.Basic.cs @@ -0,0 +1,245 @@ +/* + * Adapted from https://github.com/dotnet/android/blob/3822f2b1ee7061813b1d456af22e043e66e2f698/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.Basic.cs + * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) + */ + +namespace Sentry.Android.AssemblyReader.V2; + +internal partial class MonoAndroidHelper +{ + public static class AndroidAbi + { + public const string Arm32 = "armeabi-v7a"; + public const string Arm64 = "arm64-v8a"; + public const string X86 = "x86"; + public const string X64 = "x86_64"; + } + + public static class RuntimeIdentifier + { + public const string Arm32 = "android-arm"; + public const string Arm64 = "android-arm64"; + public const string X86 = "android-x86"; + public const string X64 = "android-x64"; + } + + public static readonly HashSet SupportedTargetArchitectures = new HashSet { + AndroidTargetArch.Arm, + AndroidTargetArch.Arm64, + AndroidTargetArch.X86, + AndroidTargetArch.X86_64, + }; + private static readonly char[] ZipPathTrimmedChars = { '/', '\\' }; + private static readonly Dictionary ClangAbiMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { + {"arm64-v8a", "aarch64"}, + {"armeabi-v7a", "arm"}, + {"x86", "i686"}, + {"x86_64", "x86_64"} + }; + private static readonly Dictionary AbiToArchMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { + { AndroidAbi.Arm32, AndroidTargetArch.Arm }, + { AndroidAbi.Arm64, AndroidTargetArch.Arm64 }, + { AndroidAbi.X86, AndroidTargetArch.X86 }, + { AndroidAbi.X64, AndroidTargetArch.X86_64 }, + }; + private static readonly Dictionary AbiToRidMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { + { AndroidAbi.Arm32, RuntimeIdentifier.Arm32 }, + { AndroidAbi.Arm64, RuntimeIdentifier.Arm64 }, + { AndroidAbi.X86, RuntimeIdentifier.X86 }, + { AndroidAbi.X64, RuntimeIdentifier.X64 }, + }; + private static readonly Dictionary RidToAbiMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { + { RuntimeIdentifier.Arm32, AndroidAbi.Arm32 }, + { RuntimeIdentifier.Arm64, AndroidAbi.Arm64 }, + { RuntimeIdentifier.X86, AndroidAbi.X86 }, + { RuntimeIdentifier.X64, AndroidAbi.X64 }, + }; + private static readonly Dictionary RidToArchMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { + { RuntimeIdentifier.Arm32, AndroidTargetArch.Arm }, + { RuntimeIdentifier.Arm64, AndroidTargetArch.Arm64 }, + { RuntimeIdentifier.X86, AndroidTargetArch.X86 }, + { RuntimeIdentifier.X64, AndroidTargetArch.X86_64 }, + }; + private static readonly Dictionary ArchToRidMap = new Dictionary { + { AndroidTargetArch.Arm, RuntimeIdentifier.Arm32 }, + { AndroidTargetArch.Arm64, RuntimeIdentifier.Arm64 }, + { AndroidTargetArch.X86, RuntimeIdentifier.X86 }, + { AndroidTargetArch.X86_64, RuntimeIdentifier.X64 }, + }; + private static readonly Dictionary ArchToAbiMap = new Dictionary { + { AndroidTargetArch.Arm, AndroidAbi.Arm32 }, + { AndroidTargetArch.Arm64, AndroidAbi.Arm64 }, + { AndroidTargetArch.X86, AndroidAbi.X86 }, + { AndroidTargetArch.X86_64, AndroidAbi.X64 }, + }; + + public static AndroidTargetArch AbiToTargetArch(string abi) + { + if (!AbiToArchMap.TryGetValue(abi, out AndroidTargetArch arch)) + { + throw new NotSupportedException($"Internal error: unsupported ABI '{abi}'"); + } + ; + + return arch; + } + + public static string AbiToRid(string abi) + { + if (!AbiToRidMap.TryGetValue(abi, out var rid)) + { + throw new NotSupportedException($"Internal error: unsupported ABI '{abi}'"); + } + ; + + return rid; + } + + public static string RidToAbi(string rid) + { + if (!RidToAbiMap.TryGetValue(rid, out var abi)) + { + throw new NotSupportedException($"Internal error: unsupported Runtime Identifier '{rid}'"); + } + ; + + return abi; + } + + public static AndroidTargetArch RidToArchMaybe(string rid) + { + if (!RidToArchMap.TryGetValue(rid, out AndroidTargetArch arch)) + { + return AndroidTargetArch.None; + } + ; + + return arch; + } + + public static AndroidTargetArch RidToArch(string rid) + { + AndroidTargetArch arch = RidToArchMaybe(rid); + if (arch == AndroidTargetArch.None) + { + throw new NotSupportedException($"Internal error: unsupported Runtime Identifier '{rid}'"); + } + ; + + return arch; + } + + public static string ArchToRid(AndroidTargetArch arch) + { + if (!ArchToRidMap.TryGetValue(arch, out var rid)) + { + throw new InvalidOperationException($"Internal error: unsupported architecture '{arch}'"); + } + ; + + return rid; + } + + public static string ArchToAbi(AndroidTargetArch arch) + { + if (!ArchToAbiMap.TryGetValue(arch, out var abi)) + { + throw new InvalidOperationException($"Internal error: unsupported architecture '{arch}'"); + } + ; + + return abi; + } + + public static bool IsValidAbi(string abi) => AbiToRidMap.ContainsKey(abi); + public static bool IsValidRID(string rid) => RidToAbiMap.ContainsKey(rid); + + public static string? CultureInvariantToString(object? obj) + { + if (obj == null) + { + return null; + } + + return Convert.ToString(obj, CultureInfo.InvariantCulture); + } + + public static string? MapAndroidAbiToClang(string androidAbi) + { + if (ClangAbiMap.TryGetValue(androidAbi, out var clangAbi)) + { + return clangAbi; + } + return null; + } + + public static string MakeZipArchivePath(string part1, params string[]? pathParts) + { + return MakeZipArchivePath(part1, (ICollection?)pathParts); + } + + public static string MakeZipArchivePath(string part1, ICollection? pathParts) + { + var parts = new List(); + if (!string.IsNullOrEmpty(part1)) + { + parts.Add(part1.TrimEnd(ZipPathTrimmedChars)); + } + ; + + if (pathParts != null && pathParts.Count > 0) + { + foreach (string p in pathParts) + { + if (string.IsNullOrEmpty(p)) + { + continue; + } + parts.Add(p.TrimEnd(ZipPathTrimmedChars)); + } + } + + if (parts.Count == 0) + { + return string.Empty; + } + + return string.Join("/", parts); + } + + // These 3 MUST be the same as the like-named constants in src/monodroid/jni/shared-constants.hh + public const string MANGLED_ASSEMBLY_NAME_EXT = ".so"; + public const string MANGLED_ASSEMBLY_REGULAR_ASSEMBLY_MARKER = "lib_"; + public const string MANGLED_ASSEMBLY_SATELLITE_ASSEMBLY_MARKER = "lib-"; + public const string SATELLITE_CULTURE_END_MARKER_CHAR = "_"; + + /// + /// Mangles APK/AAB entry name for assembly and their associated pdb and config entries in the + /// way expected by our native runtime. Must **NOT** be used to mangle names when assembly stores + /// are used. Must **NOT** be used for entries other than assemblies and their associated files. + /// + public static string MakeDiscreteAssembliesEntryName(string name, string? culture = null) + { + if (!string.IsNullOrEmpty(culture)) + { + return $"{MANGLED_ASSEMBLY_SATELLITE_ASSEMBLY_MARKER}{culture}_{name}{MANGLED_ASSEMBLY_NAME_EXT}"; + } + + return $"{MANGLED_ASSEMBLY_REGULAR_ASSEMBLY_MARKER}{name}{MANGLED_ASSEMBLY_NAME_EXT}"; + } + + /// + /// Returns size of the extension + length of the prefix for mangled assembly names. This is + /// used to pre-allocate space for assembly names in `libxamarin-app.so` + /// + /// + public static ulong GetMangledAssemblyNameSizeOverhead() + { + // Satellite marker is one character more, for the `-` closing the culture part + return (ulong)MANGLED_ASSEMBLY_NAME_EXT.Length + + (ulong)Math.Max(MANGLED_ASSEMBLY_SATELLITE_ASSEMBLY_MARKER.Length + 1, MANGLED_ASSEMBLY_REGULAR_ASSEMBLY_MARKER.Length); + } + + public static byte[] Utf8StringToBytes(string str) => Encoding.UTF8.GetBytes(str); + public static byte[] Utf16StringToBytes(string str) => Encoding.Unicode.GetBytes(str); +} diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReaderV1.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReaderV1.cs new file mode 100644 index 0000000000..c1a3fe3c2d --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReaderV1.cs @@ -0,0 +1,38 @@ +/* + * Adapted from https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/tools/assembly-store-reader-mk2/AssemblyStore/StoreReader_V1.cs + * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) + */ + +namespace Sentry.Android.AssemblyReader.V2; + +internal class StoreReader_V1 : AssemblyStoreReader +{ + public override string Description => "Assembly store v1"; + public override bool NeedsExtensionInName => false; + + public static IList ApkPaths { get; } + public static IList AabPaths { get; } + public static IList AabBasePaths { get; } + + static StoreReader_V1() + { + ApkPaths = new List().AsReadOnly(); + AabPaths = new List().AsReadOnly(); + AabBasePaths = new List().AsReadOnly(); + } + + public StoreReader_V1(Stream store, string path, DebugLogger? logger) + : base(store, path, logger) + { } + + protected override bool IsSupported() + { + return false; + } + + protected override void Prepare() + { + } + + protected override ulong GetStoreStartDataOffset() => 0; +} diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReaderV2.Classes.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReaderV2.Classes.cs new file mode 100644 index 0000000000..b1ed2e392e --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReaderV2.Classes.cs @@ -0,0 +1,96 @@ +/* + * Adapted from https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/tools/assembly-store-reader-mk2/AssemblyStore/StoreReader_V2.Classes.cs + * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) + */ + +namespace Sentry.Android.AssemblyReader.V2; + +internal partial class StoreReaderV2 +{ + private sealed class Header + { + public const uint NativeSize = 5 * sizeof(uint); + + public readonly uint magic; + public readonly uint version; + public readonly uint entry_count; + public readonly uint index_entry_count; + + // 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) + { + this.magic = magic; + this.version = version; + this.entry_count = entry_count; + this.index_entry_count = index_entry_count; + this.index_size = index_size; + } + } + + private sealed class IndexEntry + { + public readonly ulong name_hash; + public readonly uint descriptor_index; + + public IndexEntry(ulong name_hash, uint descriptor_index) + { + this.name_hash = name_hash; + this.descriptor_index = descriptor_index; + } + } + + private sealed class EntryDescriptor + { + public uint mapping_index; + + public uint data_offset; + public uint data_size; + + public uint debug_data_offset; + public uint debug_data_size; + + public uint config_data_offset; + public uint config_data_size; + } + + private sealed class StoreItemV2 : AssemblyStoreItem + { + public StoreItemV2(AndroidTargetArch targetArch, string name, bool is64Bit, List indexEntries, EntryDescriptor descriptor) + : base(name, is64Bit, IndexToHashes(indexEntries)) + { + DataOffset = descriptor.data_offset; + DataSize = descriptor.data_size; + DebugOffset = descriptor.debug_data_offset; + DebugSize = descriptor.debug_data_size; + ConfigOffset = descriptor.config_data_offset; + ConfigSize = descriptor.config_data_size; + TargetArch = targetArch; + } + + private static List IndexToHashes(List indexEntries) + { + var ret = new List(); + foreach (IndexEntry ie in indexEntries) + { + ret.Add(ie.name_hash); + } + + return ret; + } + } + + private sealed class TemporaryItem + { + public readonly string Name; + public readonly List IndexEntries = new List(); + public readonly EntryDescriptor Descriptor; + + public TemporaryItem(string name, EntryDescriptor descriptor) + { + Name = name; + Descriptor = descriptor; + } + } +} diff --git a/src/Sentry.Android.AssemblyReader/V2/StoreReaderV2.cs b/src/Sentry.Android.AssemblyReader/V2/StoreReaderV2.cs new file mode 100644 index 0000000000..a60ade7e82 --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/StoreReaderV2.cs @@ -0,0 +1,241 @@ +/* + * Adapted from https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/tools/assembly-store-reader-mk2/AssemblyStore/StoreReader_V2.cs + * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) + */ + +namespace Sentry.Android.AssemblyReader.V2; + +internal partial class StoreReaderV2 : AssemblyStoreReader +{ + // Bit 31 is set for 64-bit platforms, cleared for the 32-bit ones + private const uint ASSEMBLY_STORE_FORMAT_VERSION_64BIT = 0x80000002; // Must match the ASSEMBLY_STORE_FORMAT_VERSION native constant + private const uint ASSEMBLY_STORE_FORMAT_VERSION_32BIT = 0x00000002; + private const uint ASSEMBLY_STORE_FORMAT_VERSION_MASK = 0xF0000000; + private const uint ASSEMBLY_STORE_ABI_AARCH64 = 0x00010000; + private const uint ASSEMBLY_STORE_ABI_ARM = 0x00020000; + private const uint ASSEMBLY_STORE_ABI_X64 = 0x00030000; + private const uint ASSEMBLY_STORE_ABI_X86 = 0x00040000; + private const uint ASSEMBLY_STORE_ABI_MASK = 0x00FF0000; + + public override string Description => "Assembly store v2"; + public override bool NeedsExtensionInName => true; + + public static IList ApkPaths { get; } + public static IList AabPaths { get; } + public static IList AabBasePaths { get; } + + private readonly HashSet supportedVersions; + private Header? header; + private ulong elfOffset = 0; + + static StoreReaderV2() + { + var paths = new List { + GetArchPath (AndroidTargetArch.Arm64), + GetArchPath (AndroidTargetArch.Arm), + GetArchPath (AndroidTargetArch.X86_64), + GetArchPath (AndroidTargetArch.X86), + }; + ApkPaths = paths.AsReadOnly(); + AabBasePaths = ApkPaths; + + const string AabBaseDir = "base"; + paths = new List { + GetArchPath (AndroidTargetArch.Arm64, AabBaseDir), + GetArchPath (AndroidTargetArch.Arm, AabBaseDir), + GetArchPath (AndroidTargetArch.X86_64, AabBaseDir), + GetArchPath (AndroidTargetArch.X86, AabBaseDir), + }; + AabPaths = paths.AsReadOnly(); + + string GetArchPath(AndroidTargetArch arch, string? root = null) + { + const string LibDirName = "lib"; + + string abi = MonoAndroidHelper.ArchToAbi(arch); + var parts = new List(); + if (!string.IsNullOrEmpty(root)) + { + parts.Add(LibDirName); + } + else + { + root = LibDirName; + } + parts.Add(abi); + parts.Add(GetBlobName(abi)); + + return MonoAndroidHelper.MakeZipArchivePath(root, parts); + } + } + + public StoreReaderV2(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, + }; + } + + private static string GetBlobName(string abi) => $"libassemblies.{abi}.blob.so"; + + protected override ulong GetStoreStartDataOffset() => elfOffset; + + protected override bool IsSupported() + { + StoreStream.Seek(0, SeekOrigin.Begin); + using var reader = CreateReader(); + + uint magic = reader.ReadUInt32(); + if (magic == Utils.ELF_MAGIC) + { + ELFPayloadError error; + (elfOffset, _, error) = Utils.FindELFPayloadSectionOffsetAndSize(StoreStream); + + if (error != ELFPayloadError.None) + { + string message = error switch + { + ELFPayloadError.NotELF => $"Store '{StorePath}' is not a valid ELF binary", + ELFPayloadError.LoadFailed => $"Store '{StorePath}' could not be loaded", + ELFPayloadError.NotSharedLibrary => $"Store '{StorePath}' is not a shared ELF library", + ELFPayloadError.NotLittleEndian => $"Store '{StorePath}' is not a little-endian ELF image", + ELFPayloadError.NoPayloadSection => $"Store '{StorePath}' does not contain the 'payload' section", + _ => $"Unknown ELF payload section error for store '{StorePath}': {error}" + }; + Logger?.Invoke(message); + // Was originally: + // ``` + // } else if (elfOffset >= 0) { + // ``` + // However since elfOffset is an ulong, it will never be less than 0 + } + else + { + StoreStream.Seek((long)elfOffset, SeekOrigin.Begin); + magic = reader.ReadUInt32(); + } + } + + if (magic != Utils.ASSEMBLY_STORE_MAGIC) + { + Logger?.Invoke("Store '{0}' has invalid header magic number.", StorePath); + return false; + } + + uint version = reader.ReadUInt32(); + if (!supportedVersions.Contains(version)) + { + Logger?.Invoke("Store '{0}' has unsupported version 0x{1:x}", StorePath, version); + return false; + } + + uint entry_count = reader.ReadUInt32(); + uint index_entry_count = reader.ReadUInt32(); + uint index_size = reader.ReadUInt32(); + + header = new Header(magic, version, entry_count, index_entry_count, index_size); + return true; + } + + protected override void Prepare() + { + if (header == null) + { + throw new InvalidOperationException("Internal error: header not set, was IsSupported() called?"); + } + + TargetArch = (header.version & ASSEMBLY_STORE_ABI_MASK) switch + { + ASSEMBLY_STORE_ABI_AARCH64 => AndroidTargetArch.Arm64, + ASSEMBLY_STORE_ABI_ARM => AndroidTargetArch.Arm, + ASSEMBLY_STORE_ABI_X64 => AndroidTargetArch.X86_64, + ASSEMBLY_STORE_ABI_X86 => AndroidTargetArch.X86, + _ => throw new NotSupportedException($"Unsupported ABI in store version: 0x{header.version:x}") + }; + + Is64Bit = (header.version & ASSEMBLY_STORE_FORMAT_VERSION_MASK) != 0; + AssemblyCount = header.entry_count; + IndexEntryCount = header.index_entry_count; + + StoreStream.Seek((long)elfOffset + Header.NativeSize, SeekOrigin.Begin); + using var reader = CreateReader(); + + var index = new List(); + for (uint i = 0; i < header.index_entry_count; i++) + { + ulong name_hash; + if (Is64Bit) + { + name_hash = reader.ReadUInt64(); + } + else + { + name_hash = (ulong)reader.ReadUInt32(); + } + + uint descriptor_index = reader.ReadUInt32(); + index.Add(new IndexEntry(name_hash, descriptor_index)); + } + + var descriptors = new List(); + for (uint i = 0; i < header.entry_count; i++) + { + uint mapping_index = reader.ReadUInt32(); + uint data_offset = reader.ReadUInt32(); + uint data_size = reader.ReadUInt32(); + uint debug_data_offset = reader.ReadUInt32(); + uint debug_data_size = reader.ReadUInt32(); + uint config_data_offset = reader.ReadUInt32(); + uint config_data_size = reader.ReadUInt32(); + + var desc = new EntryDescriptor + { + mapping_index = mapping_index, + data_offset = data_offset, + data_size = data_size, + debug_data_offset = debug_data_offset, + debug_data_size = debug_data_size, + config_data_offset = config_data_offset, + config_data_size = config_data_size, + }; + descriptors.Add(desc); + } + + var names = new List(); + for (uint i = 0; i < header.entry_count; i++) + { + uint name_length = reader.ReadUInt32(); + byte[] name_bytes = reader.ReadBytes((int)name_length); + names.Add(Encoding.UTF8.GetString(name_bytes)); + } + + var tempItems = new Dictionary(); + foreach (IndexEntry ie in index) + { + if (!tempItems.TryGetValue(ie.descriptor_index, out TemporaryItem? item)) + { + item = new TemporaryItem(names[(int)ie.descriptor_index], descriptors[(int)ie.descriptor_index]); + tempItems.Add(ie.descriptor_index, item); + } + item.IndexEntries.Add(ie); + } + + if (tempItems.Count != descriptors.Count) + { + throw new InvalidOperationException($"Assembly store '{StorePath}' index is corrupted."); + } + + var storeItems = new List(); + foreach (var kvp in tempItems) + { + TemporaryItem ti = kvp.Value; + var item = new StoreItemV2(TargetArch, ti.Name, Is64Bit, ti.IndexEntries, ti.Descriptor); + storeItems.Add(item); + } + Assemblies = storeItems.AsReadOnly(); + } +} diff --git a/src/Sentry.Android.AssemblyReader/V2/Utils.cs b/src/Sentry.Android.AssemblyReader/V2/Utils.cs new file mode 100644 index 0000000000..a8978e78e4 --- /dev/null +++ b/src/Sentry.Android.AssemblyReader/V2/Utils.cs @@ -0,0 +1,180 @@ +/* + * Adapted from https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/tools/assembly-store-reader-mk2/AssemblyStore/Utils.cs + * Original code licensed under the MIT License (https://github.com/dotnet/android/blob/5ebcb1dd1503648391e3c0548200495f634d90c6/LICENSE.TXT) + */ +using ELFSharp.ELF; +using ELFSharp.ELF.Sections; +using Machine = ELFSharp.ELF.Machine; + +namespace Sentry.Android.AssemblyReader.V2; + +internal static class Utils +{ + private static readonly string[] aabZipEntries = { + "base/manifest/AndroidManifest.xml", + "BundleConfig.pb", + }; + private static readonly string[] aabBaseZipEntries = { + "manifest/AndroidManifest.xml", + }; + private static readonly string[] apkZipEntries = { + "AndroidManifest.xml", + }; + + public const uint ZIP_MAGIC = 0x4034b50; + public const uint ASSEMBLY_STORE_MAGIC = 0x41424158; + public const uint ELF_MAGIC = 0x464c457f; + + public static readonly ArrayPool BytePool = ArrayPool.Shared; + + public static (ulong offset, ulong size, ELFPayloadError error) FindELFPayloadSectionOffsetAndSize(Stream stream) + { + stream.Seek(0, SeekOrigin.Begin); + Class elfClass = ELFReader.CheckELFType(stream); + if (elfClass == Class.NotELF) + { + return ReturnError(null, ELFPayloadError.NotELF); + } + + if (!ELFReader.TryLoad(stream, shouldOwnStream: false, out IELF? elf)) + { + return ReturnError(elf, ELFPayloadError.LoadFailed); + } + + if (elf.Type != FileType.SharedObject) + { + return ReturnError(elf, ELFPayloadError.NotSharedLibrary); + } + + if (elf.Endianess != ELFSharp.Endianess.LittleEndian) + { + return ReturnError(elf, ELFPayloadError.NotLittleEndian); + } + + if (!elf.TryGetSection("payload", out ISection? payloadSection)) + { + return ReturnError(elf, ELFPayloadError.NoPayloadSection); + } + + bool is64 = elf.Machine switch + { + Machine.ARM => false, + Machine.Intel386 => false, + + Machine.AArch64 => true, + Machine.AMD64 => true, + + _ => throw new NotSupportedException($"Unsupported ELF architecture '{elf.Machine}'") + }; + + ulong offset; + ulong size; + + if (is64) + { + (offset, size) = GetOffsetAndSize64((Section)payloadSection); + } + else + { + (offset, size) = GetOffsetAndSize32((Section)payloadSection); + } + + elf.Dispose(); + return (offset, size, ELFPayloadError.None); + + (ulong offset, ulong size) GetOffsetAndSize64(Section payload) + { + return (payload.Offset, payload.Size); + } + + (ulong offset, ulong size) GetOffsetAndSize32(Section payload) + { + return ((ulong)payload.Offset, (ulong)payload.Size); + } + + (ulong offset, ulong size, ELFPayloadError error) ReturnError(IELF? elf, ELFPayloadError error) + { + elf?.Dispose(); + + return (0, 0, error); + } + } + + public static (FileFormat format, FileInfo? info) DetectFileFormat(string path) + { + if (string.IsNullOrEmpty(path)) + { + return (FileFormat.Unknown, null); + } + + var info = new FileInfo(path); + if (!info.Exists) + { + return (FileFormat.Unknown, null); + } + + using var reader = new BinaryReader(info.OpenRead()); + + // ATM, all formats we recognize have 4-byte magic at the start + FileFormat format = reader.ReadUInt32() switch + { + Utils.ZIP_MAGIC => FileFormat.Zip, + Utils.ELF_MAGIC => FileFormat.ELF, + Utils.ASSEMBLY_STORE_MAGIC => FileFormat.AssemblyStore, + _ => FileFormat.Unknown + }; + + if (format == FileFormat.Unknown || format != FileFormat.Zip) + { + return (format, info); + } + + return (DetectAndroidArchive(info, format), info); + } + + private static FileFormat DetectAndroidArchive(FileInfo info, FileFormat defaultFormat) + { + using var zip = ZipFile.OpenRead(info.FullName); + + if (HasAllEntries(zip, aabZipEntries)) + { + return FileFormat.Aab; + } + + if (HasAllEntries(zip, apkZipEntries)) + { + return FileFormat.Apk; + } + + if (HasAllEntries(zip, aabBaseZipEntries)) + { + return FileFormat.AabBase; + } + + return defaultFormat; + } + + private static bool HasAllEntries(ZipArchive zip, string[] entries) + { + foreach (var entry in entries) + { + if (zip.GetEntry(entry) is null) + { + return false; + } + } + + return true; + } + + internal static MemoryStream Extract(this ZipArchiveEntry zipEntry) + { + var memStream = new MemoryStream((int)zipEntry.Length); + using var zipStream = zipEntry.Open(); + zipStream.CopyTo(memStream); + memStream.Position = 0; + return memStream; + } + + internal static bool ContainsEntry(this ZipArchive zip, string entry) => zip.GetEntry(entry) is not null; +} diff --git a/test/AndroidTestApp/AndroidTestApp.csproj b/test/AndroidTestApp/AndroidTestApp.csproj index 15dedbe4c9..3d8a1981e1 100644 --- a/test/AndroidTestApp/AndroidTestApp.csproj +++ b/test/AndroidTestApp/AndroidTestApp.csproj @@ -1,6 +1,7 @@ - net8.0-android34.0 + net8.0-android;net9.0-android + false 21 Exe enable diff --git a/test/Directory.Build.props b/test/Directory.Build.props index d6cc562cef..4cc80884d9 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -46,7 +46,7 @@ - + diff --git a/test/Sentry.Android.AssemblyReader.Tests/AndroidAssemblyReaderTests.cs b/test/Sentry.Android.AssemblyReader.Tests/AndroidAssemblyReaderTests.cs index 4b98a43257..7eae0392ce 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/AndroidAssemblyReaderTests.cs +++ b/test/Sentry.Android.AssemblyReader.Tests/AndroidAssemblyReaderTests.cs @@ -1,9 +1,20 @@ +using Sentry.Android.AssemblyReader.V1; + namespace Sentry.Android.AssemblyReader.Tests; public class AndroidAssemblyReaderTests { private readonly ITestOutputHelper _output; +#if NET9_0 + private static string TargetFramework => "net9.0"; +#elif NET8_0 + private static string TargetFramework => "net8.0"; +#else + // Adding a new TFM to the project? Include it above +#error "Target Framework not yet supported for AndroidAssemblyReader" +#endif + public AndroidAssemblyReaderTests(ITestOutputHelper output) { _output = output; @@ -19,7 +30,7 @@ private IAndroidAssemblyReader GetSut(bool isAssemblyStore, bool isCompressed) Path.GetFullPath(Path.Combine( Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "..", "..", "..", "TestAPKs", - $"android-Store={isAssemblyStore}-Compressed={isCompressed}.apk")); + $"{TargetFramework}-android-Store={isAssemblyStore}-Compressed={isCompressed}.apk")); _output.WriteLine($"Checking if APK exists: {apkPath}"); File.Exists(apkPath).Should().BeTrue(); @@ -39,13 +50,17 @@ public void CreatesCorrectReader(bool isAssemblyStore) Skip.If(true, "It's unknown whether the current Android app APK is an assembly store or not."); #endif using var sut = GetSut(isAssemblyStore, isCompressed: true); - if (isAssemblyStore) + if (isAssemblyStore && TargetFramework == "net9.0") + { + Assert.IsType(sut); + } + else if (isAssemblyStore && TargetFramework == "net8.0") { - Assert.IsType(sut); + Assert.IsType(sut); } else { - Assert.IsType(sut); + Assert.IsType(sut); } } diff --git a/test/Sentry.Android.AssemblyReader.Tests/ApiApprovalTests.verify.cs b/test/Sentry.Android.AssemblyReader.Tests/ApiApprovalTests.verify.cs index 32bcf57cb7..53706496bf 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/ApiApprovalTests.verify.cs +++ b/test/Sentry.Android.AssemblyReader.Tests/ApiApprovalTests.verify.cs @@ -1,3 +1,5 @@ +using Sentry.Android.AssemblyReader.V1; + namespace Sentry.Android.AssemblyReader.Tests; public class ApiApprovalTests 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 d6dd9fc202..a160cd85cd 100644 --- a/test/Sentry.Android.AssemblyReader.Tests/Sentry.Android.AssemblyReader.Tests.csproj +++ b/test/Sentry.Android.AssemblyReader.Tests/Sentry.Android.AssemblyReader.Tests.csproj @@ -2,7 +2,8 @@ net9.0;net8.0 - $(TargetFrameworks);net8.0-android34.0 + + $(TargetFrameworks);net8.0-android34.0;net9.0-android35.0 enable @@ -13,10 +14,11 @@ + + - @@ -26,8 +28,8 @@ <_ConfigString>Store=$(_Store)-Compressed=$(_Compressed) - - + + diff --git a/test/Sentry.Extensions.Logging.Tests/Sentry.Extensions.Logging.Tests.csproj b/test/Sentry.Extensions.Logging.Tests/Sentry.Extensions.Logging.Tests.csproj index 947d10b537..7b0d03edad 100644 --- a/test/Sentry.Extensions.Logging.Tests/Sentry.Extensions.Logging.Tests.csproj +++ b/test/Sentry.Extensions.Logging.Tests/Sentry.Extensions.Logging.Tests.csproj @@ -2,9 +2,9 @@ net9.0;net8.0;net48 - $(TargetFrameworks);net8.0-android34.0 - $(TargetFrameworks);net8.0-ios17.0 - $(TargetFrameworks);net8.0-maccatalyst17.0 + $(TargetFrameworks);net8.0-android34.0;net9.0-android35.0 + $(TargetFrameworks);net8.0-ios17.0;net9.0-ios18.0 + $(TargetFrameworks);net8.0-maccatalyst17.0;net9.0-maccatalyst18.0 diff --git a/test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj b/test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj index 5953b65d70..f08b24c8c4 100644 --- a/test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj +++ b/test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj @@ -2,8 +2,8 @@ - $(TargetFrameworks);net8.0-android34.0 - $(TargetFrameworks);net8.0-ios17.0 + $(TargetFrameworks);net8.0-android;net9.0-android + $(TargetFrameworks);net8.0-ios;net9.0-ios true