From 5452258611abedc92dae37d3063f3b8111ff1b93 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 28 Aug 2026 16:07:41 +0200 Subject: [PATCH 01/14] Prototype rewriting JNI names from R8 mappings Rebuild managed PE metadata and IL with obfuscated JNI class, method, field, descriptor, RegisterNatives, and FieldRVA string data. Preserve compression descriptor ordering when rewritten assembly sizes change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...teCompressedAssembliesNativeSourceFiles.cs | 67 + .../Tasks/RewriteJniNamesForR8.cs | 93 ++ .../Tasks/RewriteJniNamesForR8Tests.cs | 154 ++ .../JniRemapping/JniAssemblyRewriterTests.cs | 1383 +++++++++++++++++ .../JniRemapping/JniDescriptorTextTests.cs | 77 + .../JniRemapping/JniFixtureBuilder.cs | 267 ++++ .../JniRemapping/LdstrRewriterTests.cs | 109 ++ .../NativeResourceSectionCopierTests.cs | 124 ++ .../Utilities/JniRemapping/R8MappingTests.cs | 217 +++ .../JniRemapping/AssemblyRebuilder.cs | 1065 +++++++++++++ .../CustomAttributeStringRewriter.cs | 78 + .../Utilities/JniRemapping/FieldRvaTable.cs | 221 +++ .../Utilities/JniRemapping/IlOpcodeTable.cs | 101 ++ .../JniRemapping/JniAssemblyRewriter.cs | 59 + .../JniRemapping/JniDescriptorText.cs | 210 +++ .../JniRemapping/JniRewriteException.cs | 22 + .../Utilities/JniRemapping/JniRewritePlan.cs | 119 ++ .../JniRemapping/JniRewritePlanner.cs | 544 +++++++ .../Utilities/JniRemapping/LdstrRewriter.cs | 170 ++ .../JniRemapping/MetadataEncoding.cs | 74 + .../JniRemapping/MetadataRawColumns.cs | 30 + .../NativeResourceSectionCopier.cs | 140 ++ .../Utilities/JniRemapping/R8Mapping.cs | 322 ++++ 23 files changed, 5646 insertions(+) create mode 100644 src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniFixtureBuilder.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/NativeResourceSectionCopierTests.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/AssemblyRebuilder.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/FieldRvaTable.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/IlOpcodeTable.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewriteException.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlan.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/MetadataEncoding.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/MetadataRawColumns.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/NativeResourceSectionCopier.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateCompressedAssembliesNativeSourceFiles.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateCompressedAssembliesNativeSourceFiles.cs index f0c800c8358..134e5f03173 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateCompressedAssembliesNativeSourceFiles.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateCompressedAssembliesNativeSourceFiles.cs @@ -16,6 +16,8 @@ public class GenerateCompressedAssembliesNativeSourceFiles : AndroidTask [Required] public ITaskItem[] ResolvedAssemblies { get; set; } = []; + public ITaskItem []? SizeSourceAssemblies { get; set; } + [Required] public string [] SupportedAbis { get; set; } = []; @@ -51,6 +53,11 @@ void GenerateCompressedAssemblySources () return; } + if (SizeSourceAssemblies != null) { + Generate (CreateUpdatedCompressionInfo (SizeSourceAssemblies)); + return; + } + Dictionary> perArchAssemblies = MonoAndroidHelper.GetPerArchAssemblies ( ResolvedAssemblies, SupportedAbis, @@ -122,8 +129,68 @@ void Generate (Dictionary> CreateUpdatedCompressionInfo (ITaskItem [] sizeSourceAssemblies) + { + string registrationKey = CompressedAssemblyInfo.GetKey (ProjectFullPath); + var registered = BuildEngine4.GetRegisteredTaskObjectAssemblyLocal>> ( + registrationKey, + RegisteredTaskObjectLifetime.Build + ); + if (registered == null) { + throw new InvalidOperationException ($"Compression assembly information with key '{registrationKey}' has not been generated."); + } + + var sources = new Dictionary> (); + foreach (ITaskItem source in sizeSourceAssemblies) { + if (bool.TryParse (source.GetMetadata ("AndroidSkipAddToPackage"), out bool skip) && skip) { + continue; + } + + AndroidTargetArch arch = MonoAndroidHelper.GetTargetArch (source); + if (!sources.TryGetValue (arch, out Dictionary? archSources)) { + archSources = new Dictionary (StringComparer.OrdinalIgnoreCase); + sources.Add (arch, archSources); + } + + string assemblyKey = CompressedAssemblyInfo.GetDictionaryKey (source); + if (!archSources.TryGetValue (assemblyKey, out ITaskItem? existing)) { + archSources.Add (assemblyKey, source); + continue; + } + + if (new FileInfo (existing.ItemSpec).Length != new FileInfo (source.ItemSpec).Length) { + throw new InvalidOperationException ($"Size-source assemblies '{existing.ItemSpec}' and '{source.ItemSpec}' have the same package key but different sizes."); + } + } + + var updated = new Dictionary> (); + foreach (var archEntry in registered) { + if (!sources.TryGetValue (archEntry.Key, out Dictionary? archSources)) { + throw new InvalidOperationException ($"Could not find size-source assemblies for architecture '{archEntry.Key}'."); + } + + var archAssemblies = new Dictionary (StringComparer.OrdinalIgnoreCase); + foreach (var assemblyEntry in archEntry.Value) { + if (!archSources.TryGetValue (assemblyEntry.Key, out ITaskItem? source)) { + throw new InvalidOperationException ($"Could not find a size-source assembly matching package key '{assemblyEntry.Key}' for architecture '{archEntry.Key}'."); + } + + var fi = new FileInfo (source.ItemSpec); + if (!fi.Exists) { + throw new FileNotFoundException ($"Size-source assembly '{source.ItemSpec}' does not exist.", source.ItemSpec); + } + + CompressedAssemblyInfo info = assemblyEntry.Value; + archAssemblies.Add (assemblyEntry.Key, new CompressedAssemblyInfo (checked((uint)fi.Length), info.DescriptorIndex, info.TargetArch, info.AssemblyName)); } + updated.Add (archEntry.Key, archAssemblies); } + return updated; } } } diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs new file mode 100644 index 00000000000..6a62cd67ab9 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs @@ -0,0 +1,93 @@ +#nullable enable + +using System; +using System.IO; +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; +using Xamarin.Android.Tasks.JniRemapping; + +namespace Xamarin.Android.Tasks +{ + /// + /// Rewrites the JNI names embedded in compiled assemblies - Android.Runtime.RegisterAttribute, + /// the Java.Interop.Jni*SignatureAttribute family, the JniPeerMembers / RegisterNatives + /// ldstr strings the generator emits, and the null-terminated UTF-8 JNI data the + /// trimmable typemap generator stores in FieldRVA - according to an R8 mapping.txt, so + /// that typemap generation, ILLink, and ILC see names that already match the R8-obfuscated + /// Java side. + /// + /// Each assembly is fully reconstructed with System.Reflection.Metadata: every metadata table + /// row is cloned in its original order, so every entity token keeps its value, while the + /// heaps, method bodies, managed resources, and mapped field data are re-emitted. Replacements + /// may therefore be of any length. + /// + /// An adjacent PDB is copied unchanged: it stays valid because method tokens, IL offsets, and + /// the PE's CodeView identity (GUID, age, path) are all preserved. + /// + public class RewriteJniNamesForR8 : AndroidTask + { + public override string TaskPrefix => "RJN"; + + [Required] + public ITaskItem [] SourceFiles { get; set; } = []; + + [Required] + public ITaskItem [] DestinationFiles { get; set; } = []; + + [Required] + public string MappingFile { get; set; } = ""; + + public override bool RunTask () + { + if (SourceFiles.Length != DestinationFiles.Length) { + Log.LogCodedError ("RJN0000", "SourceFiles and DestinationFiles must contain the same number of items."); + return !Log.HasLoggedErrors; + } + + R8Mapping mapping = R8Mapping.Load (MappingFile); + + for (int i = 0; i < SourceFiles.Length; i++) { + string source = SourceFiles [i].ItemSpec; + try { + RewriteAssembly (source, DestinationFiles [i].ItemSpec, mapping); + } catch (JniRewriteException e) { + Log.LogCodedError ("RJN0001", $"Could not rewrite the JNI names in '{source}': {e.Message}"); + } + } + + return !Log.HasLoggedErrors; + } + + void RewriteAssembly (string sourcePath, string destinationPath, R8Mapping mapping) + { + string? destinationDirectory = Path.GetDirectoryName (destinationPath); + if (!destinationDirectory.IsNullOrEmpty ()) { + Directory.CreateDirectory (destinationDirectory); + } + + JniRewriteResult result = JniAssemblyRewriter.Rewrite (File.ReadAllBytes (sourcePath), mapping, Log); + + Log.LogDebugMessage ($"RewriteJniNamesForR8: rewrote {result.ReplacementCount} JNI name(s) in '{Path.GetFileName (sourcePath)}'."); + if (result.StrongNameSignatureCleared) { + Log.LogCodedWarning ("RJN0002", $"'{Path.GetFileName (sourcePath)}' was strong-name signed; the rewritten assembly is left delay-signed (its signature directory space is preserved so it can be re-signed) because no signing key is available here."); + } + + bool inPlace = String.Equals (Path.GetFullPath (sourcePath), Path.GetFullPath (destinationPath), StringComparison.Ordinal); + if (!inPlace || result.ReplacementCount != 0) { + File.WriteAllBytes (destinationPath, result.Image); + } + if (!inPlace) { + CopyAdjacentPdbUnchanged (sourcePath, destinationPath); + } + } + + static void CopyAdjacentPdbUnchanged (string sourcePath, string destinationPath) + { + string pdbSource = Path.ChangeExtension (sourcePath, "pdb"); + if (File.Exists (pdbSource)) { + string pdbDestination = Path.ChangeExtension (destinationPath, "pdb"); + Files.CopyIfChanged (pdbSource, pdbDestination); + } + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs new file mode 100644 index 00000000000..884494ff36f --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Immutable; +using System.IO; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class RewriteJniNamesForR8Tests : BaseTest + { + static byte [] BuildTrivialAssembly () + { + var metadata = new MetadataBuilder (); + var il = new BlobBuilder (); + + metadata.AddModule (0, metadata.GetOrAddString ("Fixture.dll"), metadata.GetOrAddGuid (Guid.NewGuid ()), default, default); + metadata.AddAssembly (metadata.GetOrAddString ("Fixture"), new Version (1, 0, 0, 0), default, default, 0, AssemblyHashAlgorithm.None); + metadata.AddTypeDefinition (default, default, metadata.GetOrAddString (""), default, + MetadataTokens.FieldDefinitionHandle (1), MetadataTokens.MethodDefinitionHandle (1)); + + var peHeaderBuilder = new PEHeaderBuilder (imageCharacteristics: Characteristics.Dll); + var peBuilder = new ManagedPEBuilder (peHeaderBuilder, new MetadataRootBuilder (metadata), il); + var peBlob = new BlobBuilder (); + peBuilder.Serialize (peBlob); + + using var stream = new MemoryStream (); + peBlob.WriteContentTo (stream); + return stream.ToArray (); + } + + [Test] + public void CopiesSourceToDestinationAndAdjacentPdbUnchanged () + { + string path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + + string sourceDll = Path.Combine (path, "source", "Test.dll"); + Directory.CreateDirectory (Path.GetDirectoryName (sourceDll)); + File.WriteAllBytes (sourceDll, BuildTrivialAssembly ()); + string sourcePdb = Path.ChangeExtension (sourceDll, "pdb"); + byte [] pdbContent = { 1, 2, 3, 4, 5 }; + File.WriteAllBytes (sourcePdb, pdbContent); + + string mappingFile = Path.Combine (path, "mapping.txt"); + File.WriteAllText (mappingFile, "acme.orig.Unused -> a.b.C:\n"); + + string destinationDll = Path.Combine (path, "destination", "nested", "Test.dll"); + string destinationPdb = Path.ChangeExtension (destinationDll, "pdb"); + + var task = new RewriteJniNamesForR8 { + BuildEngine = new MockBuildEngine (TestContext.Out), + SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem (sourceDll) }, + DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem (destinationDll) }, + MappingFile = mappingFile, + }; + + Assert.IsTrue (task.Execute (), "Task should succeed."); + + FileAssert.Exists (destinationDll); + FileAssert.Exists (destinationPdb); + CollectionAssert.AreEqual (File.ReadAllBytes (sourceDll), File.ReadAllBytes (destinationDll), "An assembly with no JNI replacements must remain byte-identical."); + CollectionAssert.AreEqual (pdbContent, File.ReadAllBytes (destinationPdb), "The adjacent PDB must be copied unchanged."); + + using var sourceReader = new PEReader (ImmutableArray.Create (File.ReadAllBytes (sourceDll))); + using var peReader = new PEReader (ImmutableArray.Create (File.ReadAllBytes (destinationDll))); + Assert.IsTrue (peReader.HasMetadata, "The destination must still be a valid managed PE."); + + MetadataReader before = sourceReader.GetMetadataReader (); + MetadataReader after = peReader.GetMetadataReader (); + Assert.AreEqual (before.GetGuid (before.GetModuleDefinition ().Mvid), after.GetGuid (after.GetModuleDefinition ().Mvid)); + Assert.AreEqual ("Fixture", after.GetString (after.GetAssemblyDefinition ().Name)); + } + + [Test] + public void LeavesInPlaceAssemblyWithNoReplacementsUntouched () + { + string path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + + string assembly = Path.Combine (path, "Test.dll"); + byte [] content = BuildTrivialAssembly (); + File.WriteAllBytes (assembly, content); + DateTime originalWriteTime = new DateTime (2020, 1, 2, 3, 4, 5, DateTimeKind.Utc); + File.SetLastWriteTimeUtc (assembly, originalWriteTime); + + string mappingFile = Path.Combine (path, "mapping.txt"); + File.WriteAllText (mappingFile, "acme.orig.Unused -> a.b.C:\n"); + + var task = new RewriteJniNamesForR8 { + BuildEngine = new MockBuildEngine (TestContext.Out), + SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem (assembly) }, + DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem (assembly) }, + MappingFile = mappingFile, + }; + + Assert.IsTrue (task.Execute (), "Task should succeed."); + CollectionAssert.AreEqual (content, File.ReadAllBytes (assembly)); + Assert.AreEqual (originalWriteTime, File.GetLastWriteTimeUtc (assembly), "An in-place no-op must not write the assembly."); + } + + [Test] + public void FailsWithACodedErrorWhenSourceAndDestinationCountsDiffer () + { + string path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + string mappingFile = Path.Combine (path, "mapping.txt"); + File.WriteAllText (mappingFile, ""); + + var task = new RewriteJniNamesForR8 { + BuildEngine = new MockBuildEngine (TestContext.Out), + SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem ("a.dll"), new Microsoft.Build.Utilities.TaskItem ("b.dll") }, + DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem ("a.dll") }, + MappingFile = mappingFile, + }; + + Assert.IsFalse (task.Execute (), "Task should fail when SourceFiles/DestinationFiles counts differ."); + } + + [Test] + public void HandlesMultipleFilesInOneInvocation () + { + string path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + + string source1 = Path.Combine (path, "One.dll"); + string source2 = Path.Combine (path, "Two.dll"); + File.WriteAllBytes (source1, BuildTrivialAssembly ()); + File.WriteAllBytes (source2, BuildTrivialAssembly ()); + + string mappingFile = Path.Combine (path, "mapping.txt"); + File.WriteAllText (mappingFile, ""); + + string destination1 = Path.Combine (path, "out", "One.dll"); + string destination2 = Path.Combine (path, "out", "Two.dll"); + + var task = new RewriteJniNamesForR8 { + BuildEngine = new MockBuildEngine (TestContext.Out), + SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem (source1), new Microsoft.Build.Utilities.TaskItem (source2) }, + DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem (destination1), new Microsoft.Build.Utilities.TaskItem (destination2) }, + MappingFile = mappingFile, + }; + + Assert.IsTrue (task.Execute ()); + FileAssert.Exists (destination1); + FileAssert.Exists (destination2); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs new file mode 100644 index 00000000000..66926cfa312 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs @@ -0,0 +1,1383 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using Microsoft.Build.Utilities; +using NUnit.Framework; +using Xamarin.Android.Tasks.JniRemapping; + +namespace Xamarin.Android.Build.Tests +{ + /// + /// End-to-end tests that build small PE fixtures purely with System.Reflection.Metadata / + /// Ecma335 (no Mono.Cecil), run the two-pass JNI rewriter against them, and then read the + /// rebuilt image back with a fresh PEReader/MetadataReader to verify both that the JNI names + /// were rewritten and that everything else survived the reconstruction unchanged. + /// + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class JniAssemblyRewriterTests : BaseTest + { + static JniRewriteResult Rewrite (byte [] sourceImage, R8Mapping mapping) + { + var log = new TaskLoggingHelper (new MockBuildEngine (TestContext.Out), nameof (JniAssemblyRewriterTests)); + return JniAssemblyRewriter.Rewrite (sourceImage, mapping, log); + } + + static R8Mapping Mapping (string text) => R8Mapping.Parse (new StringReader (text)); + + static IReadOnlyList AttributeStringArgs (MetadataReader reader, CustomAttributeHandleCollection attributes, MethodDefinitionHandle ctor) + { + foreach (CustomAttributeHandle handle in attributes) { + CustomAttribute attribute = reader.GetCustomAttribute (handle); + if (attribute.Constructor.Kind != HandleKind.MethodDefinition || (MethodDefinitionHandle) attribute.Constructor != ctor) { + continue; + } + + var decoded = attribute.DecodeValue (Xamarin.Android.Tasks.DummyCustomAttributeProvider.Instance); + var result = new List (); + foreach (var argument in decoded.FixedArguments) { + result.Add (argument.Value as string); + } + return result; + } + return Array.Empty (); + } + + static string FirstAttributeStringArg (MetadataReader reader, CustomAttributeHandleCollection attributes, MethodDefinitionHandle ctor) + { + var args = AttributeStringArgs (reader, attributes, ctor); + return args.Count > 0 ? args [0] : null; + } + + /// + /// Collects the ldstr operand offsets and the strings they load, so a rebuilt body + /// can be compared against the source instruction-for-instruction. + /// + static List> LoadedStrings (PEReader peReader, MetadataReader reader, MethodDefinitionHandle method) + { + var result = new List> (); + MethodDefinition definition = reader.GetMethodDefinition (method); + if (definition.RelativeVirtualAddress == 0) { + return result; + } + + byte [] il = peReader.GetMethodBody (definition.RelativeVirtualAddress).GetILBytes (); + int i = 0; + while (i < il.Length) { + if (il [i] == (byte) ILOpCode.Ldstr) { + int token = il [i + 1] | (il [i + 2] << 8) | (il [i + 3] << 16) | (il [i + 4] << 24); + result.Add (new KeyValuePair (i + 1, reader.GetUserString (MetadataTokens.UserStringHandle (token & 0x00FFFFFF)))); + i += 5; + continue; + } + i++; + } + return result; + } + + static void AssertTableRowCountsMatch (MetadataReader expected, MetadataReader actual, params TableIndex [] except) + { + for (int i = 0; i < MetadataTokens.TableCount; i++) { + var table = (TableIndex) i; + if (Array.IndexOf (except, table) >= 0) { + continue; + } + Assert.AreEqual (expected.GetTableRowCount (table), actual.GetTableRowCount (table), $"Row count of table '{table}' changed."); + } + } + + [Test] + public void RewritesBareMemberAndDescriptorForAReferencedJniClass () + { + var fixture = new JniFixtureBuilder (); + UserStringHandle className = fixture.String ("net/dot/android/ApplicationRegistration"); + UserStringHandle fieldName = fixture.String ("Context"); + UserStringHandle descriptor = fixture.String ("Landroid/content/Context;"); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + MethodDefinitionHandle method = fixture.AddVoidMethod ("GetContext", fixture.EmitLoadStringBody (className, fieldName, descriptor)); + fixture.AddType ("Acme", "ContextAccessor", fieldStart, methodStart); + + JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ( + "net.dot.android.ApplicationRegistration -> c4:\n" + + " android.content.Context Context -> a\n")); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + CollectionAssert.AreEqual (new [] { + "c4", + "a", + "Landroid/content/Context;", + }, LoadedStrings (peReader, reader, method).ConvertAll (entry => entry.Value)); + } + + [Test] + public void RewritesAttributesAndLoadedStrings () + { + var fixture = new JniFixtureBuilder (); + + const string myViewJni = "acme/orig/MyView"; + const string callbackDescriptor = "(Lacme/orig/Callback;)V"; + const string rewrittenCallbackDescriptor = "(La/b/Cb;)V"; + const string registerNativesLine = "onClick:" + callbackDescriptor + ":n_OnClick_Lacme_orig_Callback_Handler"; + + UserStringHandle methodId = fixture.String ("onClick." + callbackDescriptor); + UserStringHandle fieldId = fixture.String ("someField.I"); + UserStringHandle exactClassName = fixture.String ("acme/orig/Marker"); + UserStringHandle singleLine = fixture.String (registerNativesLine); + UserStringHandle multiline = fixture.String (registerNativesLine + "\nunused:()V:n_Unused"); + UserStringHandle trailingNewline = fixture.String (registerNativesLine + "\n"); + UserStringHandle unrelated = fixture.String ("this is an ordinary string, untouched"); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + + var someField = fixture.Metadata.AddFieldDefinition (FieldAttributes.Public, + fixture.Metadata.GetOrAddString ("SomeField"), fixture.Metadata.GetOrAddBlob (IntFieldSignature ())); + fixture.Metadata.AddCustomAttribute (someField, fixture.RegisterCtor1, fixture.AttributeBlob ("someField")); + + int onClickBody = fixture.EmitLoadStringBody (methodId, fieldId, exactClassName, singleLine, multiline, trailingNewline, unrelated); + MethodDefinitionHandle onClick = fixture.AddVoidMethod ("OnClick", onClickBody); + fixture.Metadata.AddCustomAttribute (onClick, fixture.RegisterCtor3, + fixture.AttributeBlob ("onClick", callbackDescriptor, "n_OnClick_Lacme_orig_Callback_Handler")); + fixture.Metadata.AddCustomAttribute (onClick, fixture.JniMethodSignatureCtor2, + fixture.AttributeBlob ("onClick", callbackDescriptor)); + + int ctorBody = fixture.EmitReturnOnlyBody (); + MethodDefinitionHandle ctor = fixture.AddVoidMethod (".ctor", ctorBody, + MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName); + fixture.Metadata.AddCustomAttribute (ctor, fixture.JniConstructorSignatureCtor1, fixture.AttributeBlob (callbackDescriptor)); + + TypeDefinitionHandle myView = fixture.AddType ("Acme.Orig", "MyView", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (myView, fixture.RegisterCtor1, fixture.AttributeBlob (myViewJni)); + + fieldStart = fixture.NextFieldRid; + methodStart = fixture.NextMethodRid; + TypeDefinitionHandle marker = fixture.AddType ("Acme.Orig", "Marker", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (marker, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Marker")); + + // A nested type with no JNI identity of its own inherits its owner from MyView. + UserStringHandle nestedRun = fixture.String ("run:()V:n_Run"); + fieldStart = fixture.NextFieldRid; + methodStart = fixture.NextMethodRid; + fixture.AddVoidMethod ("Run", fixture.EmitLoadStringBody (nestedRun)); + TypeDefinitionHandle nested = fixture.AddType (null, "Nested", fieldStart, methodStart, + TypeAttributes.NestedPublic | TypeAttributes.Class | TypeAttributes.BeforeFieldInit); + fixture.Metadata.AddNestedType (nested, myView); + + byte [] source = fixture.Serialize (); + JniRewriteResult result = Rewrite (source, Mapping ( + "acme.orig.MyView -> a.b.C:\n" + + " void onClick(acme.orig.Callback) -> a\n" + + " int someField -> x\n" + + " void run() -> b\n" + + " void (acme.orig.Callback) -> \n" + + "acme.orig.Callback -> a.b.Cb:\n" + + "acme.orig.Marker -> a.b.D:\n")); + + Assert.Greater (result.ReplacementCount, 0); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + + Assert.AreEqual ("a/b/C", FirstAttributeStringArg (reader, reader.GetTypeDefinition (myView).GetCustomAttributes (), fixture.RegisterCtor1)); + Assert.AreEqual ("a/b/D", FirstAttributeStringArg (reader, reader.GetTypeDefinition (marker).GetCustomAttributes (), fixture.JniTypeSignatureCtor1)); + Assert.AreEqual ("x", FirstAttributeStringArg (reader, reader.GetFieldDefinition (someField).GetCustomAttributes (), fixture.RegisterCtor1)); + + CustomAttributeHandleCollection onClickAttributes = reader.GetMethodDefinition (onClick).GetCustomAttributes (); + CollectionAssert.AreEqual (new [] { "a", rewrittenCallbackDescriptor, "n_OnClick_Lacme_orig_Callback_Handler" }, + AttributeStringArgs (reader, onClickAttributes, fixture.RegisterCtor3)); + CollectionAssert.AreEqual (new [] { "a", rewrittenCallbackDescriptor }, + AttributeStringArgs (reader, onClickAttributes, fixture.JniMethodSignatureCtor2)); + CollectionAssert.AreEqual (new [] { rewrittenCallbackDescriptor }, + AttributeStringArgs (reader, reader.GetMethodDefinition (ctor).GetCustomAttributes (), fixture.JniConstructorSignatureCtor1), + "JniConstructorSignatureAttribute's only argument is the descriptor."); + + using var sourceReader = new PEReader (ImmutableArray.Create (source)); + var strings = LoadedStrings (peReader, reader, onClick); + CollectionAssert.AreEqual (new [] { + "a." + rewrittenCallbackDescriptor, + "x.I", + "a/b/D", + "a:" + rewrittenCallbackDescriptor + ":n_OnClick_Lacme_orig_Callback_Handler", + "a:" + rewrittenCallbackDescriptor + ":n_OnClick_Lacme_orig_Callback_Handler\nunused:()V:n_Unused", + "a:" + rewrittenCallbackDescriptor + ":n_OnClick_Lacme_orig_Callback_Handler\n", + "this is an ordinary string, untouched", + }, ValuesOf (strings)); + + // The nested type resolves its owner from the enclosing MyView type. + MethodDefinitionHandle run = FirstMethodOf (reader, nested); + CollectionAssert.AreEqual (new [] { "b:()V:n_Run" }, ValuesOf (LoadedStrings (peReader, reader, run))); + } + + static List ValuesOf (List> pairs) + { + var values = new List (pairs.Count); + foreach (var pair in pairs) { + values.Add (pair.Value); + } + return values; + } + + static MethodDefinitionHandle FirstMethodOf (MetadataReader reader, TypeDefinitionHandle type) + { + foreach (MethodDefinitionHandle handle in reader.GetTypeDefinition (type).GetMethods ()) { + return handle; + } + return default; + } + + static BlobBuilder IntFieldSignature () + { + var signature = new BlobBuilder (); + new BlobEncoder (signature).FieldSignature ().Int32 (); + return signature; + } + + [Test] + public void SharedLoadedStringGetsOwnerSpecificReplacements () + { + var fixture = new JniFixtureBuilder (); + + // One deduplicated #US entry, used by two classes that R8 renames differently. + UserStringHandle shared = fixture.String ("go.()V"); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + fixture.AddVoidMethod ("Go", fixture.EmitLoadStringBody (shared)); + TypeDefinitionHandle first = fixture.AddType ("Acme.Orig", "Dup1", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (first, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Dup1")); + + fieldStart = fixture.NextFieldRid; + methodStart = fixture.NextMethodRid; + fixture.AddVoidMethod ("Go", fixture.EmitLoadStringBody (shared)); + TypeDefinitionHandle second = fixture.AddType ("Acme.Orig", "Dup2", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (second, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Dup2")); + + byte [] source = fixture.Serialize (); + JniRewriteResult result = Rewrite (source, Mapping ( + "acme.orig.Dup1 -> a.b.F1:\n" + + " void go() -> z\n" + + "acme.orig.Dup2 -> a.b.F2:\n" + + " void go() -> q\n")); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + + CollectionAssert.AreEqual (new [] { "z.()V" }, ValuesOf (LoadedStrings (peReader, reader, FirstMethodOf (reader, first)))); + CollectionAssert.AreEqual (new [] { "q.()V" }, ValuesOf (LoadedStrings (peReader, reader, FirstMethodOf (reader, second)))); + } + + [Test] + public void AppliesReplacementsLongerThanTheOriginal () + { + var fixture = new JniFixtureBuilder (); + + UserStringHandle memberId = fixture.String ("go.()V"); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + MethodDefinitionHandle go = fixture.AddVoidMethod ("Go", fixture.EmitLoadStringBody (memberId)); + fixture.Metadata.AddCustomAttribute (go, fixture.RegisterCtor3, fixture.AttributeBlob ("go", "()V", "n_Go")); + TypeDefinitionHandle small = fixture.AddType ("Acme.Orig", "Small", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (small, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Small")); + + const string longName = "aVeryLongReplacementMethodNameThatCouldNeverFitInPlace"; + JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ( + "acme.orig.Small -> com.example.a.VeryLongObfuscatedClassName:\n" + + " void go() -> " + longName + "\n")); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + + Assert.AreEqual ("com/example/a/VeryLongObfuscatedClassName", + FirstAttributeStringArg (reader, reader.GetTypeDefinition (small).GetCustomAttributes (), fixture.JniTypeSignatureCtor1)); + CollectionAssert.AreEqual (new [] { longName, "()V", "n_Go" }, + AttributeStringArgs (reader, reader.GetMethodDefinition (go).GetCustomAttributes (), fixture.RegisterCtor3)); + CollectionAssert.AreEqual (new [] { longName + ".()V" }, ValuesOf (LoadedStrings (peReader, reader, go))); + } + + [Test] + public void IdentifiesUtf8FieldRvaDataStructurally () + { + var fixture = new JniFixtureBuilder (); + FieldDefinitionHandle nameField = fixture.AddUtf8Field ("onClick"); + FieldDefinitionHandle signatureField = fixture.AddUtf8Field ("(Lacme/orig/Callback;)V"); + + using var peReader = new PEReader (ImmutableArray.Create (fixture.Serialize ())); + MetadataReader reader = peReader.GetMetadataReader (); + FieldRvaTable table = FieldRvaTable.Read (peReader, reader); + + Assert.AreEqual (2, table.Entries.Count); + + FieldRvaEntry name = table.Get (nameField); + Assert.IsNotNull (name); + Assert.IsTrue (name.IsUtf8Datum, "A __utf8_N mapped field must be recognised structurally."); + Assert.AreEqual ("onClick", name.Utf8Value); + + FieldRvaEntry signature = table.Get (signatureField); + Assert.IsNotNull (signature); + Assert.IsTrue (signature.IsUtf8Datum); + Assert.AreEqual ("(Lacme/orig/Callback;)V", signature.Utf8Value); + } + + [Test] + public void RewritesUtf8FieldRvaJniNamesAndSignatures () + { + var fixture = new JniFixtureBuilder (); + + FieldDefinitionHandle nameField = fixture.AddUtf8Field ("onClick"); + FieldDefinitionHandle signatureField = fixture.AddUtf8Field ("(Lacme/orig/Callback;)V"); + FieldDefinitionHandle classNameField = fixture.AddUtf8Field ("acme/orig/Callback"); + FieldDefinitionHandle longNameField = fixture.AddUtf8Field ("run"); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + int ctorBody = fixture.EmitBody (encoder => { + encoder.OpCode (ILOpCode.Ldarg_0); + encoder.LoadString (fixture.String ("acme/orig/MyView")); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ret); + }); + fixture.AddVoidMethod (".ctor", ctorBody, + MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName); + + int registerBody = fixture.EmitBody (encoder => { + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (nameField); + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (signatureField); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (longNameField); + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (signatureField); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ret); + }); + fixture.AddVoidMethod ("RegisterNatives", registerBody); + + // A JavaPeerProxy-derived type carries its JNI identity in its .ctor's only ldstr. + fixture.AddType ("Acme.Orig", "MyViewProxy", fieldStart, methodStart, + TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class, fixture.JavaPeerProxyReference); + + byte [] source = fixture.Serialize (); + JniRewriteResult result = Rewrite (source, Mapping ( + "acme.orig.MyView -> a.b.C:\n" + + " void onClick(acme.orig.Callback) -> a\n" + + " void run(acme.orig.Callback) -> aMuchLongerObfuscatedName\n" + + "acme.orig.Callback -> a.b.Cb:\n")); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + + Assert.AreEqual ("a", ReadUtf8Field (peReader, reader, nameField), "The method name is renamed using the owning proxy's JNI class."); + Assert.AreEqual ("(La/b/Cb;)V", ReadUtf8Field (peReader, reader, signatureField)); + Assert.AreEqual ("a/b/Cb", ReadUtf8Field (peReader, reader, classNameField), "An unreferenced datum that is a known class name is still renamed."); + Assert.AreEqual ("aMuchLongerObfuscatedName", ReadUtf8Field (peReader, reader, longNameField), "A longer datum is relocated into a wider __utf8_N slot."); + + // Growing a datum appends exactly one new sized type; no existing token moves. + using var sourceReader = new PEReader (ImmutableArray.Create (source)); + MetadataReader before = sourceReader.GetMetadataReader (); + Assert.AreEqual (before.GetTableRowCount (TableIndex.TypeDef) + 1, reader.GetTableRowCount (TableIndex.TypeDef)); + AssertTableRowCountsMatch (before, reader, TableIndex.TypeDef, TableIndex.ClassLayout, TableIndex.NestedClass); + } + + static string ReadUtf8Field (PEReader peReader, MetadataReader reader, FieldDefinitionHandle field) + { + FieldDefinition definition = reader.GetFieldDefinition (field); + int rva = definition.GetRelativeVirtualAddress (); + Assert.AreNotEqual (0, rva, "Field has no RVA."); + + PEMemoryBlock block = peReader.GetSectionData (rva); + var bytes = new List (); + BlobReader blob = block.GetReader (0, Math.Min (block.Length, 256)); + for (byte b = blob.ReadByte (); b != 0; b = blob.ReadByte ()) { + bytes.Add (b); + } + return System.Text.Encoding.UTF8.GetString (bytes.ToArray ()); + } + + [Test] + public void FailsWhenASharedUtf8DatumNeedsTwoDifferentNames () + { + var fixture = new JniFixtureBuilder (); + + FieldDefinitionHandle shared = fixture.AddUtf8Field ("go"); + FieldDefinitionHandle signature = fixture.AddUtf8Field ("()V"); + + AddProxy (fixture, "acme/orig/P1", shared, signature); + AddProxy (fixture, "acme/orig/P2", shared, signature); + + var exception = Assert.Throws (() => Rewrite (fixture.Serialize (), Mapping ( + "acme.orig.P1 -> a.b.P1:\n" + + " void go() -> z\n" + + "acme.orig.P2 -> a.b.P2:\n" + + " void go() -> q\n"))); + StringAssert.Contains ("shared", exception.Message.ToLowerInvariant ()); + } + + static void AddProxy (JniFixtureBuilder fixture, string jniName, FieldDefinitionHandle nameField, FieldDefinitionHandle signatureField) + { + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + + fixture.AddVoidMethod (".ctor", fixture.EmitBody (encoder => { + encoder.OpCode (ILOpCode.Ldarg_0); + encoder.LoadString (fixture.String (jniName)); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ret); + }), MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName); + + fixture.AddVoidMethod ("RegisterNatives", fixture.EmitBody (encoder => { + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (nameField); + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (signatureField); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ret); + })); + + fixture.AddType ("Acme.Orig", jniName.Replace ('/', '_'), fieldStart, methodStart, + TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class, fixture.JavaPeerProxyReference); + } + + [Test] + public void PreservesResourcesExceptionRegionsAndComplexIL () + { + var fixture = new JniFixtureBuilder (); + + byte [] resource1 = new byte [] { 1, 2, 3, 4, 5, 6, 7 }; + var resource2 = new byte [300]; + for (int i = 0; i < resource2.Length; i++) { + resource2 [i] = (byte) (i * 7); + } + fixture.AddEmbeddedResource ("First.resources", resource1); + fixture.AddEmbeddedResource ("Second.resources", resource2); + + var localSignature = new BlobBuilder (); + var localEncoder = new BlobEncoder (localSignature).LocalVariableSignature (2); + localEncoder.AddVariable ().Type ().Int32 (); + localEncoder.AddVariable ().Type ().Object (); + StandaloneSignatureHandle locals = fixture.Metadata.AddStandaloneSignature (fixture.Metadata.GetOrAddBlob (localSignature)); + + UserStringHandle jniString = fixture.String ("go.()V"); + var controlFlow = new ControlFlowBuilder (); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + int bodyOffset = fixture.EmitBody (encoder => { + LabelHandle tryStart = encoder.DefineLabel (); + LabelHandle catchStart = encoder.DefineLabel (); + LabelHandle catchEnd = encoder.DefineLabel (); + LabelHandle protectedStart = encoder.DefineLabel (); + LabelHandle finallyStart = encoder.DefineLabel (); + LabelHandle finallyEnd = encoder.DefineLabel (); + LabelHandle afterCatch = encoder.DefineLabel (); + LabelHandle afterFinally = encoder.DefineLabel (); + LabelHandle case0 = encoder.DefineLabel (); + LabelHandle case1 = encoder.DefineLabel (); + LabelHandle case2 = encoder.DefineLabel (); + LabelHandle done = encoder.DefineLabel (); + + encoder.MarkLabel (tryStart); + encoder.LoadString (jniString); + encoder.OpCode (ILOpCode.Pop); + encoder.Branch (ILOpCode.Leave, afterCatch); + + encoder.MarkLabel (catchStart); + encoder.StoreLocal (1); + encoder.Branch (ILOpCode.Leave, afterCatch); + encoder.MarkLabel (catchEnd); + + encoder.MarkLabel (afterCatch); + encoder.MarkLabel (protectedStart); + encoder.OpCode (ILOpCode.Nop); + encoder.Branch (ILOpCode.Leave, afterFinally); + + encoder.MarkLabel (finallyStart); + encoder.OpCode (ILOpCode.Endfinally); + encoder.MarkLabel (finallyEnd); + + encoder.MarkLabel (afterFinally); + encoder.LoadConstantI4 (1); + encoder.StoreLocal (0); + encoder.LoadLocal (0); + SwitchInstructionEncoder switchEncoder = encoder.Switch (3); + switchEncoder.Branch (case0); + switchEncoder.Branch (case1); + switchEncoder.Branch (case2); + + // Two-byte opcodes: ceq (0xFE 0x01) and ldloc (0xFE 0x0C) via LoadLocal (300). + encoder.MarkLabel (case0); + encoder.LoadConstantI4 (0); + encoder.LoadConstantI4 (1); + encoder.OpCode (ILOpCode.Ceq); + encoder.OpCode (ILOpCode.Pop); + encoder.Branch (ILOpCode.Br_s, done); + + encoder.MarkLabel (case1); + encoder.OpCode (ILOpCode.Sizeof); + encoder.Token (fixture.ExceptionReference); + encoder.OpCode (ILOpCode.Pop); + encoder.Branch (ILOpCode.Br, done); + + encoder.MarkLabel (case2); + encoder.OpCode (ILOpCode.Nop); + + encoder.MarkLabel (done); + encoder.OpCode (ILOpCode.Ret); + + controlFlow.AddCatchRegion (tryStart, catchStart, catchStart, catchEnd, fixture.ExceptionReference); + controlFlow.AddFinallyRegion (protectedStart, finallyStart, finallyStart, finallyEnd); + }, locals, controlFlow); + + MethodDefinitionHandle method = fixture.AddVoidMethod ("Go", bodyOffset); + TypeDefinitionHandle type = fixture.AddType ("Acme.Orig", "Complex", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (type, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Complex")); + + byte [] source = fixture.Serialize (); + JniRewriteResult result = Rewrite (source, Mapping ( + "acme.orig.Complex -> a.b.X:\n" + + " void go() -> z\n")); + + using var sourcePe = new PEReader (ImmutableArray.Create (source)); + using var rewrittenPe = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader before = sourcePe.GetMetadataReader (); + MetadataReader after = rewrittenPe.GetMetadataReader (); + + MethodBodyBlock sourceBody = sourcePe.GetMethodBody (before.GetMethodDefinition (method).RelativeVirtualAddress); + MethodBodyBlock rewrittenBody = rewrittenPe.GetMethodBody (after.GetMethodDefinition (method).RelativeVirtualAddress); + + Assert.AreEqual (sourceBody.GetILBytes ().Length, rewrittenBody.GetILBytes ().Length, "IL length must not change."); + Assert.AreEqual (sourceBody.MaxStack, rewrittenBody.MaxStack); + Assert.AreEqual (sourceBody.LocalVariablesInitialized, rewrittenBody.LocalVariablesInitialized); + Assert.AreEqual (sourceBody.LocalSignature, rewrittenBody.LocalSignature); + Assert.AreEqual (sourceBody.ExceptionRegions.Length, rewrittenBody.ExceptionRegions.Length); + for (int i = 0; i < sourceBody.ExceptionRegions.Length; i++) { + ExceptionRegion expected = sourceBody.ExceptionRegions [i]; + ExceptionRegion actual = rewrittenBody.ExceptionRegions [i]; + Assert.AreEqual (expected.Kind, actual.Kind); + Assert.AreEqual (expected.TryOffset, actual.TryOffset); + Assert.AreEqual (expected.TryLength, actual.TryLength); + Assert.AreEqual (expected.HandlerOffset, actual.HandlerOffset); + Assert.AreEqual (expected.HandlerLength, actual.HandlerLength); + Assert.AreEqual (expected.CatchType, actual.CatchType); + } + + // Every byte except the four-byte ldstr operands is untouched, so branch targets and + // PDB IL offsets remain valid. + byte [] sourceIL = sourceBody.GetILBytes (); + byte [] rewrittenIL = rewrittenBody.GetILBytes (); + var stringOperands = new HashSet (); + foreach (var pair in LoadedStrings (sourcePe, before, method)) { + for (int i = 0; i < 4; i++) { + stringOperands.Add (pair.Key + i); + } + } + for (int i = 0; i < sourceIL.Length; i++) { + if (!stringOperands.Contains (i)) { + Assert.AreEqual (sourceIL [i], rewrittenIL [i], $"IL byte {i} changed."); + } + } + + CollectionAssert.AreEqual (new [] { "z.()V" }, ValuesOf (LoadedStrings (rewrittenPe, after, method))); + + CollectionAssert.AreEqual (resource1, ReadResource (rewrittenPe, after, "First.resources")); + CollectionAssert.AreEqual (resource2, ReadResource (rewrittenPe, after, "Second.resources")); + + AssertTableRowCountsMatch (before, after); + } + + static byte [] ReadResource (PEReader peReader, MetadataReader reader, string name) + { + foreach (ManifestResourceHandle handle in reader.ManifestResources) { + ManifestResource resource = reader.GetManifestResource (handle); + if (reader.GetString (resource.Name) != name) { + continue; + } + + DirectoryEntry directory = peReader.PEHeaders.CorHeader.ResourcesDirectory; + PEMemoryBlock block = peReader.GetSectionData (directory.RelativeVirtualAddress); + int offset = (int) resource.Offset; + int size = block.GetReader (offset, sizeof (int)).ReadInt32 (); + return block.GetReader (offset + sizeof (int), size).ReadBytes (size); + } + + Assert.Fail ($"Resource '{name}' is missing."); + return null; + } + + [Test] + public void RebuildsAnAssemblyWhoseUserStringHeapIsAbsent () + { + var fixture = new JniFixtureBuilder (); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + fixture.AddVoidMethod ("Go", fixture.EmitReturnOnlyBody ()); + TypeDefinitionHandle type = fixture.AddType ("Acme.Orig", "NoStrings", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (type, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/NoStrings")); + + // MetadataBuilder always emits a (empty) #US stream; drop its stream header so the + // rewriter is exercised against an assembly that genuinely has no #US heap at all, + // the way ilasm and some post-processing tools emit them. + byte [] source = RemoveUserStringStreamHeader (fixture.Serialize ()); + using (var sourcePe = new PEReader (ImmutableArray.Create (source))) { + Assert.AreEqual (0, sourcePe.GetMetadataReader ().GetHeapSize (HeapIndex.UserString), + "The fixture must have no #US heap for this test to be meaningful."); + } + + JniRewriteResult result = Rewrite (source, Mapping ("acme.orig.NoStrings -> a.b.N:\n")); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + Assert.AreEqual ("a/b/N", FirstAttributeStringArg (reader, reader.GetTypeDefinition (type).GetCustomAttributes (), fixture.JniTypeSignatureCtor1)); + Assert.AreEqual ("Go", reader.GetString (reader.GetMethodDefinition (FirstMethodOf (reader, type)).Name)); + } + + /// + /// Removes the #US entry from the metadata root's stream header list, leaving every + /// other stream's (root-relative) offset untouched. + /// + static byte [] RemoveUserStringStreamHeader (byte [] image) + { + var result = (byte []) image.Clone (); + using var peReader = new PEReader (ImmutableArray.Create (image)); + int root = peReader.PEHeaders.MetadataStartOffset; + + int position = root + 4 + 2 + 2 + 4; + int versionLength = BitConverter.ToInt32 (result, position); + position += 4 + versionLength + 2; + + int streamCountOffset = position; + int streamCount = BitConverter.ToUInt16 (result, streamCountOffset); + position += 2; + + int headersStart = position; + var kept = new List (); + for (int i = 0; i < streamCount; i++) { + int entryStart = position; + position += 8; + int nameStart = position; + while (result [position] != 0) { + position++; + } + string name = System.Text.Encoding.ASCII.GetString (result, nameStart, position - nameStart); + position++; + position = headersStart + (position - headersStart + 3) / 4 * 4; + + if (name != "#US") { + for (int b = entryStart; b < position; b++) { + kept.Add (result [b]); + } + } + } + + Assert.AreNotEqual (streamCount * 8, kept.Count, "The fixture is expected to contain a #US stream header."); + + int headersEnd = position; + for (int i = 0; i < kept.Count; i++) { + result [headersStart + i] = kept [i]; + } + for (int i = headersStart + kept.Count; i < headersEnd; i++) { + result [i] = 0; + } + + byte [] newCount = BitConverter.GetBytes ((ushort) (streamCount - 1)); + result [streamCountOffset] = newCount [0]; + result [streamCountOffset + 1] = newCount [1]; + return result; + } + + [Test] + public void PreservesTokensIdentityAndDebugDirectory () + { + var fixture = new JniFixtureBuilder (); + + var pdbId = new BlobContentId (new Guid ("2A3B4C5D-6E7F-4011-9223-334455667788"), 0xAABBCCDD); + var debugDirectory = new DebugDirectoryBuilder (); + debugDirectory.AddCodeViewEntry ("/some/where/Fixture.pdb", pdbId, portablePdbVersion: 0x0100); + debugDirectory.AddReproducibleEntry (); + fixture.DebugDirectory = debugDirectory; + + UserStringHandle jniString = fixture.String ("go.()V"); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + MethodDefinitionHandle go = fixture.AddVoidMethod ("Go", fixture.EmitLoadStringBody (jniString)); + TypeDefinitionHandle type = fixture.AddType ("Acme.Orig", "Identity", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (type, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Identity")); + + byte [] source = fixture.Serialize (); + JniRewriteResult result = Rewrite (source, Mapping ( + "acme.orig.Identity -> a.b.I:\n" + + " void go() -> z\n")); + Assert.IsFalse (result.StrongNameSignatureCleared); + + using var sourcePe = new PEReader (ImmutableArray.Create (source)); + using var rewrittenPe = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader before = sourcePe.GetMetadataReader (); + MetadataReader after = rewrittenPe.GetMetadataReader (); + + Assert.AreEqual (before.GetGuid (before.GetModuleDefinition ().Mvid), after.GetGuid (after.GetModuleDefinition ().Mvid), "MVID must be preserved."); + Assert.AreEqual (before.MetadataVersion, after.MetadataVersion); + Assert.AreEqual (before.GetString (before.GetAssemblyDefinition ().Name), after.GetString (after.GetAssemblyDefinition ().Name)); + Assert.AreEqual (before.GetAssemblyDefinition ().Version, after.GetAssemblyDefinition ().Version); + Assert.AreEqual (before.GetAssemblyDefinition ().HashAlgorithm, after.GetAssemblyDefinition ().HashAlgorithm); + Assert.AreEqual (sourcePe.PEHeaders.CoffHeader.Machine, rewrittenPe.PEHeaders.CoffHeader.Machine); + Assert.AreEqual (sourcePe.PEHeaders.CoffHeader.Characteristics, rewrittenPe.PEHeaders.CoffHeader.Characteristics); + Assert.AreEqual (sourcePe.PEHeaders.CoffHeader.TimeDateStamp, rewrittenPe.PEHeaders.CoffHeader.TimeDateStamp); + Assert.AreEqual (sourcePe.PEHeaders.PEHeader.Subsystem, rewrittenPe.PEHeaders.PEHeader.Subsystem); + Assert.AreEqual (sourcePe.PEHeaders.PEHeader.DllCharacteristics, rewrittenPe.PEHeaders.PEHeader.DllCharacteristics); + Assert.AreEqual (sourcePe.PEHeaders.CorHeader.Flags, rewrittenPe.PEHeaders.CorHeader.Flags); + + var sourceEntries = sourcePe.ReadDebugDirectory (); + var rewrittenEntries = rewrittenPe.ReadDebugDirectory (); + Assert.AreEqual (sourceEntries.Length, rewrittenEntries.Length, "Debug directory entry count changed."); + for (int i = 0; i < sourceEntries.Length; i++) { + Assert.AreEqual (sourceEntries [i].Type, rewrittenEntries [i].Type); + Assert.AreEqual (sourceEntries [i].Stamp, rewrittenEntries [i].Stamp); + Assert.AreEqual (sourceEntries [i].MajorVersion, rewrittenEntries [i].MajorVersion); + Assert.AreEqual (sourceEntries [i].MinorVersion, rewrittenEntries [i].MinorVersion); + } + + CodeViewDebugDirectoryData codeView = rewrittenPe.ReadCodeViewDebugDirectoryData (rewrittenEntries [0]); + Assert.AreEqual (pdbId.Guid, codeView.Guid, "The PDB GUID must still match the portable PDB."); + Assert.AreEqual ("/some/where/Fixture.pdb", codeView.Path); + Assert.AreEqual (1, codeView.Age); + + AssertTableRowCountsMatch (before, after); + + // The rewritten string is reachable through the original method token, and the ldstr + // operand sits at the same IL offset as before. + var sourceStrings = LoadedStrings (sourcePe, before, go); + var rewrittenStrings = LoadedStrings (rewrittenPe, after, go); + Assert.AreEqual (sourceStrings.Count, rewrittenStrings.Count); + Assert.AreEqual (sourceStrings [0].Key, rewrittenStrings [0].Key, "The ldstr operand moved."); + Assert.AreEqual ("z.()V", rewrittenStrings [0].Value); + } + + [Test] + public void ClearsTheStrongNameSignedFlagButReservesTheSignatureDirectory () + { + const int OriginalStrongNameSignatureSize = 128; + + var fixture = new JniFixtureBuilder { + Flags = CorFlags.ILOnly | CorFlags.StrongNameSigned, + StrongNameSignatureSize = OriginalStrongNameSignatureSize, + }; + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + fixture.AddVoidMethod ("Go", fixture.EmitReturnOnlyBody ()); + TypeDefinitionHandle type = fixture.AddType ("Acme.Orig", "Signed", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (type, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Signed")); + + byte [] source = fixture.Serialize (); + using (var sourcePe = new PEReader (ImmutableArray.Create (source))) { + Assert.IsTrue ((sourcePe.PEHeaders.CorHeader.Flags & CorFlags.StrongNameSigned) != 0); + Assert.AreEqual (OriginalStrongNameSignatureSize, sourcePe.PEHeaders.CorHeader.StrongNameSignatureDirectory.Size); + } + + JniRewriteResult result = Rewrite (source, Mapping ("acme.orig.Signed -> a.b.S:\n")); + Assert.IsTrue (result.StrongNameSignatureCleared, "The rewriter must report that it dropped the signature."); + + using var rewrittenPe = new PEReader (ImmutableArray.Create (result.Image)); + CorHeader corHeader = rewrittenPe.PEHeaders.CorHeader; + Assert.AreEqual (CorFlags.ILOnly, corHeader.Flags, "The StrongNameSigned flag must be cleared, not left stale."); + + // The output must be genuinely delay-signed / re-signable: the original signature + // directory's size (and a real, non-zero RVA reserving that space in the image) must + // be preserved, not zeroed out - otherwise there would be nowhere to write a new + // signature into without another full rewrite. + Assert.AreEqual (OriginalStrongNameSignatureSize, corHeader.StrongNameSignatureDirectory.Size, + "The signature directory's reserved size must be preserved so the assembly can be re-signed."); + Assert.AreNotEqual (0, corHeader.StrongNameSignatureDirectory.RelativeVirtualAddress, + "The signature directory must still point at reserved space in the image."); + } + + [Test] + public void RoundTripsRealAssembliesWithAnEmptyMapping () + { + // Real assemblies exercise the cloner far harder than any hand-built fixture: Win32 + // version resources, generics, P/Invokes, exception handlers, type forwarders, + // declarative security, embedded resources, and a populated debug directory. + string [] candidates = { + typeof (Xamarin.Android.Tasks.JniRemapping.R8Mapping).Assembly.Location, + typeof (MetadataReader).Assembly.Location, + typeof (NUnit.Framework.Assert).Assembly.Location, + typeof (object).Assembly.Location, + FindLocalRuntimeAssembly ("Mono.Android.dll"), + FindLocalRuntimeAssembly ("Java.Interop.dll"), + }; + + int checkedCount = 0; + var skipped = new List (); + foreach (string path in candidates) { + if (string.IsNullOrEmpty (path) || !File.Exists (path)) { + continue; + } + + byte [] source = File.ReadAllBytes (path); + JniRewriteResult result; + try { + result = Rewrite (source, Mapping ("acme.orig.NothingAtAll -> a.b.C:\n")); + } catch (JniRewriteException e) { + // ReadyToRun images and the like are rejected up front rather than silently + // stripped; that is the documented contract, so record and move on. + skipped.Add ($"{Path.GetFileName (path)}: {e.Message}"); + continue; + } + + Assert.AreEqual (0, result.ReplacementCount, $"'{path}' should have no JNI names to rewrite."); + AssertFaithfulRoundTrip (source, result.Image, path); + checkedCount++; + } + + TestContext.Out.WriteLine ($"Round tripped {checkedCount} assembl(ies); skipped: {string.Join ("; ", skipped)}"); + Assert.Greater (checkedCount, 0, "No real assembly was available to round trip."); + } + + /// + /// Locates an assembly from the local build's Android runtime pack, or returns null when + /// this tree has not produced one. + /// + static string FindLocalRuntimeAssembly (string fileName) + { + var directory = new DirectoryInfo (AppContext.BaseDirectory); + while (directory != null && !Directory.Exists (Path.Combine (directory.FullName, "bin"))) { + directory = directory.Parent; + } + if (directory == null) { + return null; + } + + string packs = Path.Combine (directory.FullName, "bin", "Debug", "lib", "packs"); + if (!Directory.Exists (packs)) { + return null; + } + + foreach (string candidate in Directory.EnumerateFiles (packs, fileName, SearchOption.AllDirectories)) { + if (candidate.Contains ("Microsoft.Android.Runtime", StringComparison.Ordinal)) { + return candidate; + } + } + return null; + } + + [Test] + public void RewritesARealAndroidAssembly () + { + string path = FindLocalRuntimeAssembly ("Mono.Android.dll"); + if (path == null) { + Assert.Ignore ("This tree has not built a Microsoft.Android runtime pack."); + } + + byte [] source = File.ReadAllBytes (path); + JniRewriteResult result = Rewrite (source, Mapping ( + "android.app.Activity -> zz.A:\n" + + " void onCreate(android.os.Bundle) -> b\n" + + "android.os.Bundle -> zz.B:\n")); + + Assert.Greater (result.ReplacementCount, 0, "Mono.Android.dll is expected to carry JNI names for android.app.Activity."); + + using var sourcePe = new PEReader (ImmutableArray.Create (source)); + using var rewrittenPe = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader before = sourcePe.GetMetadataReader (); + MetadataReader after = rewrittenPe.GetMetadataReader (); + + AssertTableRowCountsMatch (before, after); + Assert.AreEqual (before.GetGuid (before.GetModuleDefinition ().Mvid), after.GetGuid (after.GetModuleDefinition ().Mvid)); + + // Every rewritten body must still be a well-formed method body of the same length. + int methodCount = after.GetTableRowCount (TableIndex.MethodDef); + for (int rid = 1; rid <= methodCount; rid++) { + var handle = MetadataTokens.MethodDefinitionHandle (rid); + int sourceRva = before.GetMethodDefinition (handle).RelativeVirtualAddress; + int rewrittenRva = after.GetMethodDefinition (handle).RelativeVirtualAddress; + Assert.AreEqual (sourceRva == 0, rewrittenRva == 0); + if (sourceRva == 0) { + continue; + } + Assert.AreEqual (sourcePe.GetMethodBody (sourceRva).GetILBytes ().Length, + rewrittenPe.GetMethodBody (rewrittenRva).GetILBytes ().Length, $"IL length of method {rid} changed."); + } + + bool sawRenamedActivity = false; + foreach (CustomAttributeHandle handle in after.CustomAttributes) { + CustomAttribute attribute = after.GetCustomAttribute (handle); + BlobReader blob = after.GetBlobReader (attribute.Value); + if (blob.Length < 3) { + continue; + } + byte [] bytes = blob.ReadBytes (blob.Length); + if (IndexOfAscii (bytes, "zz/A") >= 0) { + sawRenamedActivity = true; + break; + } + } + Assert.IsTrue (sawRenamedActivity, "No custom attribute picked up the renamed android.app.Activity JNI name."); + } + + static int IndexOfAscii (byte [] haystack, string needle) + { + for (int i = 0; i + needle.Length <= haystack.Length; i++) { + int j = 0; + while (j < needle.Length && haystack [i + j] == (byte) needle [j]) { + j++; + } + if (j == needle.Length) { + return i; + } + } + return -1; + } + + static void AssertFaithfulRoundTrip (byte [] source, byte [] rewritten, string path) + { + using var sourcePe = new PEReader (ImmutableArray.Create (source)); + using var rewrittenPe = new PEReader (ImmutableArray.Create (rewritten)); + MetadataReader before = sourcePe.GetMetadataReader (); + MetadataReader after = rewrittenPe.GetMetadataReader (); + + AssertTableRowCountsMatch (before, after); + Assert.AreEqual (before.GetGuid (before.GetModuleDefinition ().Mvid), after.GetGuid (after.GetModuleDefinition ().Mvid), $"MVID of '{path}' changed."); + Assert.AreEqual (before.MetadataVersion, after.MetadataVersion); + Assert.AreEqual (sourcePe.PEHeaders.CoffHeader.Machine, rewrittenPe.PEHeaders.CoffHeader.Machine); + Assert.AreEqual (sourcePe.PEHeaders.PEHeader.Subsystem, rewrittenPe.PEHeaders.PEHeader.Subsystem); + Assert.AreEqual (sourcePe.PEHeaders.PEHeader.ResourceTableDirectory.Size != 0, + rewrittenPe.PEHeaders.PEHeader.ResourceTableDirectory.Size != 0, $"Win32 resources of '{path}' were dropped."); + CollectionAssert.AreEqual (ReadWin32Resources (sourcePe), ReadWin32Resources (rewrittenPe), + $"Win32 resource contents of '{path}' changed."); + + int methodCount = before.GetTableRowCount (TableIndex.MethodDef); + for (int rid = 1; rid <= methodCount; rid++) { + var handle = MetadataTokens.MethodDefinitionHandle (rid); + MethodDefinition sourceMethod = before.GetMethodDefinition (handle); + MethodDefinition rewrittenMethod = after.GetMethodDefinition (handle); + + Assert.AreEqual (before.GetString (sourceMethod.Name), after.GetString (rewrittenMethod.Name), $"Method {rid} of '{path}' moved."); + Assert.AreEqual (sourceMethod.Attributes, rewrittenMethod.Attributes); + Assert.AreEqual (sourceMethod.RelativeVirtualAddress == 0, rewrittenMethod.RelativeVirtualAddress == 0); + if (sourceMethod.RelativeVirtualAddress == 0) { + continue; + } + + MethodBodyBlock sourceBody = sourcePe.GetMethodBody (sourceMethod.RelativeVirtualAddress); + MethodBodyBlock rewrittenBody = rewrittenPe.GetMethodBody (rewrittenMethod.RelativeVirtualAddress); + CollectionAssert.AreEqual (sourceBody.GetILBytes (), rewrittenBody.GetILBytes (), + $"IL of method {rid} of '{path}' changed even though nothing was rewritten."); + Assert.AreEqual (sourceBody.MaxStack, rewrittenBody.MaxStack); + Assert.AreEqual (sourceBody.LocalSignature, rewrittenBody.LocalSignature); + Assert.AreEqual (sourceBody.LocalVariablesInitialized, rewrittenBody.LocalVariablesInitialized); + Assert.AreEqual (sourceBody.ExceptionRegions.Length, rewrittenBody.ExceptionRegions.Length); + } + + int typeCount = before.GetTableRowCount (TableIndex.TypeDef); + for (int rid = 1; rid <= typeCount; rid++) { + var handle = MetadataTokens.TypeDefinitionHandle (rid); + Assert.AreEqual (before.GetString (before.GetTypeDefinition (handle).Name), + after.GetString (after.GetTypeDefinition (handle).Name), $"Type {rid} of '{path}' moved."); + } + + // Every FieldRVA-mapped data block (static array initializers, and similar) must + // survive byte-for-byte: a wrong size computed from the field's value type would + // silently truncate or overrun this comparison. + FieldRvaTable sourceFieldRva = FieldRvaTable.Read (sourcePe, before); + FieldRvaTable rewrittenFieldRva = FieldRvaTable.Read (rewrittenPe, after); + Assert.AreEqual (sourceFieldRva.Entries.Count, rewrittenFieldRva.Entries.Count, $"FieldRVA row count of '{path}' changed."); + foreach (FieldRvaEntry sourceEntry in sourceFieldRva.Entries) { + FieldRvaEntry rewrittenEntry = rewrittenFieldRva.Get (sourceEntry.Field); + Assert.IsNotNull (rewrittenEntry, $"FieldRVA row for field {MetadataTokens.GetToken (sourceEntry.Field):X} of '{path}' disappeared."); + CollectionAssert.AreEqual (sourceEntry.Data, rewrittenEntry.Data, + $"FieldRVA data for field {MetadataTokens.GetToken (sourceEntry.Field):X} of '{path}' changed."); + } + + foreach (ManifestResourceHandle handle in before.ManifestResources) { + ManifestResource sourceResource = before.GetManifestResource (handle); + if (!sourceResource.Implementation.IsNil) { + continue; + } + CollectionAssert.AreEqual ( + ReadResource (sourcePe, before, before.GetString (sourceResource.Name)), + ReadResource (rewrittenPe, after, before.GetString (sourceResource.Name)), + $"Embedded resource '{before.GetString (sourceResource.Name)}' of '{path}' changed."); + } + } + + [Test] + public void RewrittenAssemblyLoadsAndRunsInTheRuntime () + { + // Metadata that merely parses is not enough: the runtime has to accept the rebuilt PE + // and JIT code out of it. + string path = typeof (Xamarin.Android.Tasks.JniRemapping.R8Mapping).Assembly.Location; + if (string.IsNullOrEmpty (path) || !File.Exists (path)) { + Assert.Ignore ("The assembly under test is not available on disk."); + } + + JniRewriteResult result = Rewrite (File.ReadAllBytes (path), Mapping ("acme.orig.NothingAtAll -> a.b.C:\n")); + + string directory = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (directory); + string rewrittenPath = Path.Combine (directory, Path.GetFileName (path)); + File.WriteAllBytes (rewrittenPath, result.Image); + + var context = new System.Runtime.Loader.AssemblyLoadContext (TestName, isCollectible: true); + try { + context.Resolving += (loadContext, name) => { + string candidate = Path.Combine (Path.GetDirectoryName (path), name.Name + ".dll"); + return File.Exists (candidate) ? loadContext.LoadFromAssemblyPath (candidate) : null; + }; + + Assembly assembly = context.LoadFromAssemblyPath (rewrittenPath); + Type mappingType = assembly.GetType ("Xamarin.Android.Tasks.JniRemapping.R8Mapping", throwOnError: true); + MethodInfo parse = mappingType.GetMethod ("Parse", BindingFlags.Public | BindingFlags.Static); + Assert.IsNotNull (parse, "R8Mapping.Parse is missing from the rewritten assembly."); + + object mapping = parse.Invoke (null, new object [] { new StringReader ("acme.orig.Foo -> a.b.C:\n") }); + MethodInfo tryGetRenamedClass = mappingType.GetMethod ("TryGetRenamedClass", BindingFlags.Public | BindingFlags.Instance); + Assert.IsNotNull (tryGetRenamedClass); + + var arguments = new object [] { "acme/orig/Foo", null }; + Assert.IsTrue ((bool) tryGetRenamedClass.Invoke (mapping, arguments), "The rewritten assembly's code did not run correctly."); + Assert.AreEqual ("a/b/C", arguments [1]); + } finally { + context.Unload (); + } + } + + /// + /// Flattens the Win32 resource directory into "type/name/language = bytes" entries, so a + /// relocated .rsrc section can be compared against the original without depending + /// on the RVAs that legitimately changed. + /// + static List ReadWin32Resources (PEReader peReader) + { + var result = new List (); + DirectoryEntry directory = peReader.PEHeaders.PEHeader.ResourceTableDirectory; + if (directory.Size == 0) { + return result; + } + + byte [] section = peReader.GetSectionData (directory.RelativeVirtualAddress).GetReader (0, directory.Size).ReadBytes (directory.Size); + WalkWin32Resources (peReader, section, directory.RelativeVirtualAddress, 0, "", result); + result.Sort (StringComparer.Ordinal); + return result; + } + + static void WalkWin32Resources (PEReader peReader, byte [] section, int sectionRva, int directoryOffset, string prefix, List result) + { + int namedEntries = BitConverter.ToUInt16 (section, directoryOffset + 12); + int idEntries = BitConverter.ToUInt16 (section, directoryOffset + 14); + int entryOffset = directoryOffset + 16; + + for (int i = 0; i < namedEntries + idEntries; i++, entryOffset += 8) { + uint name = BitConverter.ToUInt32 (section, entryOffset); + uint offsetToData = BitConverter.ToUInt32 (section, entryOffset + 4); + string key = prefix + "/" + name.ToString ("X8"); + + if ((offsetToData & 0x80000000) != 0) { + WalkWin32Resources (peReader, section, sectionRva, (int) (offsetToData & 0x7FFFFFFF), key, result); + continue; + } + + int dataEntry = (int) offsetToData; + int dataRva = (int) BitConverter.ToUInt32 (section, dataEntry); + int size = (int) BitConverter.ToUInt32 (section, dataEntry + 4); + byte [] data = peReader.GetSectionData (dataRva).GetReader (0, size).ReadBytes (size); + result.Add (key + " = " + BitConverter.ToString (data)); + } + } + + [Test] + public void IlOpcodeTableCoversEveryDefinedOpcode () + { + // A missing entry would make the IL walker throw on a perfectly ordinary assembly. + var missing = new List (); + foreach (ILOpCode code in Enum.GetValues (typeof (ILOpCode))) { + if (code == ILOpCode.Switch) { + continue; // Variable-length operand, handled explicitly by the scanner. + } + if (!Xamarin.Android.Tasks.JniRemapping.IlOpcodeTable.OperandSizes.ContainsKey ((ushort) code)) { + missing.Add (code.ToString ()); + } + } + + CollectionAssert.IsEmpty (missing, "IlOpcodeTable is missing operand sizes for these opcodes."); + } + + [Test] + public void RewrittenAssemblyStillMatchesItsPortablePdb () + { + var fixture = new JniFixtureBuilder (); + + UserStringHandle jniString = fixture.String ("go.()V"); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + MethodDefinitionHandle go = fixture.AddVoidMethod ("Go", fixture.EmitLoadStringBody (jniString)); + TypeDefinitionHandle type = fixture.AddType ("Acme.Orig", "Debuggable", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (type, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Debuggable")); + + string directory = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (directory); + string assemblyPath = Path.Combine (directory, "Fixture.dll"); + string pdbPath = Path.Combine (directory, "Fixture.pdb"); + + // A real portable PDB with a sequence point at IL offset 0 of Go(). + var pdbMetadata = new MetadataBuilder (); + DocumentHandle document = pdbMetadata.AddDocument ( + pdbMetadata.GetOrAddDocumentName ("/src/Fixture.cs"), default, default, default); + var sequencePoints = new BlobBuilder (); + sequencePoints.WriteCompressedInteger (0); // LocalSignature: none + sequencePoints.WriteCompressedInteger (0); // IL offset + sequencePoints.WriteCompressedInteger (1); // delta lines + sequencePoints.WriteCompressedInteger (10); // delta columns + sequencePoints.WriteCompressedInteger (5); // start line + sequencePoints.WriteCompressedInteger (1); // start column + pdbMetadata.SetCapacity (TableIndex.MethodDebugInformation, MetadataTokens.GetRowNumber (go)); + for (int rid = 1; rid < MetadataTokens.GetRowNumber (go); rid++) { + pdbMetadata.AddMethodDebugInformation (default, default); + } + pdbMetadata.AddMethodDebugInformation (document, pdbMetadata.GetOrAddBlob (sequencePoints)); + + var pdbBuilder = new PortablePdbBuilder (pdbMetadata, fixture.Metadata.GetRowCounts (), default); + var pdbBlob = new BlobBuilder (); + BlobContentId pdbId = pdbBuilder.Serialize (pdbBlob); + + var debugDirectory = new DebugDirectoryBuilder (); + debugDirectory.AddCodeViewEntry (pdbPath, pdbId, pdbBuilder.FormatVersion); + fixture.DebugDirectory = debugDirectory; + + byte [] source = fixture.Serialize (); + using (var pdbStream = File.Create (pdbPath)) { + pdbBlob.WriteContentTo (pdbStream); + } + + JniRewriteResult result = Rewrite (source, Mapping ( + "acme.orig.Debuggable -> a.b.D:\n" + + " void go() -> z\n")); + File.WriteAllBytes (assemblyPath, result.Image); + + // The unmodified PDB must still be accepted for the rewritten assembly... + using var peReader = new PEReader (File.OpenRead (assemblyPath)); + Assert.IsTrue (peReader.TryOpenAssociatedPortablePdb (assemblyPath, File.OpenRead, out MetadataReaderProvider provider, out string _), + "The rewritten assembly no longer matches its unchanged portable PDB."); + + using (provider) { + MetadataReader pdbReader = provider.GetMetadataReader (); + MetadataReader reader = peReader.GetMetadataReader (); + + // ...and its sequence points must still address real IL in the rewritten body. + var points = new List (pdbReader.GetMethodDebugInformation (go).GetSequencePoints ()); + Assert.AreEqual (1, points.Count); + Assert.AreEqual (0, points [0].Offset); + Assert.AreEqual (5, points [0].StartLine); + + byte [] il = peReader.GetMethodBody (reader.GetMethodDefinition (go).RelativeVirtualAddress).GetILBytes (); + Assert.Greater (il.Length, points [0].Offset); + Assert.AreEqual ((byte) ILOpCode.Ldstr, il [0], "The sequence point no longer points at the ldstr it was emitted for."); + CollectionAssert.AreEqual (new [] { "z.()V" }, ValuesOf (LoadedStrings (peReader, reader, go))); + } + } + + [Test] + public void PreservesMappedFieldDataThatIsNotAJniDatum () + { + var fixture = new JniFixtureBuilder (); + + // A plain C#-style array initializer blob: not a __utf8_N datum, so it must survive + // byte-for-byte. + var payload = new byte [] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 }; + TypeDefinitionHandle enclosing = fixture.EnsurePrivateImplementationDetails (); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + TypeDefinitionHandle arrayType = fixture.AddType (null, "__StaticArrayInitTypeSize=8", fieldStart, methodStart, + TypeAttributes.NestedPrivate | TypeAttributes.ExplicitLayout | TypeAttributes.Sealed | TypeAttributes.AnsiClass, + fixture.ValueTypeReference); + fixture.Metadata.AddTypeLayout (arrayType, packingSize: 1, size: (uint) payload.Length); + fixture.Metadata.AddNestedType (arrayType, enclosing); + + var signature = new BlobBuilder (); + new BlobEncoder (signature).FieldSignature ().Type (arrayType, isValueType: true); + int rva = fixture.MappedFieldData.Count; + fixture.MappedFieldData.WriteBytes (payload); + FieldDefinitionHandle dataField = fixture.Metadata.AddFieldDefinition ( + FieldAttributes.Static | FieldAttributes.Assembly | FieldAttributes.HasFieldRVA, + fixture.Metadata.GetOrAddString ("ArrayData"), fixture.Metadata.GetOrAddBlob (signature)); + fixture.Metadata.AddFieldRelativeVirtualAddress (dataField, rva); + + JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ("acme.orig.Nothing -> a.b.N:\n")); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + int newRva = reader.GetFieldDefinition (dataField).GetRelativeVirtualAddress (); + Assert.AreNotEqual (0, newRva); + CollectionAssert.AreEqual (payload, peReader.GetSectionData (newRva).GetReader (0, payload.Length).ReadBytes (payload.Length)); + } + + [Test] + public void RejectsFieldRvaValueTypeWithoutAnExplicitClassLayoutSize () + { + // A mapped value type with no ClassLayout row (or a zero size) cannot be sized safely: + // summing its instance fields would be a guess about the CLR's actual layout, and a + // wrong guess risks truncating - or reading past the end of - the mapped data. The + // rewriter must refuse rather than take that risk. + var fixture = new JniFixtureBuilder (); + + TypeDefinitionHandle enclosing = fixture.EnsurePrivateImplementationDetails (); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + TypeDefinitionHandle unsizedType = fixture.AddType (null, "__UnsizedBlob", fieldStart, methodStart, + TypeAttributes.NestedPrivate | TypeAttributes.ExplicitLayout | TypeAttributes.Sealed | TypeAttributes.AnsiClass, + fixture.ValueTypeReference); + fixture.Metadata.AddNestedType (unsizedType, enclosing); + // Deliberately no fixture.Metadata.AddTypeLayout (...) call: the type has no + // ClassLayout row at all. + + var signature = new BlobBuilder (); + new BlobEncoder (signature).FieldSignature ().Type (unsizedType, isValueType: true); + int rva = fixture.MappedFieldData.Count; + fixture.MappedFieldData.WriteBytes (new byte [] { 0x01, 0x02, 0x03, 0x04 }); + FieldDefinitionHandle dataField = fixture.Metadata.AddFieldDefinition ( + FieldAttributes.Static | FieldAttributes.Assembly | FieldAttributes.HasFieldRVA, + fixture.Metadata.GetOrAddString ("UnsizedData"), fixture.Metadata.GetOrAddBlob (signature)); + fixture.Metadata.AddFieldRelativeVirtualAddress (dataField, rva); + + byte [] source = fixture.Serialize (); + var ex = Assert.Throws (() => Rewrite (source, Mapping ("acme.orig.Nothing -> a.b.N:\n"))); + StringAssert.Contains ("ClassLayout", ex.Message); + } + + [Test] + public void RejectsAnEmbeddedResourceLengthPrefixThatWouldOverflowTheBoundsCheck () + { + // A length prefix close to int.MaxValue makes "offset + sizeof(int) + size" overflow + // a 32-bit sum and wrap around to a small (or negative) value; with plain int + // arithmetic that would slip past the bounds check below instead of being caught by + // it. The resource must be rejected, not read out of bounds. + var fixture = new JniFixtureBuilder (); + + fixture.ManagedResources.WriteInt32 (int.MaxValue); + fixture.Metadata.AddManifestResource (ManifestResourceAttributes.Public, fixture.Metadata.GetOrAddString ("Overflowing.resources"), default, offset: 0); + TypeDefinitionHandle resourceOwner = fixture.AddType ("Acme.Orig", "ResourceOwner", fixture.NextFieldRid, fixture.NextMethodRid); + fixture.Metadata.AddCustomAttribute (resourceOwner, fixture.RegisterCtor1, fixture.AttributeBlob ("acme/orig/ResourceOwner")); + + byte [] source = fixture.Serialize (); + var ex = Assert.Throws (() => Rewrite (source, Mapping ("acme.orig.ResourceOwner -> a.b.R:\n"))); + StringAssert.Contains ("extends past the end of the resources directory", ex.Message); + } + + [Test] + public void PreservesPInvokesConstantsPropertiesAndGenerics () + { + var fixture = new JniFixtureBuilder (); + + ModuleReferenceHandle moduleRef = fixture.Metadata.AddModuleReference (fixture.Metadata.GetOrAddString ("libc")); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + + FieldDefinitionHandle constant = fixture.Metadata.AddFieldDefinition ( + FieldAttributes.Public | FieldAttributes.Static | FieldAttributes.Literal | FieldAttributes.HasDefault, + fixture.Metadata.GetOrAddString ("Answer"), fixture.Metadata.GetOrAddBlob (Int32FieldSignature ())); + fixture.Metadata.AddConstant (constant, 42); + + FieldDefinitionHandle backing = fixture.Metadata.AddFieldDefinition (FieldAttributes.Private, + fixture.Metadata.GetOrAddString ("backing"), fixture.Metadata.GetOrAddBlob (Int32FieldSignature ())); + + MethodDefinitionHandle getter = fixture.Metadata.AddMethodDefinition ( + MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName, + MethodImplAttributes.IL, fixture.Metadata.GetOrAddString ("get_Value"), + fixture.Metadata.GetOrAddBlob (Int32NoArgsMethodSignature ()), + fixture.EmitBody (encoder => { + encoder.LoadConstantI4 (0); + encoder.OpCode (ILOpCode.Ret); + }), + MetadataTokens.ParameterHandle (fixture.Metadata.GetRowCount (TableIndex.Param) + 1)); + + MethodDefinitionHandle pinvoke = fixture.Metadata.AddMethodDefinition ( + MethodAttributes.Public | MethodAttributes.Static | MethodAttributes.HideBySig | MethodAttributes.PinvokeImpl, + MethodImplAttributes.IL | MethodImplAttributes.PreserveSig, fixture.Metadata.GetOrAddString ("Getpid"), + fixture.Metadata.GetOrAddBlob (Int32NoArgsStaticMethodSignature ()), -1, + MetadataTokens.ParameterHandle (fixture.Metadata.GetRowCount (TableIndex.Param) + 1)); + fixture.Metadata.AddMethodImport (pinvoke, MethodImportAttributes.CallingConventionCDecl, + fixture.Metadata.GetOrAddString ("getpid"), moduleRef); + + TypeDefinitionHandle type = fixture.AddType ("Acme.Orig", "Rich`1", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (type, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Rich")); + fixture.Metadata.AddGenericParameter (type, GenericParameterAttributes.None, fixture.Metadata.GetOrAddString ("T"), 0); + + var propertySignature = new BlobBuilder (); + new BlobEncoder (propertySignature).PropertySignature ().Parameters (0, r => r.Type ().Int32 (), p => { }); + PropertyDefinitionHandle property = fixture.Metadata.AddProperty (PropertyAttributes.None, + fixture.Metadata.GetOrAddString ("Value"), fixture.Metadata.GetOrAddBlob (propertySignature)); + fixture.Metadata.AddPropertyMap (type, property); + fixture.Metadata.AddMethodSemantics (property, MethodSemanticsAttributes.Getter, getter); + + byte [] source = fixture.Serialize (); + JniRewriteResult result = Rewrite (source, Mapping ("acme.orig.Rich -> a.b.R:\n")); + + using var sourcePe = new PEReader (ImmutableArray.Create (source)); + using var rewrittenPe = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader before = sourcePe.GetMetadataReader (); + MetadataReader after = rewrittenPe.GetMetadataReader (); + + AssertTableRowCountsMatch (before, after); + + Assert.AreEqual ("a/b/R", FirstAttributeStringArg (after, after.GetTypeDefinition (type).GetCustomAttributes (), fixture.JniTypeSignatureCtor1)); + + Constant constantRow = after.GetConstant (after.GetFieldDefinition (constant).GetDefaultValue ()); + Assert.AreEqual (ConstantTypeCode.Int32, constantRow.TypeCode); + Assert.AreEqual (42, after.GetBlobReader (constantRow.Value).ReadInt32 ()); + + MethodImport import = after.GetMethodDefinition (pinvoke).GetImport (); + Assert.AreEqual ("getpid", after.GetString (import.Name)); + Assert.AreEqual ("libc", after.GetString (after.GetModuleReference (import.Module).Name)); + Assert.AreEqual (MethodImportAttributes.CallingConventionCDecl, import.Attributes); + + PropertyAccessors accessors = after.GetPropertyDefinition (property).GetAccessors (); + Assert.AreEqual (getter, accessors.Getter); + + GenericParameterHandleCollection genericParameters = after.GetTypeDefinition (type).GetGenericParameters (); + Assert.AreEqual (1, genericParameters.Count); + Assert.AreEqual ("T", after.GetString (after.GetGenericParameter (genericParameters [0]).Name)); + Assert.AreEqual (0, after.GetMethodDefinition (pinvoke).RelativeVirtualAddress, "A P/Invoke must keep its zero RVA."); + Assert.AreNotEqual (0, after.GetMethodDefinition (getter).RelativeVirtualAddress); + + Assert.AreEqual ("backing", after.GetString (after.GetFieldDefinition (backing).Name)); + } + + static BlobBuilder Int32FieldSignature () + { + var signature = new BlobBuilder (); + new BlobEncoder (signature).FieldSignature ().Int32 (); + return signature; + } + + static BlobBuilder Int32NoArgsMethodSignature () + { + var signature = new BlobBuilder (); + new BlobEncoder (signature).MethodSignature (isInstanceMethod: true) + .Parameters (0, out ReturnTypeEncoder returnType, out ParametersEncoder _); + returnType.Type ().Int32 (); + return signature; + } + + static BlobBuilder Int32NoArgsStaticMethodSignature () + { + var signature = new BlobBuilder (); + new BlobEncoder (signature).MethodSignature () + .Parameters (0, out ReturnTypeEncoder returnType, out ParametersEncoder _); + returnType.Type ().Int32 (); + return signature; + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs new file mode 100644 index 00000000000..ddb9e07289e --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs @@ -0,0 +1,77 @@ +using System; +using NUnit.Framework; +using Xamarin.Android.Tasks.JniRemapping; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class JniDescriptorTextTests : BaseTest + { + static string? Rename (string cls) => cls == "acme/orig/MyView" ? "a/b/C" : null; + + [Test] + public void RewritesObjectParameterAndReturnTypes () + { + bool changed = JniDescriptorText.TryRewriteDescriptor ("(Lacme/orig/MyView;I)Lacme/orig/MyView;", Rename, out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("(La/b/C;I)La/b/C;", rewritten); + } + + [Test] + public void RewritesArrayOfObjectType () + { + bool changed = JniDescriptorText.TryRewriteDescriptor ("[Lacme/orig/MyView;", Rename, out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("[La/b/C;", rewritten); + } + + [Test] + public void LeavesUnrelatedTypesAndPrimitivesAlone () + { + bool changed = JniDescriptorText.TryRewriteDescriptor ("(Landroid/view/View;[I)V", Rename, out string rewritten); + + Assert.IsFalse (changed); + Assert.AreEqual ("(Landroid/view/View;[I)V", rewritten); + } + + [TestCase ("()V", true)] + [TestCase ("(Ljava/lang/Object;)Z", true)] + [TestCase ("(I)I", true)] + [TestCase ("I", false)] + [TestCase ("Ljava/lang/Object;", false)] + [TestCase ("not a descriptor", false)] + public void ValidatesMethodDescriptors (string descriptor, bool expected) + { + Assert.AreEqual (expected, JniDescriptorText.IsValidMethodDescriptor (descriptor)); + } + + [TestCase ("I", true)] + [TestCase ("[I", true)] + [TestCase ("Ljava/lang/Object;", true)] + [TestCase ("()V", false)] + [TestCase ("", false)] + public void ValidatesFieldDescriptors (string descriptor, bool expected) + { + Assert.AreEqual (expected, JniDescriptorText.IsValidFieldDescriptor (descriptor)); + } + + [Test] + public void ConvertsMethodDescriptorToJavaParameterTypes () + { + var parameters = JniDescriptorText.MethodDescriptorToJavaParameterTypes ("(Landroid/os/Bundle;I[Ljava/lang/String;)V"); + + CollectionAssert.AreEqual (new [] { "android.os.Bundle", "int", "java.lang.String[]" }, parameters); + } + + [Test] + public void ConvertsSingleTypeTokenToJavaSource () + { + Assert.AreEqual ("boolean", JniDescriptorText.JniTypeTokenToJavaSource ("Z")); + Assert.AreEqual ("int[]", JniDescriptorText.JniTypeTokenToJavaSource ("[I")); + Assert.AreEqual ("java.lang.Object", JniDescriptorText.JniTypeTokenToJavaSource ("Ljava/lang/Object;")); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniFixtureBuilder.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniFixtureBuilder.cs new file mode 100644 index 00000000000..b081a4611ed --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniFixtureBuilder.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using System.Text; + +namespace Xamarin.Android.Build.Tests +{ + /// + /// Builds small but structurally faithful managed PE fixtures with System.Reflection.Metadata + /// only, so the JNI rewriter can be exercised end to end without any external assembly. + /// + class JniFixtureBuilder + { + public const string RegisterAttributeNamespace = "Android.Runtime"; + public const string RegisterAttributeName = "RegisterAttribute"; + public const string JavaInteropNamespace = "Java.Interop"; + public const string PrivateImplementationDetails = ""; + + public MetadataBuilder Metadata { get; } = new MetadataBuilder (); + public BlobBuilder Il { get; } = new BlobBuilder (); + public BlobBuilder MappedFieldData { get; } = new BlobBuilder (); + public BlobBuilder ManagedResources { get; } = new BlobBuilder (); + + public Guid Mvid { get; } = Guid.NewGuid (); + public uint TimeDateStamp { get; } = 0x5A5A1234; + public CorFlags Flags { get; set; } = CorFlags.ILOnly; + public int StrongNameSignatureSize { get; set; } + public DebugDirectoryBuilder DebugDirectory { get; set; } + public ResourceSectionBuilder NativeResources { get; set; } + + public AssemblyReferenceHandle CoreLibraryReference { get; } + public TypeReferenceHandle ValueTypeReference { get; } + public TypeReferenceHandle JavaPeerProxyReference { get; } + public TypeReferenceHandle ExceptionReference { get; } + + public MethodDefinitionHandle RegisterCtor1 { get; } + public MethodDefinitionHandle RegisterCtor3 { get; } + public MethodDefinitionHandle JniTypeSignatureCtor1 { get; } + public MethodDefinitionHandle JniMethodSignatureCtor2 { get; } + public MethodDefinitionHandle JniConstructorSignatureCtor1 { get; } + + readonly MethodBodyStreamEncoder bodyEncoder; + TypeDefinitionHandle privateImplementationDetails; + readonly Dictionary sizedTypes = new Dictionary (); + int utf8FieldCounter; + + public JniFixtureBuilder () + { + bodyEncoder = new MethodBodyStreamEncoder (Il); + + Metadata.AddModule (0, Metadata.GetOrAddString ("Fixture.dll"), Metadata.GetOrAddGuid (Mvid), default, default); + Metadata.AddAssembly (Metadata.GetOrAddString ("Fixture"), new Version (1, 0, 0, 0), default, default, 0, AssemblyHashAlgorithm.Sha1); + Metadata.AddTypeDefinition (default, default, Metadata.GetOrAddString (""), default, + MetadataTokens.FieldDefinitionHandle (1), MetadataTokens.MethodDefinitionHandle (1)); + + CoreLibraryReference = Metadata.AddAssemblyReference ( + Metadata.GetOrAddString ("System.Runtime"), new Version (11, 0, 0, 0), default, default, default, default); + ValueTypeReference = Metadata.AddTypeReference (CoreLibraryReference, + Metadata.GetOrAddString ("System"), Metadata.GetOrAddString ("ValueType")); + ExceptionReference = Metadata.AddTypeReference (CoreLibraryReference, + Metadata.GetOrAddString ("System"), Metadata.GetOrAddString ("Exception")); + JavaPeerProxyReference = Metadata.AddTypeReference (CoreLibraryReference, + Metadata.GetOrAddString (JavaInteropNamespace), Metadata.GetOrAddString ("JavaPeerProxy")); + + int fieldStart = NextFieldRid; + int methodStart = NextMethodRid; + RegisterCtor1 = AddAttributeCtor (1); + RegisterCtor3 = AddAttributeCtor (3); + AddType (RegisterAttributeNamespace, RegisterAttributeName, fieldStart, methodStart); + + fieldStart = NextFieldRid; + methodStart = NextMethodRid; + JniTypeSignatureCtor1 = AddAttributeCtor (1); + AddType (JavaInteropNamespace, "JniTypeSignatureAttribute", fieldStart, methodStart); + + fieldStart = NextFieldRid; + methodStart = NextMethodRid; + JniMethodSignatureCtor2 = AddAttributeCtor (2); + AddType (JavaInteropNamespace, "JniMethodSignatureAttribute", fieldStart, methodStart); + + fieldStart = NextFieldRid; + methodStart = NextMethodRid; + JniConstructorSignatureCtor1 = AddAttributeCtor (1); + AddType (JavaInteropNamespace, "JniConstructorSignatureAttribute", fieldStart, methodStart); + } + + public int NextFieldRid => Metadata.GetRowCount (TableIndex.Field) + 1; + + public int NextMethodRid => Metadata.GetRowCount (TableIndex.MethodDef) + 1; + + public TypeDefinitionHandle AddType (string ns, string name, int fieldStart, int methodStart, + TypeAttributes attributes = TypeAttributes.Public | TypeAttributes.Class | TypeAttributes.BeforeFieldInit, + EntityHandle baseType = default) + => Metadata.AddTypeDefinition ( + attributes, + ns == null ? default : Metadata.GetOrAddString (ns), + Metadata.GetOrAddString (name), + baseType, + MetadataTokens.FieldDefinitionHandle (fieldStart), + MetadataTokens.MethodDefinitionHandle (methodStart)); + + public MethodDefinitionHandle AddVoidMethod (string name, int bodyOffset, + MethodAttributes attributes = MethodAttributes.Public | MethodAttributes.HideBySig) + => Metadata.AddMethodDefinition ( + attributes, + MethodImplAttributes.IL, + Metadata.GetOrAddString (name), + Metadata.GetOrAddBlob (BuildVoidNoArgsSignature ()), + bodyOffset, + MetadataTokens.ParameterHandle (Metadata.GetRowCount (TableIndex.Param) + 1)); + + MethodDefinitionHandle AddAttributeCtor (int argCount) + { + var signature = new BlobBuilder (); + new BlobEncoder (signature).MethodSignature (isInstanceMethod: true) + .Parameters (argCount, out ReturnTypeEncoder returnType, out ParametersEncoder parameters); + returnType.Void (); + for (int i = 0; i < argCount; i++) { + parameters.AddParameter ().Type ().String (); + } + + return Metadata.AddMethodDefinition ( + MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName, + MethodImplAttributes.IL, + Metadata.GetOrAddString (".ctor"), + Metadata.GetOrAddBlob (signature), + EmitReturnOnlyBody (), + MetadataTokens.ParameterHandle (Metadata.GetRowCount (TableIndex.Param) + 1)); + } + + static BlobBuilder BuildVoidNoArgsSignature () + { + var signature = new BlobBuilder (); + new BlobEncoder (signature).MethodSignature (isInstanceMethod: true) + .Parameters (0, out ReturnTypeEncoder returnType, out ParametersEncoder _); + returnType.Void (); + return signature; + } + + public int EmitReturnOnlyBody () => EmitBody (encoder => encoder.OpCode (ILOpCode.Ret)); + + public int EmitLoadStringBody (params UserStringHandle [] strings) + => EmitBody (encoder => { + foreach (UserStringHandle handle in strings) { + encoder.LoadString (handle); + encoder.OpCode (ILOpCode.Pop); + } + encoder.OpCode (ILOpCode.Ret); + }); + + public int EmitBody (Action emit, StandaloneSignatureHandle localSignature = default, + ControlFlowBuilder controlFlow = null, int maxStack = 8) + { + var code = new BlobBuilder (); + var encoder = new InstructionEncoder (code, controlFlow); + emit (encoder); + return bodyEncoder.AddMethodBody (encoder, maxStack, localSignature); + } + + public UserStringHandle String (string value) => Metadata.GetOrAddUserString (value); + + public BlobHandle AttributeBlob (params string [] fixedStringArgs) + { + var blob = new BlobBuilder (); + blob.WriteUInt16 (0x0001); // Prolog + foreach (string arg in fixedStringArgs) { + blob.WriteSerializedString (arg); + } + blob.WriteUInt16 (0x0000); // NumNamed + return Metadata.GetOrAddBlob (blob); + } + + public void AddEmbeddedResource (string name, byte [] content) + { + ManagedResources.Align (8); + int offset = ManagedResources.Count; + ManagedResources.WriteInt32 (content.Length); + ManagedResources.WriteBytes (content); + Metadata.AddManifestResource (ManifestResourceAttributes.Public, Metadata.GetOrAddString (name), default, (uint) offset); + } + + /// + /// Emits a null-terminated UTF-8 JNI datum exactly the way + /// Microsoft.Android.Sdk.TrimmableTypeMap's PEAssemblyBuilder does: a static HasFieldRVA + /// field whose type is a <PrivateImplementationDetails>/__utf8_N + /// explicit-layout value type sized to the datum. + /// + public FieldDefinitionHandle AddUtf8Field (string value) + { + int size = Encoding.UTF8.GetByteCount (value) + 1; + TypeDefinitionHandle sizedType = GetOrCreateSizedType (size); + + var signature = new BlobBuilder (); + new BlobEncoder (signature).FieldSignature ().Type (sizedType, isValueType: true); + + int rva = MappedFieldData.Count; + var bytes = new byte [size]; + Encoding.UTF8.GetBytes (value, 0, value.Length, bytes, 0); + MappedFieldData.WriteBytes (bytes); + + FieldDefinitionHandle handle = Metadata.AddFieldDefinition ( + FieldAttributes.Static | FieldAttributes.Assembly | FieldAttributes.HasFieldRVA | FieldAttributes.InitOnly, + Metadata.GetOrAddString ("__utf8_" + utf8FieldCounter++), + Metadata.GetOrAddBlob (signature)); + Metadata.AddFieldRelativeVirtualAddress (handle, rva); + return handle; + } + + public TypeDefinitionHandle EnsurePrivateImplementationDetails () + { + if (!privateImplementationDetails.IsNil) { + return privateImplementationDetails; + } + + privateImplementationDetails = AddType (null, PrivateImplementationDetails, NextFieldRid, NextMethodRid, + TypeAttributes.NotPublic | TypeAttributes.Sealed | TypeAttributes.Abstract | TypeAttributes.BeforeFieldInit); + return privateImplementationDetails; + } + + TypeDefinitionHandle GetOrCreateSizedType (int size) + { + if (sizedTypes.TryGetValue (size, out TypeDefinitionHandle existing)) { + return existing; + } + + TypeDefinitionHandle enclosing = EnsurePrivateImplementationDetails (); + TypeDefinitionHandle handle = AddType (null, "__utf8_" + size, NextFieldRid, NextMethodRid, + TypeAttributes.NestedAssembly | TypeAttributes.ExplicitLayout | TypeAttributes.Sealed | TypeAttributes.AnsiClass, + ValueTypeReference); + Metadata.AddTypeLayout (handle, packingSize: 1, size: (uint) size); + Metadata.AddNestedType (handle, enclosing); + + sizedTypes [size] = handle; + return handle; + } + + public byte [] Serialize () + { + var headerBuilder = new PEHeaderBuilder ( + imageCharacteristics: Characteristics.Dll | Characteristics.ExecutableImage); + + var peBuilder = new ManagedPEBuilder ( + headerBuilder, + new MetadataRootBuilder (Metadata), + Il, + mappedFieldData: MappedFieldData.Count > 0 ? MappedFieldData : null, + managedResources: ManagedResources.Count > 0 ? ManagedResources : null, + nativeResources: NativeResources, + debugDirectoryBuilder: DebugDirectory, + strongNameSignatureSize: StrongNameSignatureSize, + entryPoint: default, + flags: Flags, + deterministicIdProvider: _ => new BlobContentId (Mvid, TimeDateStamp)); + + var peBlob = new BlobBuilder (); + peBuilder.Serialize (peBlob); + + using var stream = new MemoryStream (); + peBlob.WriteContentTo (stream); + return stream.ToArray (); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs new file mode 100644 index 00000000000..ff6cc05e6cc --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs @@ -0,0 +1,109 @@ +using System.IO; +using NUnit.Framework; +using Xamarin.Android.Tasks.JniRemapping; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class LdstrRewriterTests : BaseTest + { + static R8Mapping BuildMapping () => R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onClick(android.view.View) -> a\n" + + " int someField -> x\n" + + "acme.orig.Marker -> a.b.D:\n")); + + [Test] + public void RewritesJniPeerMembersEncodedMethodId () + { + bool changed = LdstrRewriter.TryRewrite ("onClick.(Landroid/view/View;)V", "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("a.(Landroid/view/View;)V", rewritten); + } + + [Test] + public void RewritesJniPeerMembersEncodedFieldId () + { + bool changed = LdstrRewriter.TryRewrite ("someField.I", "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("x.I", rewritten); + } + + [Test] + public void RewritesBareConstructorDescriptorEmbeddedTypesOnly () + { + bool changed = LdstrRewriter.TryRewrite ("(Lacme/orig/Marker;)V", "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("(La/b/D;)V", rewritten); + } + + [Test] + public void UnchangedBareDescriptorIsReportedAsNotChanged () + { + bool changed = LdstrRewriter.TryRewrite ("()V", "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsFalse (changed); + Assert.AreEqual ("()V", rewritten); + } + + [Test] + public void RewritesSingleRegisterNativesLine () + { + bool changed = LdstrRewriter.TryRewrite ("onClick:(Landroid/view/View;)V:cb", "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("a:(Landroid/view/View;)V:cb", rewritten); + } + + [Test] + public void RewritesMultilineRegisterNativesBlockWithoutTrailingNewline () + { + string original = "onClick:(Landroid/view/View;)V:cb1\nunrelated:()V:cb2"; + bool changed = LdstrRewriter.TryRewrite (original, "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("a:(Landroid/view/View;)V:cb1\nunrelated:()V:cb2", rewritten); + } + + [Test] + public void RewritesMultilineRegisterNativesBlockWithTrailingNewline () + { + string original = "onClick:(Landroid/view/View;)V:cb1\n"; + bool changed = LdstrRewriter.TryRewrite (original, "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("a:(Landroid/view/View;)V:cb1\n", rewritten); + } + + [Test] + public void RewritesExactClassNameString () + { + bool changed = LdstrRewriter.TryRewrite ("acme/orig/Marker", null, BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("a/b/D", rewritten); + } + + [Test] + public void LeavesUnrelatedDotNetStringsAlone () + { + bool changed = LdstrRewriter.TryRewrite ("System.String", "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsFalse (changed); + Assert.AreEqual ("System.String", rewritten); + } + + [Test] + public void LeavesUnknownMemberIdAloneWhenOwnerUnknown () + { + bool changed = LdstrRewriter.TryRewrite ("someField.I", null, BuildMapping (), out string rewritten); + + Assert.IsFalse (changed); + Assert.AreEqual ("someField.I", rewritten); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/NativeResourceSectionCopierTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/NativeResourceSectionCopierTests.cs new file mode 100644 index 00000000000..25d7cce045b --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/NativeResourceSectionCopierTests.cs @@ -0,0 +1,124 @@ +using System.Collections.Immutable; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using NUnit.Framework; +using Xamarin.Android.Tasks.JniRemapping; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class NativeResourceSectionCopierTests : BaseTest + { + const int ResourceDirectoryHeaderSize = 16; + const int ResourceDirectoryEntrySize = 8; + const int ResourceDataEntrySize = 16; + + /// + /// A minimal hand-built Win32 resource directory: one root directory with a single ID + /// entry that points straight at one data entry (no named entries, no subdirectories). + /// + sealed class OneEntryResourceSectionBuilder : ResourceSectionBuilder + { + readonly byte [] data; + readonly int dataEntryRvaDelta; + readonly uint dataEntrySize; + + /// + /// Added to the section's own final RVA to produce the data entry's absolute + /// OffsetToData. Use 0 for a well-formed, in-range entry; a large positive + /// value to simulate a corrupt entry that points past the copied section. + /// + public OneEntryResourceSectionBuilder (byte [] data, int dataEntryRvaDelta, uint dataEntrySize) + { + this.data = data; + this.dataEntryRvaDelta = dataEntryRvaDelta; + this.dataEntrySize = dataEntrySize; + } + + protected override void Serialize (BlobBuilder builder, SectionLocation location) + { + int dataEntryOffset = ResourceDirectoryHeaderSize + ResourceDirectoryEntrySize; + int dataOffset = dataEntryOffset + ResourceDataEntrySize; + + // IMAGE_RESOURCE_DIRECTORY: no named entries, one ID entry. + builder.WriteUInt32 (0); // Characteristics + builder.WriteUInt32 (0); // TimeDateStamp + builder.WriteUInt16 (0); // MajorVersion + builder.WriteUInt16 (0); // MinorVersion + builder.WriteUInt16 (0); // NumberOfNamedEntries + builder.WriteUInt16 (1); // NumberOfIdEntries + + // IMAGE_RESOURCE_DIRECTORY_ENTRY: straight to the data entry (no high bit). + builder.WriteUInt32 (1); // Id + builder.WriteUInt32 ((uint) dataEntryOffset); // OffsetToData + + // IMAGE_RESOURCE_DATA_ENTRY. + uint dataRva = unchecked ((uint) (location.RelativeVirtualAddress + dataOffset + dataEntryRvaDelta)); + builder.WriteUInt32 (dataRva); // OffsetToData (an absolute RVA, not a section offset) + builder.WriteUInt32 (dataEntrySize); // Size + builder.WriteUInt32 (0); // CodePage + builder.WriteUInt32 (0); // Reserved + + builder.WriteBytes (data); + } + } + + static byte [] BuildAndReadBack (ResourceSectionBuilder nativeResources) + { + var fixture = new JniFixtureBuilder { + NativeResources = nativeResources, + }; + return fixture.Serialize (); + } + + [Test] + public void ThrowsWhenADataEntryRvaPlusSizeExtendsPastTheCopiedSection () + { + byte [] payload = { 1, 2, 3, 4 }; + // The RVA delta pushes OffsetToData well past the end of the section this class + // copies, simulating a corrupt (or malicious) resource directory. + var builder = new OneEntryResourceSectionBuilder (payload, dataEntryRvaDelta: 4096, dataEntrySize: (uint) payload.Length); + + byte [] image = BuildAndReadBack (builder); + using var peReader = new PEReader (ImmutableArray.Create (image)); + + var ex = Assert.Throws (() => NativeResourceSectionCopier.TryCreate (peReader)); + StringAssert.Contains ("outside of the copied resource section", ex.Message); + } + + [Test] + public void ThrowsWhenADataEntrySizeAloneExtendsPastTheCopiedSection () + { + byte [] payload = { 1, 2, 3, 4 }; + // The RVA itself is in range, but the declared size overruns the section. + var builder = new OneEntryResourceSectionBuilder (payload, dataEntryRvaDelta: 0, dataEntrySize: 0x7FFFFFFF); + + byte [] image = BuildAndReadBack (builder); + using var peReader = new PEReader (ImmutableArray.Create (image)); + + var ex = Assert.Throws (() => NativeResourceSectionCopier.TryCreate (peReader)); + StringAssert.Contains ("outside of the copied resource section", ex.Message); + } + + [Test] + public void AcceptsAndRelocatesAWellFormedDataEntry () + { + byte [] payload = { 0xAA, 0xBB, 0xCC, 0xDD }; + var builder = new OneEntryResourceSectionBuilder (payload, dataEntryRvaDelta: 0, dataEntrySize: (uint) payload.Length); + + byte [] image = BuildAndReadBack (builder); + using var peReader = new PEReader (ImmutableArray.Create (image)); + + NativeResourceSectionCopier copier = NativeResourceSectionCopier.TryCreate (peReader); + Assert.IsNotNull (copier, "A resource directory was built but TryCreate did not find it."); + + DirectoryEntry directory = peReader.PEHeaders.PEHeader.ResourceTableDirectory; + CollectionAssert.AreEqual (payload, + peReader.GetSectionData (directory.RelativeVirtualAddress) + .GetReader (directory.Size - payload.Length, payload.Length) + .ReadBytes (payload.Length), + "The payload bytes following the data entry should be exactly what was written."); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs new file mode 100644 index 00000000000..831e330a5d5 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs @@ -0,0 +1,217 @@ +using System; +using System.IO; +using NUnit.Framework; +using Xamarin.Android.Tasks.JniRemapping; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class R8MappingTests : BaseTest + { + [Test] + public void ParsesSimpleClassAndFieldMapping () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " int someField -> x\n")); + + Assert.IsTrue (mapping.TryGetRenamedClass ("acme/orig/MyView", out string renamedClass)); + Assert.AreEqual ("a/b/C", renamedClass); + + Assert.IsTrue (mapping.TryGetRenamedField ("acme/orig/MyView", "someField", out string renamedField)); + Assert.AreEqual ("x", renamedField); + } + + [Test] + public void ParsesMethodMappingWithParameters () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onClick(android.view.View) -> a\n" + + " void onClick(android.view.View,int) -> b\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View" }, out string renamed1)); + Assert.AreEqual ("a", renamed1); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View", "int" }, out string renamed2)); + Assert.AreEqual ("b", renamed2); + } + + [Test] + public void ParsesMethodMappingWithLeadingAndTrailingLineRanges () + { + // "startLine:endLine:returnType name(params):origStartLine:origEndLine -> obfuscated" + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " 4:10:void onCreate(android.os.Bundle):23:29 -> a\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onCreate", new [] { "android.os.Bundle" }, out string renamed)); + Assert.AreEqual ("a", renamed); + } + + [Test] + public void ParsesMethodMappingWithASingleTrailingLineNumber () + { + // R8 sometimes collapses the trailing original range to a single line number when + // start and end coincide: "startLine:endLine:returnType name(params):originalLine -> obfuscated". + var mapping = R8Mapping.Parse (new StringReader ( + "example.Foo -> a.b.C:\n" + + "acme.orig.MyView -> a.b.D:\n" + + " 4:4:void run(example.Foo):2 -> a\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "run", new [] { "example.Foo" }, out string renamed)); + Assert.AreEqual ("a", renamed); + } + + [Test] + public void ParsesMethodMappingWithOnlyATrailingLineRange () + { + // No leading "startLine:endLine:" prefix, only the trailing ":originalStart:originalEnd". + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onPause():23:29 -> a\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onPause", Array.Empty (), out string renamed)); + Assert.AreEqual ("a", renamed); + } + + [Test] + public void ParsesNoArgMethodMapping () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onStart() -> a\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onStart", Array.Empty (), out string renamed)); + Assert.AreEqual ("a", renamed); + } + + [Test] + public void KeepsDollarInNestedClassNames () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView$Inner -> a.b.C$D:\n")); + + Assert.IsTrue (mapping.TryGetRenamedClass ("acme/orig/MyView$Inner", out string renamed)); + Assert.AreEqual ("a/b/C$D", renamed); + } + + [Test] + public void TranslatesConstructorNameForLookup () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void (int) -> \n")); + + // JVM never renames , but the lookup key must still translate ".ctor" -> "". + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", R8Mapping.JniMemberNameToMappingName (".ctor"), new [] { "int" }, out string renamed)); + Assert.AreEqual ("", renamed); + } + + [Test] + public void NameOnlyLookupSucceedsWhenUnambiguous () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onStart(int) -> a\n" + + " void onStart(int,int) -> a\n")); // Both overloads map to the same name. + + Assert.IsTrue (mapping.TryGetRenamedMethodByNameOnly ("acme/orig/MyView", "onStart", out string renamed)); + Assert.AreEqual ("a", renamed); + } + + [Test] + public void NameOnlyLookupFailsWhenAmbiguous () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onStart(int) -> a\n" + + " void onStart(int,int) -> b\n")); // Different renames - ambiguous without a descriptor. + + Assert.IsFalse (mapping.TryGetRenamedMethodByNameOnly ("acme/orig/MyView", "onStart", out _)); + } + + [Test] + public void UnknownClassOrMemberReturnsFalse () + { + var mapping = R8Mapping.Parse (new StringReader ("acme.orig.MyView -> a.b.C:\n")); + + Assert.IsFalse (mapping.TryGetRenamedClass ("acme/orig/Other", out _)); + Assert.IsFalse (mapping.TryGetRenamedField ("acme/orig/MyView", "missing", out _)); + Assert.IsFalse (mapping.TryGetRenamedMethod ("acme/orig/MyView", "missing", Array.Empty (), out _)); + } + + [Test] + public void IgnoresCommentsAndBlankLines () + { + var mapping = R8Mapping.Parse (new StringReader ( + "# a comment\n" + + "\n" + + "acme.orig.MyView -> a.b.C:\n" + + "\n" + + " int someField -> x\n")); + + Assert.IsTrue (mapping.TryGetRenamedClass ("acme/orig/MyView", out string renamed)); + Assert.AreEqual ("a/b/C", renamed); + } + + [Test] + public void IgnoresIndentedMetadataComments () + { + // R8 emits indented "# {...}" comments under member lines to carry extra metadata + // (e.g. inlining/source-position info); these must not be mistaken for member lines. + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onCreate(android.os.Bundle) -> a\n" + + " # {'id':'com.android.tools.r8.synthesized'}\n" + + " int someField -> x\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onCreate", new [] { "android.os.Bundle" }, out string renamedMethod)); + Assert.AreEqual ("a", renamedMethod); + + Assert.IsTrue (mapping.TryGetRenamedField ("acme/orig/MyView", "someField", out string renamedField)); + Assert.AreEqual ("x", renamedField); + } + + [Test] + public void IgnoresQualifiedInlineCallFrameMappings () + { + var mapping = R8Mapping.Parse (new StringReader ( + "androidx.collection.LongSparseArray -> a.b.C:\n" + + " 299:299:void androidx.collection.LongSparseArrayKt.commonGc(androidx.collection.LongSparseArray) -> keyAt\n" + + " 299:299:long keyAt(int):183 -> keyAt\n" + + " 307:307:void androidx.collection.LongSparseArrayKt.commonGc(androidx.collection.LongSparseArray) -> indexOfKey\n" + + " 307:307:int indexOfKey(long):209 -> indexOfKey\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "keyAt", new [] { "int" }, out string keyAt)); + Assert.AreEqual ("keyAt", keyAt); + Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "indexOfKey", new [] { "long" }, out string indexOfKey)); + Assert.AreEqual ("indexOfKey", indexOfKey); + Assert.IsFalse (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "androidx.collection.LongSparseArrayKt.commonGc", new [] { "androidx.collection.LongSparseArray" }, out _)); + } + + [Test] + public void TreatsMethodInlinedIntoMultipleDestinationsAsAmbiguous () + { + var mapping = R8Mapping.Parse (new StringReader ( + "androidx.collection.SimpleArrayMap -> a.b.C:\n" + + " 299:299:java.lang.Object getOrDefaultInternal(java.lang.Object,java.lang.Object) -> get\n" + + " 299:299:java.lang.Object get(java.lang.Object):278 -> get\n" + + " 299:299:java.lang.Object getOrDefaultInternal(java.lang.Object,java.lang.Object) -> getOrDefault\n" + + " 299:299:java.lang.Object getOrDefault(java.lang.Object,java.lang.Object):294 -> getOrDefault\n")); + + Assert.IsFalse (mapping.TryGetRenamedMethod ("androidx/collection/SimpleArrayMap", "getOrDefaultInternal", new [] { "java.lang.Object", "java.lang.Object" }, out _)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/SimpleArrayMap", "get", new [] { "java.lang.Object" }, out string get)); + Assert.AreEqual ("get", get); + Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/SimpleArrayMap", "getOrDefault", new [] { "java.lang.Object", "java.lang.Object" }, out string getOrDefault)); + Assert.AreEqual ("getOrDefault", getOrDefault); + } + + [Test] + public void ThrowsOnMemberLineBeforeAnyClassLine () + { + Assert.Throws (() => R8Mapping.Parse (new StringReader (" int someField -> x\n"))); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/AssemblyRebuilder.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/AssemblyRebuilder.cs new file mode 100644 index 00000000000..362b51e8664 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/AssemblyRebuilder.cs @@ -0,0 +1,1065 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + sealed class AssemblyRebuildResult + { + public byte [] Image { get; } + + /// + /// True when the source assembly was marked StrongNameSigned. The rebuilt image + /// cannot carry a valid signature (no key is available here), so only the + /// StrongNameSigned flag is cleared - the signature directory keeps its original + /// size so the image is genuinely delay-signed (space is reserved) and can be re-signed + /// without another rewrite, rather than left with a stale flag or a truncated directory. + /// + public bool StrongNameSignatureCleared { get; } + + public AssemblyRebuildResult (byte [] image, bool strongNameSignatureCleared) + { + Image = image; + StrongNameSignatureCleared = strongNameSignatureCleared; + } + } + + /// + /// Pass two of the rewrite: reconstructs a complete managed PE from a source assembly, + /// cloning every metadata table row in its original order - so every entity token keeps its + /// value - while re-emitting the heaps, the method bodies, the managed resources, and the + /// mapped field data. The JNI edits collected by are applied + /// as the corresponding data is re-emitted, which is what lets two use sites that shared one + /// deduplicated #US/#Blob entry receive different values. + /// + sealed class AssemblyRebuilder + { + static readonly TableIndex [] UnsupportedTables = { + TableIndex.FieldPtr, TableIndex.MethodPtr, TableIndex.ParamPtr, TableIndex.EventPtr, TableIndex.PropertyPtr, + TableIndex.EncLog, TableIndex.EncMap, + TableIndex.AssemblyProcessor, TableIndex.AssemblyOS, TableIndex.AssemblyRefProcessor, TableIndex.AssemblyRefOS, + TableIndex.Document, TableIndex.MethodDebugInformation, TableIndex.LocalScope, TableIndex.LocalVariable, + TableIndex.LocalConstant, TableIndex.ImportScope, TableIndex.StateMachineMethod, TableIndex.CustomDebugInformation, + }; + + // Tables whose rows carry tokens that IL, coded indices, or the plan itself can reference; + // their row counts must survive the round trip exactly. + static readonly TableIndex [] TokenCriticalTables = { + TableIndex.TypeRef, TableIndex.TypeDef, TableIndex.Field, TableIndex.MethodDef, TableIndex.Param, + TableIndex.InterfaceImpl, TableIndex.MemberRef, TableIndex.CustomAttribute, TableIndex.DeclSecurity, + TableIndex.StandAloneSig, TableIndex.Event, TableIndex.Property, TableIndex.ModuleRef, TableIndex.TypeSpec, + TableIndex.AssemblyRef, TableIndex.File, TableIndex.ExportedType, TableIndex.ManifestResource, + TableIndex.GenericParam, TableIndex.MethodSpec, TableIndex.GenericParamConstraint, + }; + + readonly PEReader peReader; + readonly MetadataReader reader; + readonly JniRewritePlan plan; + readonly FieldRvaTable fieldRvaTable; + readonly MetadataBuilder metadata = new (); + readonly BlobBuilder ilStream = new (); + readonly BlobBuilder mappedFieldData = new (); + readonly BlobBuilder managedResources = new (); + + readonly Dictionary bodyOffsets = new (); + readonly Dictionary newFieldRvaOffsets = new (); + readonly Dictionary resizedFieldTypes = new (); + readonly Dictionary resourceOffsets = new (); + readonly List syntheticSizedTypes = new (); + + int sourceTypeDefCount; + + sealed class SyntheticSizedType + { + public TypeDefinitionHandle Handle { get; } + public TypeDefinitionHandle Template { get; } + public TypeDefinitionHandle Enclosing { get; } + public int Size { get; } + + public SyntheticSizedType (TypeDefinitionHandle handle, TypeDefinitionHandle template, TypeDefinitionHandle enclosing, int size) + { + Handle = handle; + Template = template; + Enclosing = enclosing; + Size = size; + } + } + + public AssemblyRebuilder (PEReader peReader, MetadataReader reader, JniRewritePlan plan, FieldRvaTable fieldRvaTable) + { + this.peReader = peReader; + this.reader = reader; + this.plan = plan; + this.fieldRvaTable = fieldRvaTable; + } + + public AssemblyRebuildResult Build () + { + ValidateSupported (); + sourceTypeDefCount = reader.GetTableRowCount (TableIndex.TypeDef); + + PlanMappedFieldData (); + EmitMethodBodies (); + EmitManagedResources (); + CloneTables (); + EmitSyntheticSizedTypes (); + ValidateRowCounts (); + + return Serialize (); + } + + void ValidateSupported () + { + if (reader.MetadataKind != MetadataKind.Ecma335) { + throw new JniRewriteException ($"Only ECMA-335 metadata can be rewritten; this assembly uses '{reader.MetadataKind}'."); + } + + foreach (TableIndex table in UnsupportedTables) { + int rows = reader.GetTableRowCount (table); + if (rows > 0) { + throw new JniRewriteException ($"The assembly uses the '{table}' metadata table ({rows} row(s)), which this rewriter cannot reproduce."); + } + } + + CorHeader? corHeader = peReader.PEHeaders.CorHeader; + if (corHeader == null) { + throw new JniRewriteException ("The file has no CLI header and is not a managed assembly."); + } + if ((corHeader.Flags & CorFlags.NativeEntryPoint) != 0) { + throw new JniRewriteException ("The assembly declares a native entry point, which this rewriter cannot reproduce."); + } + + RequireEmptyDirectory (corHeader.ManagedNativeHeaderDirectory, "ReadyToRun/NGen native header"); + RequireEmptyDirectory (corHeader.VtableFixupsDirectory, "CLI vtable fixups (C++/CLI)"); + RequireEmptyDirectory (corHeader.ExportAddressTableJumpsDirectory, "CLI export address table jumps"); + + PEHeader? peHeader = peReader.PEHeaders.PEHeader; + if (peHeader == null) { + throw new JniRewriteException ("The file has no PE optional header and cannot be rebuilt."); + } + + // Authenticode covers the complete PE image and is invalidated by any metadata rewrite. + // ManagedPEBuilder intentionally omits the source certificate table; APK signing protects + // the final packaged image instead. + RequireEmptyDirectory (peHeader.ExportTableDirectory, "native export table"); + RequireEmptyDirectory (peHeader.DelayImportTableDirectory, "delay import table"); + RequireEmptyDirectory (peHeader.LoadConfigTableDirectory, "load configuration table"); + RequireEmptyDirectory (peHeader.ThreadLocalStorageTableDirectory, "thread local storage table"); + RequireEmptyDirectory (peHeader.ExceptionTableDirectory, "native exception (unwind) table"); + + int entryPointToken = corHeader.EntryPointTokenOrRelativeVirtualAddress; + if (entryPointToken != 0 && (entryPointToken & 0xFF000000) != 0x06000000) { + throw new JniRewriteException ($"The assembly's entry point token 0x{entryPointToken:X8} is not a MethodDef token; multi-module entry points are not supported."); + } + } + + static void RequireEmptyDirectory (DirectoryEntry directory, string description) + { + if (directory.Size != 0 || directory.RelativeVirtualAddress != 0) { + throw new JniRewriteException ($"The assembly has a {description}, which this rewriter cannot reproduce."); + } + } + + void ValidateRowCounts () + { + foreach (TableIndex table in TokenCriticalTables) { + int expected = reader.GetTableRowCount (table); + if (table == TableIndex.TypeDef) { + expected += syntheticSizedTypes.Count; + } + + int actual = metadata.GetRowCount (table); + if (actual != expected) { + throw new JniRewriteException ($"Internal error: rebuilt '{table}' table has {actual} row(s) but {expected} were expected; metadata tokens would move."); + } + } + } + + /// + /// Re-lays out the FieldRVA data. The original block is copied verbatim, keeping + /// every field's relative offset (and therefore its alignment), and rewritten UTF-8 JNI + /// data is written back over its own slot when it still fits. Longer replacements are + /// appended after the original block and the field is re-typed to a wider + /// __utf8_N value type. + /// + void PlanMappedFieldData () + { + IReadOnlyList entries = fieldRvaTable.Entries; + if (entries.Count == 0) { + return; + } + + int baseRva = int.MaxValue; + int endRva = 0; + foreach (FieldRvaEntry entry in entries) { + baseRva = Math.Min (baseRva, entry.RelativeVirtualAddress); + endRva = Math.Max (endRva, entry.RelativeVirtualAddress + entry.Data.Length); + } + + PEMemoryBlock block = peReader.GetSectionData (baseRva); + int length = endRva - baseRva; + if (block.Length < length) { + throw new JniRewriteException ("The mapped field data block extends past the end of its PE section."); + } + + byte [] data = block.GetReader (0, length).ReadBytes (length); + var appended = new List> (); + + foreach (FieldRvaEntry entry in entries) { + int offset = entry.RelativeVirtualAddress - baseRva; + string? newValue = plan.GetUtf8FieldValue (entry.Field); + if (newValue == null) { + newFieldRvaOffsets [entry.Field] = offset; + continue; + } + + byte [] replacement = EncodeNullTerminatedUtf8 (newValue); + if (replacement.Length <= entry.Data.Length) { + // Shorter or equal: write the NUL-terminated bytes over the original slot and + // zero the tail. The field keeps its declared size, so no new type is needed. + Array.Clear (data, offset, entry.Data.Length); + Array.Copy (replacement, 0, data, offset, replacement.Length); + newFieldRvaOffsets [entry.Field] = offset; + continue; + } + + appended.Add (new KeyValuePair (entry.Field, replacement)); + resizedFieldTypes [entry.Field] = GetOrCreateSizedType (entry, replacement.Length); + } + + mappedFieldData.WriteBytes (data); + foreach (KeyValuePair extra in appended) { + mappedFieldData.Align (ManagedPEBuilder.MappedFieldDataAlignment); + newFieldRvaOffsets [extra.Key] = mappedFieldData.Count; + mappedFieldData.WriteBytes (extra.Value); + } + } + + static byte [] EncodeNullTerminatedUtf8 (string value) + { + byte [] utf8 = System.Text.Encoding.UTF8.GetBytes (value); + var result = new byte [utf8.Length + 1]; + Array.Copy (utf8, result, utf8.Length); + return result; + } + + /// + /// Finds - or schedules the creation of - the <PrivateImplementationDetails>/__utf8_N + /// explicit-layout value type of the requested size. New types are appended after every + /// cloned TypeDef row, so no existing token moves. + /// + TypeDefinitionHandle GetOrCreateSizedType (FieldRvaEntry entry, int size) + { + if (entry.Utf8SizedType.IsNil) { + throw new JniRewriteException ("Internal error: a non-UTF-8 mapped field was scheduled for replacement."); + } + + TypeDefinition template = reader.GetTypeDefinition (entry.Utf8SizedType); + string wantedName = FieldRvaTable.Utf8FieldNamePrefix + size.ToString (System.Globalization.CultureInfo.InvariantCulture); + TypeDefinitionHandle enclosing = template.GetDeclaringType (); + + foreach (TypeDefinitionHandle candidate in reader.TypeDefinitions) { + TypeDefinition typeDef = reader.GetTypeDefinition (candidate); + if (reader.GetString (typeDef.Name) == wantedName && typeDef.GetDeclaringType ().Equals (enclosing)) { + return candidate; + } + } + + foreach (SyntheticSizedType existing in syntheticSizedTypes) { + if (existing.Size == size && existing.Enclosing.Equals (enclosing)) { + return existing.Handle; + } + } + + var handle = MetadataTokens.TypeDefinitionHandle (sourceTypeDefCount + syntheticSizedTypes.Count + 1); + syntheticSizedTypes.Add (new SyntheticSizedType (handle, entry.Utf8SizedType, enclosing, size)); + return handle; + } + + void EmitSyntheticSizedTypes () + { + if (syntheticSizedTypes.Count == 0) { + return; + } + + var fieldList = MetadataTokens.FieldDefinitionHandle (reader.GetTableRowCount (TableIndex.Field) + 1); + var methodList = MetadataTokens.MethodDefinitionHandle (reader.GetTableRowCount (TableIndex.MethodDef) + 1); + + foreach (SyntheticSizedType synthetic in syntheticSizedTypes) { + TypeDefinition template = reader.GetTypeDefinition (synthetic.Template); + string name = FieldRvaTable.Utf8FieldNamePrefix + synthetic.Size.ToString (System.Globalization.CultureInfo.InvariantCulture); + + TypeDefinitionHandle added = metadata.AddTypeDefinition ( + template.Attributes, + CloneString (template.Namespace), + metadata.GetOrAddString (name), + template.BaseType, + fieldList, + methodList); + + if (!added.Equals (synthetic.Handle)) { + throw new JniRewriteException ("Internal error: a synthetic sized type did not land at its reserved token."); + } + + metadata.AddTypeLayout (added, packingSize: 1, size: (uint) synthetic.Size); + + if (!synthetic.Enclosing.IsNil) { + metadata.AddNestedType (added, synthetic.Enclosing); + } + } + } + + void EmitMethodBodies () + { + var encoder = new MethodBodyStreamEncoder (ilStream); + int methodCount = reader.GetTableRowCount (TableIndex.MethodDef); + + for (int rid = 1; rid <= methodCount; rid++) { + var handle = MetadataTokens.MethodDefinitionHandle (rid); + MethodDefinition method = reader.GetMethodDefinition (handle); + if (method.RelativeVirtualAddress == 0) { + bodyOffsets [handle] = -1; + continue; + } + + MethodBodyBlock body = peReader.GetMethodBody (method.RelativeVirtualAddress); + byte [] il = RewriteIL (handle, body.GetILBytes () ?? Array.Empty (), out bool hasDynamicStackAllocation); + ImmutableArray regions = body.ExceptionRegions; + + MethodBodyStreamEncoder.MethodBody emitted = encoder.AddMethodBody ( + il.Length, + body.MaxStack, + regions.Length, + HasSmallExceptionRegions (regions), + body.LocalSignature, + body.LocalVariablesInitialized ? MethodBodyAttributes.InitLocals : MethodBodyAttributes.None, + hasDynamicStackAllocation); + + new BlobWriter (emitted.Instructions).WriteBytes (il); + + ExceptionRegionEncoder regionEncoder = emitted.ExceptionRegions; + foreach (ExceptionRegion region in regions) { + regionEncoder.Add (region.Kind, region.TryOffset, region.TryLength, region.HandlerOffset, region.HandlerLength, + region.Kind == ExceptionRegionKind.Catch ? region.CatchType : default, + region.Kind == ExceptionRegionKind.Filter ? region.FilterOffset : 0); + } + + bodyOffsets [handle] = emitted.Offset; + } + } + + static bool HasSmallExceptionRegions (ImmutableArray regions) + { + if (!ExceptionRegionEncoder.IsSmallRegionCount (regions.Length)) { + return false; + } + foreach (ExceptionRegion region in regions) { + if (!ExceptionRegionEncoder.IsSmallExceptionRegion (region.TryOffset, region.TryLength) || + !ExceptionRegionEncoder.IsSmallExceptionRegion (region.HandlerOffset, region.HandlerLength)) { + return false; + } + } + return true; + } + + /// + /// Copies a method body's IL, replacing every ldstr token: the #US heap is + /// rebuilt from scratch, so even unchanged strings need a fresh handle. Instruction widths + /// never change, so branch targets, exception region offsets, and PDB sequence points stay + /// valid. + /// + byte [] RewriteIL (MethodDefinitionHandle method, byte [] original, out bool hasDynamicStackAllocation) + { + var il = (byte []) original.Clone (); + Dictionary? replacements = plan.GetUserStrings (method); + bool localloc = false; + + IlInstructionScanner.Walk (il, (code, _, operandOffset, _) => { + if (code == (ushort) ILOpCode.Localloc) { + localloc = true; + return; + } + if (code != (ushort) ILOpCode.Ldstr) { + return; + } + + uint token = IlInstructionScanner.ReadUInt32 (il, operandOffset); + if ((token & 0xFF000000) != 0x70000000) { + throw new JniRewriteException ($"Malformed IL: ldstr operand 0x{token:X8} is not a #US token."); + } + + string? replacement = null; + replacements?.TryGetValue (operandOffset, out replacement); + string value = replacement ?? reader.GetUserString (MetadataTokens.UserStringHandle ((int) (token & 0x00FFFFFF))); + + UserStringHandle newHandle = metadata.GetOrAddUserString (value); + IlInstructionScanner.WriteUInt32 (il, operandOffset, (uint) MetadataTokens.GetToken (newHandle)); + }); + + hasDynamicStackAllocation = localloc; + return il; + } + + void EmitManagedResources () + { + int resourceCount = reader.GetTableRowCount (TableIndex.ManifestResource); + if (resourceCount == 0) { + return; + } + + CorHeader corHeader = GetCorHeader (); + DirectoryEntry directory = corHeader.ResourcesDirectory; + + for (int rid = 1; rid <= resourceCount; rid++) { + ManifestResource resource = reader.GetManifestResource (MetadataTokens.ManifestResourceHandle (rid)); + if (!resource.Implementation.IsNil) { + continue; // Lives in another file; the offset is meaningful there, not here. + } + + if (directory.RelativeVirtualAddress == 0) { + throw new JniRewriteException ("The assembly declares embedded resources but has no resources directory."); + } + + PEMemoryBlock block = peReader.GetSectionData (directory.RelativeVirtualAddress); + if (block.Length < directory.Size) { + throw new JniRewriteException ("The resources directory extends past the end of its PE section."); + } + + int offset = checked ((int) resource.Offset); + // Use long arithmetic throughout: offset and size both come from the file (an + // attacker- or corruption-controlled int32), and offset + sizeof(int) + size can + // overflow a 32-bit sum and wrap around to a small or negative value, which would + // defeat the bounds check below instead of catching it. + long headerEnd = (long) offset + sizeof (int); + if (offset < 0 || headerEnd > directory.Size) { + throw new JniRewriteException ($"Embedded resource '{reader.GetString (resource.Name)}' starts outside of the resources directory."); + } + + int size = block.GetReader (offset, sizeof (int)).ReadInt32 (); + if (size < 0 || headerEnd + (long) size > directory.Size) { + throw new JniRewriteException ($"Embedded resource '{reader.GetString (resource.Name)}' extends past the end of the resources directory."); + } + + managedResources.Align (ManagedPEBuilder.ManagedResourcesDataAlignment); + resourceOffsets [MetadataTokens.ManifestResourceHandle (rid)] = managedResources.Count; + managedResources.WriteInt32 (size); + managedResources.WriteBytes (block.GetReader (offset + sizeof (int), size).ReadBytes (size)); + } + } + + void CloneTables () + { + CloneModule (); + CloneTypeReferences (); + CloneTypeDefinitions (); + CloneFields (); + CloneMethods (); + CloneParameters (); + CloneInterfaceImplementations (); + CloneMemberReferences (); + CloneConstants (); + CloneCustomAttributes (); + CloneFieldMarshals (); + CloneDeclarativeSecurity (); + CloneClassLayouts (); + CloneFieldLayouts (); + CloneStandaloneSignatures (); + CloneEventsAndProperties (); + CloneMethodImplementations (); + CloneModuleReferences (); + CloneTypeSpecifications (); + CloneImplMaps (); + CloneFieldRvas (); + CloneAssembly (); + CloneAssemblyReferences (); + CloneFiles (); + CloneExportedTypes (); + CloneManifestResources (); + CloneNestedClasses (); + CloneGenericParameters (); + CloneMethodSpecifications (); + CloneGenericParameterConstraints (); + } + + void CloneModule () + { + ModuleDefinition module = reader.GetModuleDefinition (); + metadata.AddModule ( + module.Generation, + CloneString (module.Name), + CloneGuid (module.Mvid), + CloneGuid (module.GenerationId), + CloneGuid (module.BaseGenerationId)); + } + + void CloneTypeReferences () + { + int count = reader.GetTableRowCount (TableIndex.TypeRef); + for (int rid = 1; rid <= count; rid++) { + TypeReference typeRef = reader.GetTypeReference (MetadataTokens.TypeReferenceHandle (rid)); + metadata.AddTypeReference (typeRef.ResolutionScope, CloneString (typeRef.Namespace), CloneString (typeRef.Name)); + } + } + + void CloneTypeDefinitions () + { + int count = sourceTypeDefCount; + var fieldLists = new FieldDefinitionHandle [count + 1]; + var methodLists = new MethodDefinitionHandle [count + 1]; + + var nextField = MetadataTokens.FieldDefinitionHandle (reader.GetTableRowCount (TableIndex.Field) + 1); + var nextMethod = MetadataTokens.MethodDefinitionHandle (reader.GetTableRowCount (TableIndex.MethodDef) + 1); + + // An empty member list is encoded as "the next type's first member", so the lists have + // to be resolved from the back. + for (int rid = count; rid >= 1; rid--) { + TypeDefinition typeDef = reader.GetTypeDefinition (MetadataTokens.TypeDefinitionHandle (rid)); + + foreach (FieldDefinitionHandle field in typeDef.GetFields ()) { + nextField = field; + break; + } + foreach (MethodDefinitionHandle method in typeDef.GetMethods ()) { + nextMethod = method; + break; + } + + fieldLists [rid] = nextField; + methodLists [rid] = nextMethod; + } + + for (int rid = 1; rid <= count; rid++) { + TypeDefinition typeDef = reader.GetTypeDefinition (MetadataTokens.TypeDefinitionHandle (rid)); + metadata.AddTypeDefinition ( + typeDef.Attributes, + CloneString (typeDef.Namespace), + CloneString (typeDef.Name), + typeDef.BaseType, + fieldLists [rid], + methodLists [rid]); + } + } + + void CloneFields () + { + int count = reader.GetTableRowCount (TableIndex.Field); + for (int rid = 1; rid <= count; rid++) { + var handle = MetadataTokens.FieldDefinitionHandle (rid); + FieldDefinition field = reader.GetFieldDefinition (handle); + metadata.AddFieldDefinition (field.Attributes, CloneString (field.Name), CloneFieldSignature (handle, field)); + } + } + + BlobHandle CloneFieldSignature (FieldDefinitionHandle handle, FieldDefinition field) + { + if (!resizedFieldTypes.TryGetValue (handle, out TypeDefinitionHandle sizedType)) { + return CloneBlob (field.Signature); + } + + var blob = new BlobBuilder (); + blob.WriteByte ((byte) SignatureKind.Field); + blob.WriteByte ((byte) SignatureTypeKind.ValueType); + blob.WriteCompressedInteger (CodedIndex.TypeDefOrRefOrSpec (sizedType)); + return metadata.GetOrAddBlob (blob); + } + + void CloneMethods () + { + int count = reader.GetTableRowCount (TableIndex.MethodDef); + var parameterLists = new ParameterHandle [count + 1]; + var nextParameter = MetadataTokens.ParameterHandle (reader.GetTableRowCount (TableIndex.Param) + 1); + + for (int rid = count; rid >= 1; rid--) { + MethodDefinition method = reader.GetMethodDefinition (MetadataTokens.MethodDefinitionHandle (rid)); + foreach (ParameterHandle parameter in method.GetParameters ()) { + nextParameter = parameter; + break; + } + parameterLists [rid] = nextParameter; + } + + for (int rid = 1; rid <= count; rid++) { + var handle = MetadataTokens.MethodDefinitionHandle (rid); + MethodDefinition method = reader.GetMethodDefinition (handle); + metadata.AddMethodDefinition ( + method.Attributes, + method.ImplAttributes, + CloneString (method.Name), + CloneBlob (method.Signature), + bodyOffsets [handle], + parameterLists [rid]); + } + } + + void CloneParameters () + { + int count = reader.GetTableRowCount (TableIndex.Param); + for (int rid = 1; rid <= count; rid++) { + Parameter parameter = reader.GetParameter (MetadataTokens.ParameterHandle (rid)); + metadata.AddParameter (parameter.Attributes, CloneString (parameter.Name), parameter.SequenceNumber); + } + } + + void CloneInterfaceImplementations () + { + // The table is sorted by its (implicit) Class column, so walking the types in row + // order reproduces the original row order exactly. + foreach (TypeDefinitionHandle typeHandle in reader.TypeDefinitions) { + TypeDefinition typeDef = reader.GetTypeDefinition (typeHandle); + foreach (InterfaceImplementationHandle implHandle in typeDef.GetInterfaceImplementations ()) { + InterfaceImplementation impl = reader.GetInterfaceImplementation (implHandle); + metadata.AddInterfaceImplementation (typeHandle, impl.Interface); + } + } + } + + void CloneMemberReferences () + { + int count = reader.GetTableRowCount (TableIndex.MemberRef); + for (int rid = 1; rid <= count; rid++) { + MemberReference memberRef = reader.GetMemberReference (MetadataTokens.MemberReferenceHandle (rid)); + metadata.AddMemberReference (memberRef.Parent, CloneString (memberRef.Name), CloneBlob (memberRef.Signature)); + } + } + + void CloneConstants () + { + int count = reader.GetTableRowCount (TableIndex.Constant); + for (int rid = 1; rid <= count; rid++) { + Constant constant = reader.GetConstant (MetadataTokens.ConstantHandle (rid)); + BlobReader value = reader.GetBlobReader (constant.Value); + metadata.AddConstant (constant.Parent, value.ReadConstant (constant.TypeCode)); + } + } + + void CloneCustomAttributes () + { + int count = reader.GetTableRowCount (TableIndex.CustomAttribute); + for (int rid = 1; rid <= count; rid++) { + var handle = MetadataTokens.CustomAttributeHandle (rid); + CustomAttribute attribute = reader.GetCustomAttribute (handle); + byte []? replacement = plan.GetCustomAttributeBlob (handle); + BlobHandle value = replacement != null ? metadata.GetOrAddBlob (replacement) : CloneBlob (attribute.Value); + metadata.AddCustomAttribute (attribute.Parent, attribute.Constructor, value); + } + } + + void CloneFieldMarshals () + { + foreach (FieldDefinitionHandle handle in reader.FieldDefinitions) { + BlobHandle descriptor = reader.GetFieldDefinition (handle).GetMarshallingDescriptor (); + if (!descriptor.IsNil) { + metadata.AddMarshallingDescriptor (handle, CloneBlob (descriptor)); + } + } + + int parameterCount = reader.GetTableRowCount (TableIndex.Param); + for (int rid = 1; rid <= parameterCount; rid++) { + var handle = MetadataTokens.ParameterHandle (rid); + BlobHandle descriptor = reader.GetParameter (handle).GetMarshallingDescriptor (); + if (!descriptor.IsNil) { + metadata.AddMarshallingDescriptor (handle, CloneBlob (descriptor)); + } + } + } + + void CloneDeclarativeSecurity () + { + int count = reader.GetTableRowCount (TableIndex.DeclSecurity); + for (int rid = 1; rid <= count; rid++) { + DeclarativeSecurityAttribute security = reader.GetDeclarativeSecurityAttribute (MetadataTokens.DeclarativeSecurityAttributeHandle (rid)); + metadata.AddDeclarativeSecurityAttribute (security.Parent, security.Action, CloneBlob (security.PermissionSet)); + } + } + + void CloneClassLayouts () + { + foreach (TypeDefinitionHandle handle in reader.TypeDefinitions) { + TypeLayout layout = reader.GetTypeDefinition (handle).GetLayout (); + if (!layout.IsDefault) { + metadata.AddTypeLayout (handle, checked ((ushort) layout.PackingSize), checked ((uint) layout.Size)); + } + } + } + + void CloneFieldLayouts () + { + foreach (FieldDefinitionHandle handle in reader.FieldDefinitions) { + int offset = reader.GetFieldDefinition (handle).GetOffset (); + if (offset >= 0) { + metadata.AddFieldLayout (handle, offset); + } + } + } + + void CloneStandaloneSignatures () + { + int count = reader.GetTableRowCount (TableIndex.StandAloneSig); + for (int rid = 1; rid <= count; rid++) { + StandaloneSignature signature = reader.GetStandaloneSignature (MetadataTokens.StandaloneSignatureHandle (rid)); + metadata.AddStandaloneSignature (CloneBlob (signature.Signature)); + } + } + + void CloneEventsAndProperties () + { + int eventCount = reader.GetTableRowCount (TableIndex.Event); + for (int rid = 1; rid <= eventCount; rid++) { + EventDefinition eventDef = reader.GetEventDefinition (MetadataTokens.EventDefinitionHandle (rid)); + metadata.AddEvent (eventDef.Attributes, CloneString (eventDef.Name), eventDef.Type); + } + + int propertyCount = reader.GetTableRowCount (TableIndex.Property); + for (int rid = 1; rid <= propertyCount; rid++) { + PropertyDefinition property = reader.GetPropertyDefinition (MetadataTokens.PropertyDefinitionHandle (rid)); + metadata.AddProperty (property.Attributes, CloneString (property.Name), CloneBlob (property.Signature)); + } + + foreach (TypeDefinitionHandle typeHandle in reader.TypeDefinitions) { + TypeDefinition typeDef = reader.GetTypeDefinition (typeHandle); + + foreach (EventDefinitionHandle eventHandle in typeDef.GetEvents ()) { + metadata.AddEventMap (typeHandle, eventHandle); + break; + } + foreach (PropertyDefinitionHandle propertyHandle in typeDef.GetProperties ()) { + metadata.AddPropertyMap (typeHandle, propertyHandle); + break; + } + } + + for (int rid = 1; rid <= eventCount; rid++) { + var handle = MetadataTokens.EventDefinitionHandle (rid); + EventAccessors accessors = reader.GetEventDefinition (handle).GetAccessors (); + AddSemantics (handle, MethodSemanticsAttributes.Adder, accessors.Adder); + AddSemantics (handle, MethodSemanticsAttributes.Remover, accessors.Remover); + AddSemantics (handle, MethodSemanticsAttributes.Raiser, accessors.Raiser); + foreach (MethodDefinitionHandle other in accessors.Others) { + AddSemantics (handle, MethodSemanticsAttributes.Other, other); + } + } + + for (int rid = 1; rid <= propertyCount; rid++) { + var handle = MetadataTokens.PropertyDefinitionHandle (rid); + PropertyAccessors accessors = reader.GetPropertyDefinition (handle).GetAccessors (); + AddSemantics (handle, MethodSemanticsAttributes.Getter, accessors.Getter); + AddSemantics (handle, MethodSemanticsAttributes.Setter, accessors.Setter); + foreach (MethodDefinitionHandle other in accessors.Others) { + AddSemantics (handle, MethodSemanticsAttributes.Other, other); + } + } + } + + void AddSemantics (EntityHandle association, MethodSemanticsAttributes semantics, MethodDefinitionHandle method) + { + if (!method.IsNil) { + metadata.AddMethodSemantics (association, semantics, method); + } + } + + void CloneMethodImplementations () + { + int count = reader.GetTableRowCount (TableIndex.MethodImpl); + for (int rid = 1; rid <= count; rid++) { + MethodImplementation impl = reader.GetMethodImplementation (MetadataTokens.MethodImplementationHandle (rid)); + metadata.AddMethodImplementation ((TypeDefinitionHandle) impl.Type, impl.MethodBody, impl.MethodDeclaration); + } + } + + void CloneModuleReferences () + { + int count = reader.GetTableRowCount (TableIndex.ModuleRef); + for (int rid = 1; rid <= count; rid++) { + ModuleReference moduleRef = reader.GetModuleReference (MetadataTokens.ModuleReferenceHandle (rid)); + metadata.AddModuleReference (CloneString (moduleRef.Name)); + } + } + + void CloneTypeSpecifications () + { + int count = reader.GetTableRowCount (TableIndex.TypeSpec); + for (int rid = 1; rid <= count; rid++) { + TypeSpecification typeSpec = reader.GetTypeSpecification (MetadataTokens.TypeSpecificationHandle (rid)); + metadata.AddTypeSpecification (CloneBlob (typeSpec.Signature)); + } + } + + void CloneImplMaps () + { + int count = reader.GetTableRowCount (TableIndex.MethodDef); + for (int rid = 1; rid <= count; rid++) { + var handle = MetadataTokens.MethodDefinitionHandle (rid); + MethodImport import = reader.GetMethodDefinition (handle).GetImport (); + if (!import.Module.IsNil) { + metadata.AddMethodImport (handle, import.Attributes, CloneString (import.Name), import.Module); + } + } + } + + void CloneFieldRvas () + { + foreach (FieldRvaEntry entry in fieldRvaTable.Entries) { + if (!newFieldRvaOffsets.TryGetValue (entry.Field, out int offset)) { + throw new JniRewriteException ("Internal error: a FieldRVA row was not laid out."); + } + metadata.AddFieldRelativeVirtualAddress (entry.Field, offset); + } + } + + void CloneAssembly () + { + if (!reader.IsAssembly) { + return; + } + + AssemblyDefinition assembly = reader.GetAssemblyDefinition (); + metadata.AddAssembly ( + CloneString (assembly.Name), + assembly.Version, + CloneString (assembly.Culture), + CloneBlob (assembly.PublicKey), + assembly.Flags, + assembly.HashAlgorithm); + } + + void CloneAssemblyReferences () + { + int count = reader.GetTableRowCount (TableIndex.AssemblyRef); + for (int rid = 1; rid <= count; rid++) { + AssemblyReference assemblyRef = reader.GetAssemblyReference (MetadataTokens.AssemblyReferenceHandle (rid)); + metadata.AddAssemblyReference ( + CloneString (assemblyRef.Name), + assemblyRef.Version, + CloneString (assemblyRef.Culture), + CloneBlob (assemblyRef.PublicKeyOrToken), + assemblyRef.Flags, + CloneBlob (assemblyRef.HashValue)); + } + } + + void CloneFiles () + { + int count = reader.GetTableRowCount (TableIndex.File); + for (int rid = 1; rid <= count; rid++) { + AssemblyFile file = reader.GetAssemblyFile (MetadataTokens.AssemblyFileHandle (rid)); + metadata.AddAssemblyFile (CloneString (file.Name), CloneBlob (file.HashValue), file.ContainsMetadata); + } + } + + void CloneExportedTypes () + { + int count = reader.GetTableRowCount (TableIndex.ExportedType); + for (int rid = 1; rid <= count; rid++) { + ExportedType exported = reader.GetExportedType (MetadataTokens.ExportedTypeHandle (rid)); + metadata.AddExportedType ( + exported.Attributes, + CloneString (exported.Namespace), + CloneString (exported.Name), + exported.Implementation, + MetadataRawColumns.GetExportedTypeDefinitionId (reader, rid)); + } + } + + void CloneManifestResources () + { + int count = reader.GetTableRowCount (TableIndex.ManifestResource); + for (int rid = 1; rid <= count; rid++) { + var handle = MetadataTokens.ManifestResourceHandle (rid); + ManifestResource resource = reader.GetManifestResource (handle); + uint offset = resourceOffsets.TryGetValue (handle, out int newOffset) + ? (uint) newOffset + : checked ((uint) resource.Offset); + metadata.AddManifestResource (resource.Attributes, CloneString (resource.Name), resource.Implementation, offset); + } + } + + void CloneNestedClasses () + { + foreach (TypeDefinitionHandle handle in reader.TypeDefinitions) { + TypeDefinitionHandle enclosing = reader.GetTypeDefinition (handle).GetDeclaringType (); + if (!enclosing.IsNil) { + metadata.AddNestedType (handle, enclosing); + } + } + } + + void CloneGenericParameters () + { + int count = reader.GetTableRowCount (TableIndex.GenericParam); + for (int rid = 1; rid <= count; rid++) { + GenericParameter parameter = reader.GetGenericParameter (MetadataTokens.GenericParameterHandle (rid)); + metadata.AddGenericParameter (parameter.Parent, parameter.Attributes, CloneString (parameter.Name), parameter.Index); + } + } + + void CloneGenericParameterConstraints () + { + int count = reader.GetTableRowCount (TableIndex.GenericParamConstraint); + for (int rid = 1; rid <= count; rid++) { + GenericParameterConstraint constraint = reader.GetGenericParameterConstraint (MetadataTokens.GenericParameterConstraintHandle (rid)); + metadata.AddGenericParameterConstraint (constraint.Parameter, constraint.Type); + } + } + + void CloneMethodSpecifications () + { + int count = reader.GetTableRowCount (TableIndex.MethodSpec); + for (int rid = 1; rid <= count; rid++) { + MethodSpecification methodSpec = reader.GetMethodSpecification (MetadataTokens.MethodSpecificationHandle (rid)); + metadata.AddMethodSpecification (methodSpec.Method, CloneBlob (methodSpec.Signature)); + } + } + + StringHandle CloneString (StringHandle handle) => handle.IsNil ? default : metadata.GetOrAddString (reader.GetString (handle)); + + BlobHandle CloneBlob (BlobHandle handle) => handle.IsNil ? default : metadata.GetOrAddBlob (reader.GetBlobBytes (handle)); + + GuidHandle CloneGuid (GuidHandle handle) => handle.IsNil ? default : metadata.GetOrAddGuid (reader.GetGuid (handle)); + + CorHeader GetCorHeader () + { + CorHeader? corHeader = peReader.PEHeaders.CorHeader; + if (corHeader == null) { + throw new JniRewriteException ("The file has no CLI header and is not a managed assembly."); + } + return corHeader; + } + + AssemblyRebuildResult Serialize () + { + CorHeader corHeader = GetCorHeader (); + CoffHeader coffHeader = peReader.PEHeaders.CoffHeader; + PEHeader? peHeader = peReader.PEHeaders.PEHeader; + if (peHeader == null) { + throw new JniRewriteException ("The file has no PE optional header and cannot be rebuilt."); + } + + bool wasStrongNameSigned = (corHeader.Flags & CorFlags.StrongNameSigned) != 0; + CorFlags flags = wasStrongNameSigned ? corHeader.Flags & ~CorFlags.StrongNameSigned : corHeader.Flags; + + // Reserve the original signature directory's size even though the flag is cleared: + // the rebuilt image is delay-signed (the space for a signature is preserved) rather + // than left with no room to re-sign it later. + int strongNameSignatureSize = corHeader.StrongNameSignatureDirectory.Size; + + var headerBuilder = new PEHeaderBuilder ( + machine: coffHeader.Machine, + sectionAlignment: peHeader.SectionAlignment, + fileAlignment: peHeader.FileAlignment, + imageBase: peHeader.ImageBase, + majorLinkerVersion: peHeader.MajorLinkerVersion, + minorLinkerVersion: peHeader.MinorLinkerVersion, + majorOperatingSystemVersion: peHeader.MajorOperatingSystemVersion, + minorOperatingSystemVersion: peHeader.MinorOperatingSystemVersion, + majorImageVersion: peHeader.MajorImageVersion, + minorImageVersion: peHeader.MinorImageVersion, + majorSubsystemVersion: peHeader.MajorSubsystemVersion, + minorSubsystemVersion: peHeader.MinorSubsystemVersion, + subsystem: peHeader.Subsystem, + dllCharacteristics: peHeader.DllCharacteristics, + imageCharacteristics: coffHeader.Characteristics, + sizeOfStackReserve: peHeader.SizeOfStackReserve, + sizeOfStackCommit: peHeader.SizeOfStackCommit, + sizeOfHeapReserve: peHeader.SizeOfHeapReserve, + sizeOfHeapCommit: peHeader.SizeOfHeapCommit); + + int entryPointToken = corHeader.EntryPointTokenOrRelativeVirtualAddress; + MethodDefinitionHandle entryPoint = entryPointToken == 0 + ? default + : MetadataTokens.MethodDefinitionHandle (entryPointToken & 0x00FFFFFF); + + Guid contentIdGuid = GetModuleVersionId (); + uint timeDateStamp = unchecked ((uint) coffHeader.TimeDateStamp); + + var peBuilder = new ManagedPEBuilder ( + headerBuilder, + new MetadataRootBuilder (metadata, reader.MetadataVersion), + ilStream, + mappedFieldData: mappedFieldData.Count > 0 ? mappedFieldData : null, + managedResources: managedResources.Count > 0 ? managedResources : null, + nativeResources: NativeResourceSectionCopier.TryCreate (peReader), + debugDirectoryBuilder: CloneDebugDirectory (), + strongNameSignatureSize: strongNameSignatureSize, + entryPoint: entryPoint, + flags: flags, + deterministicIdProvider: _ => new BlobContentId (contentIdGuid, timeDateStamp)); + + var peBlob = new BlobBuilder (); + try { + peBuilder.Serialize (peBlob); + } catch (InvalidOperationException e) { + throw new JniRewriteException ($"The rebuilt metadata was rejected during serialization: {e.Message}", e); + } catch (ArgumentException e) { + throw new JniRewriteException ($"The rebuilt metadata was rejected during serialization: {e.Message}", e); + } + + using var stream = new MemoryStream (); + peBlob.WriteContentTo (stream); + return new AssemblyRebuildResult (stream.ToArray (), wasStrongNameSigned); + } + + Guid GetModuleVersionId () + { + GuidHandle mvid = reader.GetModuleDefinition ().Mvid; + return mvid.IsNil ? Guid.Empty : reader.GetGuid (mvid); + } + + /// + /// Reproduces the source debug directory so an existing portable PDB keeps matching: + /// method tokens and IL offsets are preserved by construction, and the CodeView identity + /// (GUID, age, path) is copied verbatim. + /// + DebugDirectoryBuilder CloneDebugDirectory () + { + var builder = new DebugDirectoryBuilder (); + + foreach (DebugDirectoryEntry entry in peReader.ReadDebugDirectory ()) { + switch (entry.Type) { + case DebugDirectoryEntryType.CodeView: { + CodeViewDebugDirectoryData data = peReader.ReadCodeViewDebugDirectoryData (entry); + ushort portablePdbVersion = entry.IsPortableCodeView ? entry.MajorVersion : (ushort) 0; + builder.AddCodeViewEntry (data.Path, new BlobContentId (data.Guid, entry.Stamp), portablePdbVersion, data.Age); + break; + } + case DebugDirectoryEntryType.PdbChecksum: { + PdbChecksumDebugDirectoryData data = peReader.ReadPdbChecksumDebugDirectoryData (entry); + builder.AddPdbChecksumEntry (data.AlgorithmName, data.Checksum); + break; + } + case DebugDirectoryEntryType.Reproducible: + builder.AddReproducibleEntry (); + break; + default: + AddRawDebugDirectoryEntry (builder, entry); + break; + } + } + + return builder; + } + + void AddRawDebugDirectoryEntry (DebugDirectoryBuilder builder, DebugDirectoryEntry entry) + { + uint version = (uint) entry.MajorVersion | ((uint) entry.MinorVersion << 16); + if (entry.DataSize == 0) { + builder.AddEntry (entry.Type, version, entry.Stamp); + return; + } + + PEMemoryBlock block = peReader.GetSectionData (entry.DataRelativeVirtualAddress); + if (block.Length < entry.DataSize) { + throw new JniRewriteException ($"Debug directory entry '{entry.Type}' points at data outside of any PE section."); + } + + byte [] data = block.GetReader (0, entry.DataSize).ReadBytes (entry.DataSize); + builder.AddEntry (entry.Type, version, entry.Stamp, data, static (blob, bytes) => blob.WriteBytes (bytes)); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs new file mode 100644 index 00000000000..4d9ae49f628 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs @@ -0,0 +1,78 @@ +#nullable enable + +using System; +using System.IO; +using System.Text; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// Rewrites specific fixed string arguments within a CustomAttribute value blob + /// (ECMA-335 II.23.3), leaving the prolog, any other fixed arguments, and every named + /// argument byte-for-byte untouched. + /// + /// This only supports (and only needs to support) the attributes this task rewrites - + /// Android.Runtime.RegisterAttribute, Java.Interop.JniTypeSignatureAttribute, + /// Java.Interop.JniMethodSignatureAttribute, and Java.Interop.JniConstructorSignatureAttribute + /// - whose constructors take only string fixed arguments. + /// + static class CustomAttributeStringRewriter + { + /// + /// Rewrites the fixed string arguments of a CustomAttribute value blob whose constructor + /// takes exactly string arguments. + /// is invoked with the (0-based) argument index and its original value, and should return + /// the replacement value, or null if that argument should be left unchanged. + /// + /// Returns null if no argument was rewritten (i.e. the blob does not need to change). + /// + public static byte []? TryRewrite (byte [] originalContent, int fixedArgCount, Func rewriteArg) + { + if (originalContent.Length < 2) { + throw new JniRewriteException ("Malformed custom attribute value blob: missing 2-byte prolog."); + } + + using var ms = new MemoryStream (originalContent.Length); + ms.Write (originalContent, 0, 2); // Prolog (0x0001), verbatim. + int pos = 2; + bool changed = false; + + for (int i = 0; i < fixedArgCount; i++) { + if (pos >= originalContent.Length) { + throw new JniRewriteException ("Malformed custom attribute value blob: ran out of bytes while reading fixed arguments."); + } + + int argStart = pos; + string? value; + if (originalContent [pos] == 0xFF) { + // A "null string" SerString is encoded as a single 0xFF byte (ECMA-335 II.23.3). + value = null; + pos += 1; + } else { + int prefixWidth = MetadataEncoding.ReadCompressedInteger (originalContent, pos, out int strByteLength); + pos += prefixWidth + strByteLength; + if (pos > originalContent.Length) { + throw new JniRewriteException ("Malformed custom attribute value blob: fixed string argument extends past the end of the blob."); + } + value = Encoding.UTF8.GetString (originalContent, argStart + prefixWidth, strByteLength); + } + + string? newValue = value != null ? rewriteArg (i, value) : null; + if (newValue != null && !string.Equals (newValue, value, StringComparison.Ordinal)) { + changed = true; + byte [] utf8 = Encoding.UTF8.GetBytes (newValue); + byte [] prefix = MetadataEncoding.EncodeCompressedInteger (utf8.Length); + ms.Write (prefix, 0, prefix.Length); + ms.Write (utf8, 0, utf8.Length); + } else { + ms.Write (originalContent, argStart, pos - argStart); + } + } + + // Tail: NumNamed (uint16, not compressed) + NamedArg*, copied verbatim. + ms.Write (originalContent, pos, originalContent.Length - pos); + + return changed ? ms.ToArray () : null; + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/FieldRvaTable.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/FieldRvaTable.cs new file mode 100644 index 00000000000..b11449fd82b --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/FieldRvaTable.cs @@ -0,0 +1,221 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using System.Text; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// A single FieldRVA row together with the size and contents of the mapped data it + /// points at, plus - when the field is structurally one of the trimmable typemap generator's + /// null-terminated UTF-8 JNI data fields - its decoded string value. + /// + sealed class FieldRvaEntry + { + public FieldDefinitionHandle Field { get; } + public int RelativeVirtualAddress { get; } + public byte [] Data { get; } + + /// + /// Non-nil when the field's type is a <PrivateImplementationDetails>/__utf8_N + /// explicit-layout value type, i.e. the field holds a null-terminated UTF-8 JNI name or + /// signature emitted by Microsoft.Android.Sdk.TrimmableTypeMap. + /// + public TypeDefinitionHandle Utf8SizedType { get; } + + public string? Utf8Value { get; } + + public bool IsUtf8Datum => Utf8Value != null; + + public FieldRvaEntry (FieldDefinitionHandle field, int rva, byte [] data, TypeDefinitionHandle utf8SizedType, string? utf8Value) + { + Field = field; + RelativeVirtualAddress = rva; + Data = data; + Utf8SizedType = utf8SizedType; + Utf8Value = utf8Value; + } + } + + /// + /// Reads every FieldRVA row of an assembly, resolving the byte length of each mapped + /// data block from the field's signature so the block can be copied - or replaced - when the + /// assembly is rebuilt. + /// + sealed class FieldRvaTable + { + public const string PrivateImplementationDetailsTypeName = ""; + public const string Utf8FieldNamePrefix = "__utf8_"; + + readonly List entries = new (); + readonly Dictionary byField = new (); + + public IReadOnlyList Entries => entries; + + /// + /// Returns the mapped-data entry for , or null when the field has + /// no FieldRVA row. + /// + public FieldRvaEntry? Get (FieldDefinitionHandle field) => byField.TryGetValue (field, out FieldRvaEntry? entry) ? entry : null; + + /// + /// Reads every mapped field, in Field row order - which is the order the + /// FieldRVA table is required to be sorted in. + /// + public static FieldRvaTable Read (PEReader peReader, MetadataReader reader) + { + var table = new FieldRvaTable (); + + foreach (FieldDefinitionHandle field in reader.FieldDefinitions) { + FieldDefinition fieldDef = reader.GetFieldDefinition (field); + int rva = fieldDef.GetRelativeVirtualAddress (); + if (rva == 0) { + continue; + } + + int size = ComputeFieldDataSize (reader, fieldDef, out TypeDefinitionHandle valueType); + byte [] data = ReadMappedData (peReader, rva, size, reader.GetString (fieldDef.Name)); + + string? utf8Value = TryDecodeUtf8Datum (reader, fieldDef, valueType, data); + var entry = new FieldRvaEntry (field, rva, data, utf8Value != null ? valueType : default, utf8Value); + table.entries.Add (entry); + table.byField [field] = entry; + } + + if (table.entries.Count != reader.GetTableRowCount (TableIndex.FieldRva)) { + throw new JniRewriteException ("The FieldRVA table has rows whose fields do not declare an RVA; the assembly is malformed."); + } + + return table; + } + + static byte [] ReadMappedData (PEReader peReader, int rva, int size, string fieldName) + { + PEMemoryBlock block = peReader.GetSectionData (rva); + if (block.Length < size) { + throw new JniRewriteException ($"FieldRVA data for field '{fieldName}' (RVA 0x{rva:X}, {size} byte(s)) extends past the end of its PE section."); + } + return block.GetReader (0, size).ReadBytes (size); + } + + static string? TryDecodeUtf8Datum (MetadataReader reader, FieldDefinition fieldDef, TypeDefinitionHandle valueType, byte [] data) + { + const FieldAttributes required = FieldAttributes.Static | FieldAttributes.HasFieldRVA; + if ((fieldDef.Attributes & required) != required) { + return null; + } + if (valueType.IsNil || !reader.GetString (fieldDef.Name).StartsWith (Utf8FieldNamePrefix, StringComparison.Ordinal)) { + return null; + } + + // The datum's type must be the generator's `/__utf8_N` + // explicit-layout value type whose size is exactly the field's byte count. + TypeDefinition sized = reader.GetTypeDefinition (valueType); + if ((sized.Attributes & TypeAttributes.ExplicitLayout) == 0) { + return null; + } + if (reader.GetString (sized.Name) != Utf8FieldNamePrefix + data.Length.ToString (System.Globalization.CultureInfo.InvariantCulture)) { + return null; + } + + TypeDefinitionHandle enclosing = sized.GetDeclaringType (); + if (enclosing.IsNil || reader.GetString (reader.GetTypeDefinition (enclosing).Name) != PrivateImplementationDetailsTypeName) { + return null; + } + + // Exactly one NUL, at the very end: a C string that fully occupies the field. + if (data.Length == 0 || data [data.Length - 1] != 0) { + return null; + } + for (int i = 0; i < data.Length - 1; i++) { + if (data [i] == 0) { + return null; + } + } + + try { + return new UTF8Encoding (encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) + .GetString (data, 0, data.Length - 1); + } catch (ArgumentException) { + return null; + } + } + + /// + /// Computes the number of bytes a mapped field occupies, from its field signature. + /// + static int ComputeFieldDataSize (MetadataReader reader, FieldDefinition fieldDef, out TypeDefinitionHandle valueType) + { + valueType = default; + + BlobReader blob = reader.GetBlobReader (fieldDef.Signature); + SignatureHeader header = blob.ReadSignatureHeader (); + if (header.Kind != SignatureKind.Field) { + throw new JniRewriteException ($"Field '{reader.GetString (fieldDef.Name)}' has a FieldRVA row but its signature is not a field signature."); + } + + int code = ReadTypeCodeSkippingModifiers (ref blob); + if (code == (int) SignatureTypeKind.ValueType) { + EntityHandle handle = blob.ReadTypeHandle (); + if (handle.Kind != HandleKind.TypeDefinition) { + throw new JniRewriteException ($"Field '{reader.GetString (fieldDef.Name)}' maps data whose value type lives in another assembly; its size cannot be determined."); + } + valueType = (TypeDefinitionHandle) handle; + return ComputeTypeSize (reader, valueType, reader.GetString (fieldDef.Name)); + } + + int primitiveSize = GetPrimitiveSize (code); + if (primitiveSize > 0) { + return primitiveSize; + } + + throw new JniRewriteException ($"Field '{reader.GetString (fieldDef.Name)}' maps data of an unsupported type (signature element type 0x{code:X2})."); + } + + static int ReadTypeCodeSkippingModifiers (ref BlobReader blob) + { + const int CModReqd = 0x1F; + const int CModOpt = 0x20; + + int code = blob.ReadCompressedInteger (); + while (code == CModReqd || code == CModOpt) { + blob.ReadTypeHandle (); + code = blob.ReadCompressedInteger (); + } + return code; + } + + static int GetPrimitiveSize (int code) + => code switch { + (int) SignatureTypeCode.Boolean or (int) SignatureTypeCode.SByte or (int) SignatureTypeCode.Byte => 1, + (int) SignatureTypeCode.Char or (int) SignatureTypeCode.Int16 or (int) SignatureTypeCode.UInt16 => 2, + (int) SignatureTypeCode.Int32 or (int) SignatureTypeCode.UInt32 or (int) SignatureTypeCode.Single => 4, + (int) SignatureTypeCode.Int64 or (int) SignatureTypeCode.UInt64 or (int) SignatureTypeCode.Double => 8, + _ => 0, + }; + + /// + /// Determines the byte size of a value type used as FieldRVA-mapped data, from its + /// explicit ClassLayout row alone. Summing up instance field sizes is deliberately + /// not attempted as a fallback: without an explicit layout the CLR is free to reorder, + /// pad, or otherwise size the type differently than a naive sum would predict, and + /// silently under- or over-sizing the mapped data block risks reading truncated or + /// out-of-bounds bytes. A type with no explicit layout size is therefore a hard error. + /// + static int ComputeTypeSize (MetadataReader reader, TypeDefinitionHandle handle, string fieldName) + { + TypeDefinition type = reader.GetTypeDefinition (handle); + TypeLayout layout = type.GetLayout (); + if (layout.IsDefault || layout.Size <= 0) { + throw new JniRewriteException ($"Field '{fieldName}' maps data whose value type has no explicit ClassLayout size; its size cannot be determined safely."); + } + + return layout.Size; + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/IlOpcodeTable.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/IlOpcodeTable.cs new file mode 100644 index 00000000000..5cca7732788 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/IlOpcodeTable.cs @@ -0,0 +1,101 @@ +#nullable enable + +using System.Collections.Generic; +using System.Reflection.Metadata; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// A minimal ECMA-335 (III.3-4) IL instruction operand-size table, used to walk a method + /// body's IL bytes (looking for ldstr instructions) without any IL-parsing library. + /// Every legal opcode has a fixed operand size, except switch whose operand length + /// depends on the embedded case count, which is handled specially by the caller. + /// + static class IlOpcodeTable + { + public static readonly Dictionary OperandSizes = BuildTable (); + + static Dictionary BuildTable () + { + var table = new Dictionary (); + + // No operand. + Add (table, 0, ILOpCode.Nop, ILOpCode.Break, + ILOpCode.Ldarg_0, ILOpCode.Ldarg_1, ILOpCode.Ldarg_2, ILOpCode.Ldarg_3, + ILOpCode.Ldloc_0, ILOpCode.Ldloc_1, ILOpCode.Ldloc_2, ILOpCode.Ldloc_3, + ILOpCode.Stloc_0, ILOpCode.Stloc_1, ILOpCode.Stloc_2, ILOpCode.Stloc_3, + ILOpCode.Ldnull, + ILOpCode.Ldc_i4_m1, ILOpCode.Ldc_i4_0, ILOpCode.Ldc_i4_1, ILOpCode.Ldc_i4_2, ILOpCode.Ldc_i4_3, + ILOpCode.Ldc_i4_4, ILOpCode.Ldc_i4_5, ILOpCode.Ldc_i4_6, ILOpCode.Ldc_i4_7, ILOpCode.Ldc_i4_8, + ILOpCode.Dup, ILOpCode.Pop, ILOpCode.Ret, + ILOpCode.Ldind_i1, ILOpCode.Ldind_u1, ILOpCode.Ldind_i2, ILOpCode.Ldind_u2, + ILOpCode.Ldind_i4, ILOpCode.Ldind_u4, ILOpCode.Ldind_i8, ILOpCode.Ldind_i, + ILOpCode.Ldind_r4, ILOpCode.Ldind_r8, ILOpCode.Ldind_ref, + ILOpCode.Stind_ref, ILOpCode.Stind_i1, ILOpCode.Stind_i2, ILOpCode.Stind_i4, + ILOpCode.Stind_i8, ILOpCode.Stind_r4, ILOpCode.Stind_r8, ILOpCode.Stind_i, + ILOpCode.Add, ILOpCode.Sub, ILOpCode.Mul, ILOpCode.Div, ILOpCode.Div_un, + ILOpCode.Rem, ILOpCode.Rem_un, ILOpCode.And, ILOpCode.Or, ILOpCode.Xor, + ILOpCode.Shl, ILOpCode.Shr, ILOpCode.Shr_un, ILOpCode.Neg, ILOpCode.Not, + ILOpCode.Conv_i1, ILOpCode.Conv_i2, ILOpCode.Conv_i4, ILOpCode.Conv_i8, + ILOpCode.Conv_r4, ILOpCode.Conv_r8, ILOpCode.Conv_u4, ILOpCode.Conv_u8, ILOpCode.Conv_r_un, + ILOpCode.Throw, + ILOpCode.Ldlen, + ILOpCode.Ldelem_i1, ILOpCode.Ldelem_u1, ILOpCode.Ldelem_i2, ILOpCode.Ldelem_u2, + ILOpCode.Ldelem_i4, ILOpCode.Ldelem_u4, ILOpCode.Ldelem_i8, ILOpCode.Ldelem_i, + ILOpCode.Ldelem_r4, ILOpCode.Ldelem_r8, ILOpCode.Ldelem_ref, + ILOpCode.Stelem_i, ILOpCode.Stelem_i1, ILOpCode.Stelem_i2, ILOpCode.Stelem_i4, + ILOpCode.Stelem_i8, ILOpCode.Stelem_r4, ILOpCode.Stelem_r8, ILOpCode.Stelem_ref, + ILOpCode.Conv_ovf_i1_un, ILOpCode.Conv_ovf_u1_un, ILOpCode.Conv_ovf_i2_un, ILOpCode.Conv_ovf_u2_un, + ILOpCode.Conv_ovf_i4_un, ILOpCode.Conv_ovf_u4_un, ILOpCode.Conv_ovf_i8_un, ILOpCode.Conv_ovf_u8_un, + ILOpCode.Conv_ovf_i_un, ILOpCode.Conv_ovf_u_un, + ILOpCode.Conv_ovf_i1, ILOpCode.Conv_ovf_u1, ILOpCode.Conv_ovf_i2, ILOpCode.Conv_ovf_u2, + ILOpCode.Conv_ovf_i4, ILOpCode.Conv_ovf_u4, ILOpCode.Conv_ovf_i8, ILOpCode.Conv_ovf_u8, + ILOpCode.Ckfinite, + ILOpCode.Conv_u2, ILOpCode.Conv_u1, ILOpCode.Conv_i, ILOpCode.Conv_ovf_i, ILOpCode.Conv_ovf_u, ILOpCode.Conv_u, + ILOpCode.Add_ovf, ILOpCode.Add_ovf_un, ILOpCode.Mul_ovf, ILOpCode.Mul_ovf_un, ILOpCode.Sub_ovf, ILOpCode.Sub_ovf_un, + ILOpCode.Endfinally, + ILOpCode.Arglist, ILOpCode.Ceq, ILOpCode.Cgt, ILOpCode.Cgt_un, ILOpCode.Clt, ILOpCode.Clt_un, + ILOpCode.Localloc, ILOpCode.Endfilter, + ILOpCode.Volatile, ILOpCode.Tail, ILOpCode.Cpblk, ILOpCode.Initblk, + ILOpCode.Rethrow, ILOpCode.Refanytype, ILOpCode.Readonly); + + // 1-byte operand. + Add (table, 1, ILOpCode.Starg_s, ILOpCode.Ldloc_s, ILOpCode.Ldloca_s, ILOpCode.Stloc_s, + ILOpCode.Ldarg_s, ILOpCode.Ldarga_s, ILOpCode.Ldc_i4_s, ILOpCode.Unaligned, + ILOpCode.Br_s, ILOpCode.Brfalse_s, ILOpCode.Brtrue_s, + ILOpCode.Beq_s, ILOpCode.Bge_s, ILOpCode.Bgt_s, ILOpCode.Ble_s, ILOpCode.Blt_s, + ILOpCode.Bne_un_s, ILOpCode.Bge_un_s, ILOpCode.Bgt_un_s, ILOpCode.Ble_un_s, ILOpCode.Blt_un_s, + ILOpCode.Leave_s); + + // 2-byte operand. + Add (table, 2, ILOpCode.Starg, ILOpCode.Ldloc, ILOpCode.Ldloca, ILOpCode.Stloc, ILOpCode.Ldarg, ILOpCode.Ldarga); + + // 4-byte operand. + Add (table, 4, ILOpCode.Ldc_i4, ILOpCode.Ldc_r4, + ILOpCode.Br, ILOpCode.Brfalse, ILOpCode.Brtrue, + ILOpCode.Beq, ILOpCode.Bge, ILOpCode.Bgt, ILOpCode.Ble, ILOpCode.Blt, + ILOpCode.Bne_un, ILOpCode.Bge_un, ILOpCode.Bgt_un, ILOpCode.Ble_un, ILOpCode.Blt_un, + ILOpCode.Leave, + ILOpCode.Jmp, ILOpCode.Call, ILOpCode.Callvirt, ILOpCode.Newobj, ILOpCode.Ldftn, ILOpCode.Ldvirtftn, + ILOpCode.Calli, + ILOpCode.Ldfld, ILOpCode.Ldflda, ILOpCode.Stfld, ILOpCode.Ldsfld, ILOpCode.Ldsflda, ILOpCode.Stsfld, + ILOpCode.Cpobj, ILOpCode.Ldobj, ILOpCode.Stobj, ILOpCode.Castclass, ILOpCode.Isinst, + ILOpCode.Box, ILOpCode.Newarr, ILOpCode.Ldelema, ILOpCode.Ldelem, ILOpCode.Stelem, + ILOpCode.Unbox, ILOpCode.Unbox_any, ILOpCode.Refanyval, ILOpCode.Mkrefany, + ILOpCode.Initobj, ILOpCode.Constrained, ILOpCode.Sizeof, + ILOpCode.Ldstr, ILOpCode.Ldtoken); + + // 8-byte operand. + Add (table, 8, ILOpCode.Ldc_i8, ILOpCode.Ldc_r8); + + return table; + } + + static void Add (Dictionary table, int size, params ILOpCode [] codes) + { + foreach (var code in codes) { + table [(ushort) code] = size; + } + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs new file mode 100644 index 00000000000..fa47fe17310 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs @@ -0,0 +1,59 @@ +#nullable enable + +using System; +using System.Collections.Immutable; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Microsoft.Build.Utilities; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + sealed class JniRewriteResult + { + public byte [] Image { get; } + public int ReplacementCount { get; } + public bool StrongNameSignatureCleared { get; } + + public JniRewriteResult (byte [] image, int replacementCount, bool strongNameSignatureCleared) + { + Image = image; + ReplacementCount = replacementCount; + StrongNameSignatureCleared = strongNameSignatureCleared; + } + } + + /// + /// Rewrites the JNI names embedded in an assembly - Android.Runtime.RegisterAttribute, + /// the Java.Interop.Jni*SignatureAttribute family, the JniPeerMembers/RegisterNatives + /// ldstr strings, and the generated null-terminated UTF-8 JNI data stored in + /// FieldRVA - according to an R8 mapping. + /// + /// The rewrite runs in two passes. The first scans the source into an exact plan; the second + /// reconstructs the whole assembly with MetadataBuilder, cloning every table row in its + /// original order (so entity tokens keep their values) while rebuilding the heaps. That lifts + /// the length restrictions of an in-place heap patch and lets two use sites that shared one + /// deduplicated heap entry receive different values. + /// + static class JniAssemblyRewriter + { + public static JniRewriteResult Rewrite (byte [] sourceImage, R8Mapping mapping, TaskLoggingHelper log) + { + using var peReader = new PEReader (ImmutableArray.Create (sourceImage)); + if (!peReader.HasMetadata) { + throw new JniRewriteException ("The file contains no managed metadata."); + } + + MetadataReader reader = peReader.GetMetadataReader (); + FieldRvaTable fieldRvaTable = FieldRvaTable.Read (peReader, reader); + + JniRewritePlan plan = new JniRewritePlanner (peReader, reader, mapping, fieldRvaTable, log).CreatePlan (); + if (plan.ReplacementCount == 0) { + return new JniRewriteResult (sourceImage, 0, strongNameSignatureCleared: false); + } + + AssemblyRebuildResult rebuilt = new AssemblyRebuilder (peReader, reader, plan, fieldRvaTable).Build (); + + return new JniRewriteResult (rebuilt.Image, plan.ReplacementCount, rebuilt.StrongNameSignatureCleared); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs new file mode 100644 index 00000000000..8e2fe422ae0 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs @@ -0,0 +1,210 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// Small, self-contained helpers for scanning and rewriting JNI type names, descriptors, and + /// the various encoded string forms that Java.Interop / the generator embed in compiled + /// assemblies (JniPeerMembers "name.descriptor" ids, RegisterNatives lines, etc). + /// + /// These deliberately duplicate a subset of Microsoft.Android.Sdk.TrimmableTypeMap's + /// JniSignatureHelper (which is internal to that assembly and not visible here) rather than + /// exposing it across assembly boundaries. + /// + static class JniDescriptorText + { + /// + /// Rewrites every embedded reference-type name ("Lfoo/bar/Baz;") within a JNI type or + /// method descriptor using . Returns false (and the + /// original text unmodified) if nothing needed to change. + /// + public static bool TryRewriteDescriptor (string descriptor, Func renameClass, out string rewritten) + { + var sb = new StringBuilder (descriptor.Length); + bool changed = false; + int i = 0; + while (i < descriptor.Length) { + int start = i; + if (!TryScanSingleToken (descriptor, ref i, allowParens: true)) { + // Not a type token (e.g. '(' or ')' bracket of a method descriptor) - copy verbatim. + sb.Append (descriptor [start]); + i = start + 1; + continue; + } + + string token = descriptor.Substring (start, i - start); + if (TryRewriteSingleTypeToken (token, renameClass, out string newToken)) { + changed = true; + sb.Append (newToken); + } else { + sb.Append (token); + } + } + + rewritten = changed ? sb.ToString () : descriptor; + return changed; + } + + /// + /// Rewrites a single JNI type token ("I", "[I", "Lfoo/Bar;", "[Lfoo/Bar;", ...) if it is an + /// object/array-of-object reference whose class name has a rename entry. + /// + static bool TryRewriteSingleTypeToken (string token, Func renameClass, out string rewritten) + { + rewritten = token; + int arrayDepth = 0; + while (arrayDepth < token.Length && token [arrayDepth] == '[') { + arrayDepth++; + } + + if (arrayDepth >= token.Length || token [arrayDepth] != 'L') { + return false; // Primitive (or malformed) - nothing to rename. + } + + // token[arrayDepth] == 'L', token ends with ';'. + string className = token.Substring (arrayDepth + 1, token.Length - arrayDepth - 2); + string? renamed = renameClass (className); + if (renamed == null || renamed == className) { + return false; + } + + rewritten = token.Substring (0, arrayDepth) + "L" + renamed + ";"; + return true; + } + + /// + /// Scans a single JNI type descriptor token ("I", "[[I", "Lfoo/Bar;", ...) starting at + /// , advancing past it. Returns false (without + /// advancing) if the character at cannot start a type token. + /// + static bool TryScanSingleToken (string s, ref int i, bool allowParens) + { + int start = i; + int j = i; + while (j < s.Length && s [j] == '[') { + j++; + } + + if (j >= s.Length) { + return false; + } + + switch (s [j]) { + case 'V': case 'Z': case 'B': case 'C': case 'S': case 'I': case 'J': case 'F': case 'D': + i = j + 1; + return true; + case 'L': + int end = s.IndexOf (';', j + 1); + if (end < 0) { + return false; + } + i = end + 1; + return true; + default: + return false; + } + } + + /// + /// Parses a JNI method descriptor "(param1param2...)ret" into its parameter type tokens + /// and return type token. + /// + public static bool TryParseMethodDescriptor (string descriptor, out List parameterTypes, out string returnType) + { + parameterTypes = new List (); + returnType = ""; + + if (descriptor.Length == 0 || descriptor [0] != '(') { + return false; + } + + int i = 1; + while (i < descriptor.Length && descriptor [i] != ')') { + int start = i; + if (!TryScanSingleToken (descriptor, ref i, allowParens: false)) { + return false; + } + parameterTypes.Add (descriptor.Substring (start, i - start)); + } + + if (i >= descriptor.Length || descriptor [i] != ')') { + return false; + } + i++; + + int retStart = i; + if (!TryScanSingleToken (descriptor, ref i, allowParens: false) || i != descriptor.Length) { + return false; + } + + returnType = descriptor.Substring (retStart); + return true; + } + + /// + /// True if is a syntactically valid JNI method descriptor, + /// e.g. "(Ljava/lang/Object;)Z" or "()V". + /// + public static bool IsValidMethodDescriptor (string descriptor) + => TryParseMethodDescriptor (descriptor, out _, out _); + + /// + /// True if is a single, complete JNI field/type descriptor, + /// e.g. "I", "[I", or "Ljava/lang/Object;" (and nothing else follows it). + /// + public static bool IsValidFieldDescriptor (string descriptor) + { + int i = 0; + return descriptor.Length > 0 && TryScanSingleToken (descriptor, ref i, allowParens: false) && i == descriptor.Length; + } + + /// + /// Converts a JNI type token ("I", "[Lfoo/Bar;", "Ljava/lang/String;") to its Java *source* + /// form ("int", "foo.Bar[]", "java.lang.String") as used in mapping.txt member lines. + /// + public static string JniTypeTokenToJavaSource (string token) + { + int arrayDepth = 0; + while (arrayDepth < token.Length && token [arrayDepth] == '[') { + arrayDepth++; + } + + string elementJavaName = token [arrayDepth] switch { + 'V' => "void", + 'Z' => "boolean", + 'B' => "byte", + 'C' => "char", + 'S' => "short", + 'I' => "int", + 'J' => "long", + 'F' => "float", + 'D' => "double", + 'L' => token.Substring (arrayDepth + 1, token.Length - arrayDepth - 2).Replace ('/', '.'), + _ => throw new ArgumentException ($"Malformed JNI type token '{token}'.", nameof (token)), + }; + + return elementJavaName + string.Concat (System.Linq.Enumerable.Repeat ("[]", arrayDepth)); + } + + /// + /// Splits a JNI method descriptor's parameter list into Java-source-form parameter types, + /// e.g. "(Landroid/os/Bundle;I)V" -> ["android.os.Bundle", "int"]. + /// + public static List MethodDescriptorToJavaParameterTypes (string descriptor) + { + if (!TryParseMethodDescriptor (descriptor, out var parameterTypes, out _)) { + throw new ArgumentException ($"Malformed JNI method descriptor '{descriptor}'.", nameof (descriptor)); + } + + var result = new List (parameterTypes.Count); + foreach (string p in parameterTypes) { + result.Add (JniTypeTokenToJavaSource (p)); + } + return result; + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewriteException.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewriteException.cs new file mode 100644 index 00000000000..95213c9a174 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewriteException.cs @@ -0,0 +1,22 @@ +#nullable enable + +using System; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// Thrown when an assembly cannot be rewritten: either it uses a metadata construct this + /// prototype does not know how to reproduce, or two conflicting JNI replacements were + /// requested for a single shared piece of data that cannot be split without moving tokens. + /// + sealed class JniRewriteException : Exception + { + public JniRewriteException (string message) : base (message) + { + } + + public JniRewriteException (string message, Exception innerException) : base (message, innerException) + { + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlan.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlan.cs new file mode 100644 index 00000000000..509d7a3e031 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlan.cs @@ -0,0 +1,119 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Reflection.Metadata; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// Walks a method body's IL, reporting each instruction's opcode together with the location + /// and length of its operand. Every legal opcode has a fixed operand size except + /// switch, whose length depends on its embedded case count. + /// + static class IlInstructionScanner + { + public delegate void InstructionVisitor (ushort opCode, int instructionOffset, int operandOffset, int operandSize); + + public static void Walk (byte [] il, InstructionVisitor visit) + { + int i = 0; + while (i < il.Length) { + int instructionOffset = i; + byte b0 = il [i]; + ushort code; + if (b0 == 0xFE) { + if (i + 1 >= il.Length) { + throw new JniRewriteException ("Malformed IL: truncated two-byte opcode."); + } + code = (ushort) (0xFE00 | il [i + 1]); + i += 2; + } else { + code = b0; + i += 1; + } + + int operandOffset = i; + int operandSize; + if (code == (ushort) ILOpCode.Switch) { + if (i + 4 > il.Length) { + throw new JniRewriteException ("Malformed IL: truncated switch operand."); + } + uint caseCount = ReadUInt32 (il, i); + operandSize = checked (4 + (int) caseCount * 4); + } else if (!IlOpcodeTable.OperandSizes.TryGetValue (code, out operandSize)) { + throw new JniRewriteException ($"Unrecognized IL opcode 0x{code:X} while scanning a method body."); + } + + if (operandOffset + operandSize > il.Length) { + throw new JniRewriteException ($"Malformed IL: operand of opcode 0x{code:X} at offset {instructionOffset} extends past the end of the method body."); + } + + visit (code, instructionOffset, operandOffset, operandSize); + i = operandOffset + operandSize; + } + } + + public static uint ReadUInt32 (byte [] data, int offset) + => (uint) (data [offset] | (data [offset + 1] << 8) | (data [offset + 2] << 16) | (data [offset + 3] << 24)); + + public static void WriteUInt32 (byte [] data, int offset, uint value) + { + data [offset] = (byte) value; + data [offset + 1] = (byte) (value >> 8); + data [offset + 2] = (byte) (value >> 16); + data [offset + 3] = (byte) (value >> 24); + } + } + + /// + /// The exact set of edits the rebuilder must apply while cloning an assembly. Every entry is + /// keyed by the *use site* rather than by the shared heap entry it happens to resolve to, so + /// two ldstr instructions (or two custom attributes) that the compiler deduplicated + /// into one #US/#Blob entry can still be given different replacements: the + /// rebuilder emits a fresh handle per distinct value. + /// + sealed class JniRewritePlan + { + readonly Dictionary customAttributeBlobs = new (); + readonly Dictionary> userStrings = new (); + readonly Dictionary utf8FieldValues = new (); + + public int ReplacementCount { get; private set; } + + public void AddCustomAttributeBlob (CustomAttributeHandle handle, byte [] newValue) + { + customAttributeBlobs [handle] = newValue; + ReplacementCount++; + } + + /// + /// Records that the ldstr whose 4-byte token operand starts at + /// in 's body should load + /// instead. + /// + public void AddUserString (MethodDefinitionHandle method, int operandOffset, string newValue) + { + if (!userStrings.TryGetValue (method, out var perMethod)) { + userStrings [method] = perMethod = new Dictionary (); + } + perMethod [operandOffset] = newValue; + ReplacementCount++; + } + + public void AddUtf8FieldValue (FieldDefinitionHandle field, string newValue) + { + utf8FieldValues [field] = newValue; + ReplacementCount++; + } + + public byte []? GetCustomAttributeBlob (CustomAttributeHandle handle) + => customAttributeBlobs.TryGetValue (handle, out byte []? newValue) ? newValue : null; + + public Dictionary? GetUserStrings (MethodDefinitionHandle method) + => userStrings.TryGetValue (method, out Dictionary? replacements) ? replacements : null; + + public string? GetUtf8FieldValue (FieldDefinitionHandle field) + => utf8FieldValues.TryGetValue (field, out string? newValue) ? newValue : null; + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs new file mode 100644 index 00000000000..f8c5f221079 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs @@ -0,0 +1,544 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using Microsoft.Build.Utilities; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// Pass one of the rewrite: scans an assembly and produces the exact set of JNI-bearing + /// values that must change, without mutating anything. The rebuilder then reproduces the + /// assembly and applies the plan. + /// + sealed class JniRewritePlanner + { + const string RegisterAttributeFullName = "Android.Runtime.RegisterAttribute"; + const string JniTypeSignatureAttributeFullName = "Java.Interop.JniTypeSignatureAttribute"; + const string JniMethodSignatureAttributeFullName = "Java.Interop.JniMethodSignatureAttribute"; + const string JniConstructorSignatureAttributeFullName = "Java.Interop.JniConstructorSignatureAttribute"; + + const string JavaPeerProxyNamespace = "Java.Interop"; + const string JavaPeerProxyName = "JavaPeerProxy"; + + enum Utf8Role + { + Unknown, + MethodName, + MethodSignature, + } + + readonly struct Utf8Use + { + public Utf8Role Role { get; } + public string? OwnerJniName { get; } + public string? PairedSignature { get; } + + public Utf8Use (Utf8Role role, string? ownerJniName, string? pairedSignature) + { + Role = role; + OwnerJniName = ownerJniName; + PairedSignature = pairedSignature; + } + } + + readonly PEReader peReader; + readonly MetadataReader reader; + readonly R8Mapping mapping; + readonly FieldRvaTable fieldRvaTable; + readonly TaskLoggingHelper log; + readonly Func renameClass; + readonly Dictionary ownerJniNameCache = new (); + readonly Dictionary> utf8Uses = new (); + + public JniRewritePlanner (PEReader peReader, MetadataReader reader, R8Mapping mapping, FieldRvaTable fieldRvaTable, TaskLoggingHelper log) + { + this.peReader = peReader; + this.reader = reader; + this.mapping = mapping; + this.fieldRvaTable = fieldRvaTable; + this.log = log; + renameClass = className => mapping.TryGetRenamedClass (className, out string renamed) ? renamed : null; + } + + public JniRewritePlan CreatePlan () + { + var plan = new JniRewritePlan (); + + foreach (TypeDefinitionHandle typeHandle in reader.TypeDefinitions) { + PlanType (plan, typeHandle); + } + + PlanUtf8FieldData (plan); + return plan; + } + + void PlanType (JniRewritePlan plan, TypeDefinitionHandle typeHandle) + { + TypeDefinition typeDef = reader.GetTypeDefinition (typeHandle); + string? ownerJniName = ResolveOwnerJniName (typeHandle); + + PlanTypeLevelAttributes (plan, typeDef, ownerJniName); + + foreach (MethodDefinitionHandle methodHandle in typeDef.GetMethods ()) { + PlanMethodAttributes (plan, methodHandle, ownerJniName); + PlanMethodBody (plan, methodHandle, ownerJniName); + } + + foreach (FieldDefinitionHandle fieldHandle in typeDef.GetFields ()) { + PlanMemberNameAttributes (plan, reader.GetFieldDefinition (fieldHandle).GetCustomAttributes (), ownerJniName); + } + + foreach (PropertyDefinitionHandle propertyHandle in typeDef.GetProperties ()) { + PlanMemberNameAttributes (plan, reader.GetPropertyDefinition (propertyHandle).GetCustomAttributes (), ownerJniName); + } + + foreach (EventDefinitionHandle eventHandle in typeDef.GetEvents ()) { + PlanMemberNameAttributes (plan, reader.GetEventDefinition (eventHandle).GetCustomAttributes (), ownerJniName); + } + } + + /// + /// Resolves the JNI class name that "owns" a type: its own Register/JniTypeSignature + /// argument, the JNI name a generated JavaPeerProxy passes to its base constructor, + /// or (recursively) its enclosing type's. + /// + string? ResolveOwnerJniName (TypeDefinitionHandle typeHandle) + { + if (ownerJniNameCache.TryGetValue (typeHandle, out string? cached)) { + return cached; + } + + // Guard against a pathological/cyclical nesting chain while resolving. + ownerJniNameCache [typeHandle] = null; + + TypeDefinition typeDef = reader.GetTypeDefinition (typeHandle); + string? result = TryGetTypeLevelJniName (typeDef) ?? TryGetJavaPeerProxyJniName (typeDef); + if (result == null) { + TypeDefinitionHandle declaring = typeDef.GetDeclaringType (); + if (!declaring.IsNil) { + result = ResolveOwnerJniName (declaring); + } + } + + ownerJniNameCache [typeHandle] = result; + return result; + } + + string? TryGetTypeLevelJniName (TypeDefinition typeDef) + { + foreach (CustomAttributeHandle caHandle in typeDef.GetCustomAttributes ()) { + CustomAttribute ca = reader.GetCustomAttribute (caHandle); + string? fullName = reader.GetCustomAttributeFullName (ca, log); + if (fullName != RegisterAttributeFullName && fullName != JniTypeSignatureAttributeFullName) { + continue; + } + + var args = ca.GetCustomAttributeArguments ().FixedArguments; + if (args.Length >= 1 && args [0].Value is string s && s.Length > 0) { + return s; + } + } + return null; + } + + /// + /// The trimmable typemap generator emits one JavaPeerProxy subclass per Java peer + /// whose parameterless constructor passes the peer's JNI name to the base constructor as + /// its only ldstr. That is the type's JNI identity. + /// + string? TryGetJavaPeerProxyJniName (TypeDefinition typeDef) + { + if (!IsJavaPeerProxy (typeDef.BaseType)) { + return null; + } + + foreach (MethodDefinitionHandle methodHandle in typeDef.GetMethods ()) { + MethodDefinition method = reader.GetMethodDefinition (methodHandle); + if ((method.Attributes & MethodAttributes.RTSpecialName) == 0 || reader.GetString (method.Name) != ".ctor") { + continue; + } + if (method.RelativeVirtualAddress == 0) { + continue; + } + + string? found = null; + bool ambiguous = false; + byte [] il = GetILBytes (method); + IlInstructionScanner.Walk (il, (code, _, operandOffset, _) => { + if (code != (ushort) ILOpCode.Ldstr) { + return; + } + string value = ReadUserString (il, operandOffset); + if (found != null && found != value) { + ambiguous = true; + } + found ??= value; + }); + + if (!ambiguous && found != null && found.Length > 0) { + return found; + } + } + + return null; + } + + bool IsJavaPeerProxy (EntityHandle baseType) + { + if (baseType.IsNil) { + return false; + } + + if (baseType.Kind == HandleKind.TypeReference) { + TypeReference typeRef = reader.GetTypeReference ((TypeReferenceHandle) baseType); + return reader.GetString (typeRef.Name) == JavaPeerProxyName && reader.GetString (typeRef.Namespace) == JavaPeerProxyNamespace; + } + + if (baseType.Kind == HandleKind.TypeDefinition) { + TypeDefinition typeDef = reader.GetTypeDefinition ((TypeDefinitionHandle) baseType); + return reader.GetString (typeDef.Name) == JavaPeerProxyName && reader.GetString (typeDef.Namespace) == JavaPeerProxyNamespace; + } + + return false; + } + + void PlanTypeLevelAttributes (JniRewritePlan plan, TypeDefinition typeDef, string? ownerJniName) + { + if (ownerJniName == null || !mapping.TryGetRenamedClass (ownerJniName, out string renamedClass)) { + return; + } + + foreach (CustomAttributeHandle caHandle in typeDef.GetCustomAttributes ()) { + CustomAttribute ca = reader.GetCustomAttribute (caHandle); + string? fullName = reader.GetCustomAttributeFullName (ca, log); + if (fullName != RegisterAttributeFullName && fullName != JniTypeSignatureAttributeFullName) { + continue; + } + + var args = ca.GetCustomAttributeArguments ().FixedArguments; + if (args.Length == 0 || args [0].Value is not string current || current != ownerJniName) { + continue; + } + + PlanCustomAttributeRewrite (plan, caHandle, ca, args.Length, (i, _) => i == 0 ? renamedClass : null); + } + } + + void PlanMethodAttributes (JniRewritePlan plan, MethodDefinitionHandle methodHandle, string? ownerJniName) + { + MethodDefinition method = reader.GetMethodDefinition (methodHandle); + + foreach (CustomAttributeHandle caHandle in method.GetCustomAttributes ()) { + CustomAttribute ca = reader.GetCustomAttribute (caHandle); + string? fullName = reader.GetCustomAttributeFullName (ca, log); + + switch (fullName) { + case RegisterAttributeFullName: + case JniMethodSignatureAttributeFullName: + PlanNameAndDescriptorAttribute (plan, caHandle, ca, ownerJniName, nameIndex: 0, descriptorIndex: 1); + break; + case JniConstructorSignatureAttributeFullName: + // (string memberSignature): the member name is implicitly ".ctor". + PlanNameAndDescriptorAttribute (plan, caHandle, ca, ownerJniName, nameIndex: -1, descriptorIndex: 0); + break; + } + } + } + + void PlanNameAndDescriptorAttribute (JniRewritePlan plan, CustomAttributeHandle caHandle, CustomAttribute ca, string? ownerJniName, int nameIndex, int descriptorIndex) + { + var args = ca.GetCustomAttributeArguments ().FixedArguments; + if (args.Length <= descriptorIndex) { + return; + } + + string jniMemberName = nameIndex < 0 + ? ".ctor" + : args [nameIndex].Value as string ?? ""; + if (jniMemberName.Length == 0) { + return; + } + + string? jniDescriptor = args [descriptorIndex].Value as string; + string? newName = TryFindRenamedMethodName (ownerJniName, jniMemberName, jniDescriptor); + string? newDescriptor = jniDescriptor != null && JniDescriptorText.TryRewriteDescriptor (jniDescriptor, renameClass, out string rewrittenDescriptor) + ? rewrittenDescriptor + : null; + + if (newName == null && newDescriptor == null) { + return; + } + + PlanCustomAttributeRewrite (plan, caHandle, ca, args.Length, (i, _) => { + if (i == nameIndex) { + return newName; + } + if (i == descriptorIndex) { + return newDescriptor; + } + return null; + }); + } + + void PlanMemberNameAttributes (JniRewritePlan plan, CustomAttributeHandleCollection attributes, string? ownerJniName) + { + if (ownerJniName == null) { + return; + } + + foreach (CustomAttributeHandle caHandle in attributes) { + CustomAttribute ca = reader.GetCustomAttribute (caHandle); + if (reader.GetCustomAttributeFullName (ca, log) != RegisterAttributeFullName) { + continue; + } + + var args = ca.GetCustomAttributeArguments ().FixedArguments; + if (args.Length == 0 || args [0].Value is not string jniFieldName) { + continue; + } + + if (!mapping.TryGetRenamedField (ownerJniName, jniFieldName, out string renamedField)) { + continue; + } + + PlanCustomAttributeRewrite (plan, caHandle, ca, args.Length, (i, _) => i == 0 ? renamedField : null); + } + } + + string? TryFindRenamedMethodName (string? ownerJniName, string jniMemberName, string? jniDescriptor) + { + if (ownerJniName == null) { + return null; + } + + string mappingName = R8Mapping.JniMemberNameToMappingName (jniMemberName); + + if (jniDescriptor != null && JniDescriptorText.IsValidMethodDescriptor (jniDescriptor)) { + var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (jniDescriptor); + return mapping.TryGetRenamedMethod (ownerJniName, mappingName, javaParams, out string renamed) ? renamed : null; + } + + return mapping.TryGetRenamedMethodByNameOnly (ownerJniName, mappingName, out string renamedByNameOnly) + ? renamedByNameOnly + : null; + } + + void PlanCustomAttributeRewrite (JniRewritePlan plan, CustomAttributeHandle caHandle, CustomAttribute ca, int fixedArgCount, Func rewriteArg) + { + BlobReader blobReader = reader.GetBlobReader (ca.Value); + byte [] originalContent = blobReader.ReadBytes (blobReader.Length); + byte []? newContent = CustomAttributeStringRewriter.TryRewrite (originalContent, fixedArgCount, rewriteArg); + if (newContent != null) { + plan.AddCustomAttributeBlob (caHandle, newContent); + } + } + + void PlanMethodBody (JniRewritePlan plan, MethodDefinitionHandle methodHandle, string? ownerJniName) + { + MethodDefinition method = reader.GetMethodDefinition (methodHandle); + if (method.RelativeVirtualAddress == 0) { + return; // No IL body (abstract, extern/P-Invoke, etc). + } + + byte [] il = GetILBytes (method); + FieldDefinitionHandle pendingUtf8Name = default; + string? referencedOwnerJniName = FindSingleReferencedJniClass (il); + string? pendingMemberName = null; + int pendingMemberNameOffset = 0; + + IlInstructionScanner.Walk (il, (code, _, operandOffset, _) => { + if (code == (ushort) ILOpCode.Ldstr) { + pendingUtf8Name = default; + string value = ReadUserString (il, operandOffset); + if (referencedOwnerJniName != null && pendingMemberName != null) { + if (JniDescriptorText.IsValidFieldDescriptor (value) && + mapping.TryGetRenamedField (referencedOwnerJniName, pendingMemberName, out string renamedField)) { + plan.AddUserString (methodHandle, pendingMemberNameOffset, renamedField); + } else if (JniDescriptorText.IsValidMethodDescriptor (value)) { + var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (value); + string mappingName = R8Mapping.JniMemberNameToMappingName (pendingMemberName); + if (mapping.TryGetRenamedMethod (referencedOwnerJniName, mappingName, javaParams, out string renamedMethod)) { + plan.AddUserString (methodHandle, pendingMemberNameOffset, renamedMethod); + } + } + } + if (LdstrRewriter.TryRewrite (value, ownerJniName, mapping, out string rewritten)) { + plan.AddUserString (methodHandle, operandOffset, rewritten); + } + pendingMemberName = IsBareMemberName (value) ? value : null; + pendingMemberNameOffset = operandOffset; + return; + } + + if (code != (ushort) ILOpCode.Pop) { + pendingMemberName = null; + } + if (code != (ushort) ILOpCode.Ldsflda && code != (ushort) ILOpCode.Ldsfld) { + pendingUtf8Name = default; + return; + } + + FieldDefinitionHandle field = TryGetUtf8Field (il, operandOffset); + if (field.IsNil) { + pendingUtf8Name = default; + return; + } + + // The typemap generator emits `ldsflda ; ldsflda ` pairs when + // filling in a JniNativeMethod for RegisterNatives; that adjacency is what makes + // an otherwise ambiguous bare method name resolvable against the owning class. + if (pendingUtf8Name.IsNil) { + pendingUtf8Name = field; + return; + } + + string? signature = GetUtf8Value (field); + if (signature != null && JniDescriptorText.IsValidMethodDescriptor (signature)) { + RecordUtf8Use (pendingUtf8Name, new Utf8Use (Utf8Role.MethodName, ownerJniName, signature)); + RecordUtf8Use (field, new Utf8Use (Utf8Role.MethodSignature, ownerJniName, null)); + } else { + RecordUtf8Use (pendingUtf8Name, new Utf8Use (Utf8Role.Unknown, null, null)); + RecordUtf8Use (field, new Utf8Use (Utf8Role.Unknown, null, null)); + } + + pendingUtf8Name = default; + }); + + if (!pendingUtf8Name.IsNil) { + RecordUtf8Use (pendingUtf8Name, new Utf8Use (Utf8Role.Unknown, null, null)); + } + } + + string? FindSingleReferencedJniClass (byte [] il) + { + string? referencedClass = null; + bool ambiguous = false; + IlInstructionScanner.Walk (il, (code, _, operandOffset, _) => { + if (ambiguous || code != (ushort) ILOpCode.Ldstr) { + return; + } + + string value = ReadUserString (il, operandOffset); + if (!mapping.TryGetRenamedClass (value, out _)) { + return; + } + if (referencedClass != null && referencedClass != value) { + ambiguous = true; + return; + } + referencedClass = value; + }); + return ambiguous ? null : referencedClass; + } + + static bool IsBareMemberName (string value) + { + if (value.Length == 0) { + return false; + } + foreach (char c in value) { + if (!(char.IsLetterOrDigit (c) || c == '_' || c == '$' || c == '<' || c == '>')) { + return false; + } + } + return true; + } + + void RecordUtf8Use (FieldDefinitionHandle field, Utf8Use use) + { + if (!utf8Uses.TryGetValue (field, out var uses)) { + utf8Uses [field] = uses = new List (); + } + uses.Add (use); + } + + FieldDefinitionHandle TryGetUtf8Field (byte [] il, int operandOffset) + { + uint token = IlInstructionScanner.ReadUInt32 (il, operandOffset); + if ((token & 0xFF000000) != 0x04000000) { + return default; // Not a FieldDefinition token. + } + + var handle = MetadataTokens.FieldDefinitionHandle ((int) (token & 0x00FFFFFF)); + FieldRvaEntry? entry = fieldRvaTable.Get (handle); + return entry != null && entry.IsUtf8Datum ? handle : default; + } + + string? GetUtf8Value (FieldDefinitionHandle field) => fieldRvaTable.Get (field)?.Utf8Value; + + void PlanUtf8FieldData (JniRewritePlan plan) + { + foreach (FieldRvaEntry entry in fieldRvaTable.Entries) { + string? value = entry.Utf8Value; + if (value == null) { + continue; + } + + string? resolved = null; + foreach (Utf8Use use in GetUses (entry.Field)) { + string? candidate = ComputeNewUtf8Value (value, use); + if (candidate == null) { + continue; + } + if (resolved != null && resolved != candidate) { + throw new JniRewriteException ( + $"The mapped UTF-8 JNI datum '{value}' is shared by more than one Java class, but the mapping renames it to both " + + $"'{resolved}' and '{candidate}'. Splitting a shared '{FieldRvaTable.Utf8FieldNamePrefix}' field would move metadata tokens, which this rewriter does not do."); + } + resolved ??= candidate; + } + + if (resolved != null && resolved != value) { + plan.AddUtf8FieldValue (entry.Field, resolved); + } + } + } + + IEnumerable GetUses (FieldDefinitionHandle field) + { + if (utf8Uses.TryGetValue (field, out var uses)) { + return uses; + } + return new [] { new Utf8Use (Utf8Role.Unknown, null, null) }; + } + + string? ComputeNewUtf8Value (string value, Utf8Use use) + { + if (use.Role == Utf8Role.MethodName && use.OwnerJniName != null && use.PairedSignature != null) { + var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (use.PairedSignature); + string mappingName = R8Mapping.JniMemberNameToMappingName (value); + return mapping.TryGetRenamedMethod (use.OwnerJniName, mappingName, javaParams, out string renamed) ? renamed : null; + } + + if (JniDescriptorText.IsValidMethodDescriptor (value) || JniDescriptorText.IsValidFieldDescriptor (value)) { + return JniDescriptorText.TryRewriteDescriptor (value, renameClass, out string rewritten) ? rewritten : null; + } + + if (use.Role == Utf8Role.Unknown && mapping.TryGetRenamedClass (value, out string renamedClass)) { + return renamedClass; + } + + return null; + } + + byte [] GetILBytes (MethodDefinition method) + { + MethodBodyBlock body = peReader.GetMethodBody (method.RelativeVirtualAddress); + return body.GetILBytes () ?? Array.Empty (); + } + + string ReadUserString (byte [] il, int operandOffset) + { + uint token = IlInstructionScanner.ReadUInt32 (il, operandOffset); + if ((token & 0xFF000000) != 0x70000000) { + throw new JniRewriteException ($"Malformed IL: ldstr operand 0x{token:X8} is not a #US token."); + } + return reader.GetUserString (MetadataTokens.UserStringHandle ((int) (token & 0x00FFFFFF))); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs new file mode 100644 index 00000000000..433d99d38a2 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs @@ -0,0 +1,170 @@ +#nullable enable + +using System; +using System.Collections.Generic; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// Classifies and rewrites the various forms of JNI-name-bearing string literals that + /// Java.Interop / the binding generator embed in IL as ldstr operands: + /// + /// - RegisterNatives / FastRegisterNativeMembers blocks: one or more lines of + /// "name:descriptor:connector[:callbackDeclaringType]", separated by '\n' (no trailing + /// newline required). + /// - JniPeerMembers encoded member ids: "name.descriptor" (e.g. "equals.(Ljava/lang/Object;)Z" + /// or "eventTypes.I"), or a bare descriptor for constructors (e.g. "()V"). + /// - Bare JNI class names (e.g. "java/lang/Object"). + /// + /// Anything else (ordinary .NET strings that merely happen to contain '.' or ':') is left + /// untouched. + /// + static class LdstrRewriter + { + public static bool TryRewrite (string value, string? ownerJniName, R8Mapping mapping, out string rewritten) + { + Func renameClass = className => mapping.TryGetRenamedClass (className, out string renamed) ? renamed : null; + + if (value.IndexOf ('\n') >= 0) { + return TryRewriteMultilineRegisterNatives (value, ownerJniName, mapping, renameClass, out rewritten); + } + + if (value.IndexOf (':') >= 0 && TryRewriteRegisterNativesLine (value, ownerJniName, mapping, renameClass, out rewritten)) { + return true; + } + + if (TryRewriteJniPeerMemberId (value, ownerJniName, mapping, renameClass, out rewritten)) { + return true; + } + + if (JniDescriptorText.IsValidMethodDescriptor (value) || JniDescriptorText.IsValidFieldDescriptor (value)) { + return JniDescriptorText.TryRewriteDescriptor (value, renameClass, out rewritten); + } + + if (mapping.TryGetRenamedClass (value, out string renamedWhole)) { + rewritten = renamedWhole; + return true; + } + + rewritten = value; + return false; + } + + static bool TryRewriteMultilineRegisterNatives (string value, string? ownerJniName, R8Mapping mapping, Func renameClass, out string rewritten) + { + string [] lines = value.Split ('\n'); + bool changed = false; + + for (int i = 0; i < lines.Length; i++) { + if (lines [i].Length == 0) { + continue; + } + if (TryRewriteRegisterNativesLine (lines [i], ownerJniName, mapping, renameClass, out string newLine)) { + lines [i] = newLine; + changed = true; + } + } + + rewritten = changed ? string.Join ("\n", lines) : value; + return changed; + } + + /// + /// Rewrites a single "name:descriptor:connector[:callbackDeclaringType]" line, as used by + /// AndroidRuntime.RegisterNativeMembers / FastRegisterNativeMembers. + /// + static bool TryRewriteRegisterNativesLine (string line, string? ownerJniName, R8Mapping mapping, Func renameClass, out string rewritten) + { + rewritten = line; + + int firstColon = line.IndexOf (':'); + if (firstColon < 0) { + return false; + } + int secondColon = line.IndexOf (':', firstColon + 1); + if (secondColon < 0) { + return false; + } + + string name = line.Substring (0, firstColon); + string descriptor = line.Substring (firstColon + 1, secondColon - firstColon - 1); + string rest = line.Substring (secondColon); // Includes the leading ':'. + + if (!JniDescriptorText.IsValidMethodDescriptor (descriptor)) { + return false; + } + + bool changed = false; + string newName = name; + if (ownerJniName != null) { + var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (descriptor); + string mappingName = R8Mapping.JniMemberNameToMappingName (name); + if (mapping.TryGetRenamedMethod (ownerJniName, mappingName, javaParams, out string renamedMethod)) { + newName = renamedMethod; + changed = true; + } + } + + bool descriptorChanged = JniDescriptorText.TryRewriteDescriptor (descriptor, renameClass, out string newDescriptor); + changed |= descriptorChanged; + + if (!changed) { + return false; + } + + rewritten = newName + ":" + newDescriptor + rest; + return true; + } + + /// + /// Rewrites a JniPeerMembers encoded member id: "name.descriptor" for a method + /// (descriptor starts with '(') or a field (descriptor is a single type token). + /// + static bool TryRewriteJniPeerMemberId (string value, string? ownerJniName, R8Mapping mapping, Func renameClass, out string rewritten) + { + rewritten = value; + + int dot = value.IndexOf ('.'); + if (dot <= 0 || dot == value.Length - 1) { + return false; + } + + string name = value.Substring (0, dot); + string descriptor = value.Substring (dot + 1); + + bool isMethod = descriptor.Length > 0 && descriptor [0] == '('; + if (isMethod ? !JniDescriptorText.IsValidMethodDescriptor (descriptor) : !JniDescriptorText.IsValidFieldDescriptor (descriptor)) { + return false; + } + + bool changed = false; + string newName = name; + + if (ownerJniName != null) { + if (isMethod) { + var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (descriptor); + string mappingName = R8Mapping.JniMemberNameToMappingName (name); + if (mapping.TryGetRenamedMethod (ownerJniName, mappingName, javaParams, out string renamedMethod)) { + newName = renamedMethod; + changed = true; + } + } else { + if (mapping.TryGetRenamedField (ownerJniName, name, out string renamedField)) { + newName = renamedField; + changed = true; + } + } + } + + bool descriptorChanged = JniDescriptorText.TryRewriteDescriptor (descriptor, renameClass, out string newDescriptor); + changed |= descriptorChanged; + + if (!changed) { + return false; + } + + rewritten = newName + "." + newDescriptor; + return true; + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/MetadataEncoding.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/MetadataEncoding.cs new file mode 100644 index 00000000000..042e12cad64 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/MetadataEncoding.cs @@ -0,0 +1,74 @@ +#nullable enable + +using System; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// ECMA-335 II.23.2 compressed unsigned integer encode/decode helpers, used when + /// re-serializing CustomAttribute value blobs by hand. + /// + static class MetadataEncoding + { + public static byte [] EncodeCompressedInteger (int value) + { + if (value < 0) { + throw new ArgumentOutOfRangeException (nameof (value)); + } + if (value <= 0x7F) { + return new [] { (byte) value }; + } + if (value <= 0x3FFF) { + return new [] { + (byte) (0x80 | (value >> 8)), + (byte) (value & 0xFF), + }; + } + if (value <= 0x1FFFFFFF) { + return new [] { + (byte) (0xC0 | (value >> 24)), + (byte) ((value >> 16) & 0xFF), + (byte) ((value >> 8) & 0xFF), + (byte) (value & 0xFF), + }; + } + throw new ArgumentOutOfRangeException (nameof (value), "Value too large to encode as an ECMA-335 compressed integer."); + } + + /// + /// Reads the compressed integer at , returning its width in + /// bytes and (via ) the decoded value. + /// + public static int ReadCompressedInteger (byte [] data, int offset, out int value) + { + if (offset >= data.Length) { + throw new JniRewriteException ("Malformed metadata blob: truncated compressed integer."); + } + + byte first = data [offset]; + if ((first & 0x80) == 0) { + value = first; + return 1; + } + if ((first & 0xC0) == 0x80) { + RequireLength (data, offset, 2); + value = ((first & 0x3F) << 8) | data [offset + 1]; + return 2; + } + if ((first & 0xE0) == 0xC0) { + RequireLength (data, offset, 4); + value = ((first & 0x1F) << 24) | (data [offset + 1] << 16) | (data [offset + 2] << 8) | data [offset + 3]; + return 4; + } + + throw new JniRewriteException ("Malformed metadata blob: invalid compressed integer prefix."); + } + + static void RequireLength (byte [] data, int offset, int width) + { + if (offset + width > data.Length) { + throw new JniRewriteException ("Malformed metadata blob: truncated compressed integer."); + } + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/MetadataRawColumns.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/MetadataRawColumns.cs new file mode 100644 index 00000000000..226ab26ce6b --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/MetadataRawColumns.cs @@ -0,0 +1,30 @@ +#nullable enable + +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// Reads the handful of metadata table columns that does not + /// surface, straight from the table stream. + /// + static class MetadataRawColumns + { + /// + /// The ExportedType.TypeDefId hint column (ECMA-335 II.22.14). It always occupies + /// bytes 4..7 of the row, after the fixed-width Flags column. + /// + public static unsafe int GetExportedTypeDefinitionId (MetadataReader reader, int rowNumber) + { + int rowSize = reader.GetTableRowSize (TableIndex.ExportedType); + int rowOffset = reader.GetTableMetadataOffset (TableIndex.ExportedType) + (rowNumber - 1) * rowSize; + if (rowSize < 8 || rowOffset + rowSize > reader.MetadataLength) { + throw new JniRewriteException ("The ExportedType table extends past the end of the metadata."); + } + + var blob = new BlobReader (reader.MetadataPointer + rowOffset + sizeof (uint), sizeof (uint)); + return blob.ReadInt32 (); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/NativeResourceSectionCopier.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/NativeResourceSectionCopier.cs new file mode 100644 index 00000000000..6c4b8117400 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/NativeResourceSectionCopier.cs @@ -0,0 +1,140 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// Re-emits an assembly's Win32 resource directory (.rsrc) at whatever address the + /// rebuilt PE places it. The directory tree is copied byte-for-byte; only the absolute RVAs + /// stored in each IMAGE_RESOURCE_DATA_ENTRY.OffsetToData are relocated, since every + /// other offset in the format is relative to the start of the directory. + /// + sealed class NativeResourceSectionCopier : ResourceSectionBuilder + { + const int ResourceDirectoryHeaderSize = 16; + const int ResourceDirectoryEntrySize = 8; + const int ResourceDataEntrySize = 16; + const uint HighBit = 0x80000000; + + readonly byte [] section; + readonly int originalRva; + readonly List dataEntryRvaOffsets; + + NativeResourceSectionCopier (byte [] section, int originalRva, List dataEntryRvaOffsets) + { + this.section = section; + this.originalRva = originalRva; + this.dataEntryRvaOffsets = dataEntryRvaOffsets; + } + + /// + /// Returns null when the assembly has no Win32 resources. + /// + public static NativeResourceSectionCopier? TryCreate (PEReader peReader) + { + PEHeader? peHeader = peReader.PEHeaders.PEHeader; + if (peHeader == null) { + return null; + } + + DirectoryEntry directory = peHeader.ResourceTableDirectory; + if (directory.RelativeVirtualAddress == 0 || directory.Size == 0) { + return null; + } + + PEMemoryBlock block = peReader.GetSectionData (directory.RelativeVirtualAddress); + if (block.Length < directory.Size) { + throw new JniRewriteException ("The Win32 resource directory extends past the end of its PE section."); + } + + byte [] section = block.GetReader (0, directory.Size).ReadBytes (directory.Size); + var offsets = new List (); + CollectDataEntryOffsets (section, directory.RelativeVirtualAddress, directoryOffset: 0, depth: 0, offsets, new HashSet ()); + return new NativeResourceSectionCopier (section, directory.RelativeVirtualAddress, offsets); + } + + protected override void Serialize (BlobBuilder builder, SectionLocation location) + { + var relocated = (byte []) section.Clone (); + int delta = location.RelativeVirtualAddress - originalRva; + + foreach (int offset in dataEntryRvaOffsets) { + uint value = ReadUInt32 (relocated, offset); + WriteUInt32 (relocated, offset, unchecked ((uint) ((int) value + delta))); + } + + builder.WriteBytes (relocated); + } + + static void CollectDataEntryOffsets (byte [] section, int originalRva, int directoryOffset, int depth, List offsets, HashSet visited) + { + if (depth > 8) { + throw new JniRewriteException ("The Win32 resource directory nests more deeply than the PE format allows."); + } + if (!visited.Add (directoryOffset)) { + throw new JniRewriteException ("The Win32 resource directory contains a cycle."); + } + RequireRange (section, directoryOffset, ResourceDirectoryHeaderSize); + + int namedEntries = ReadUInt16 (section, directoryOffset + 12); + int idEntries = ReadUInt16 (section, directoryOffset + 14); + int entryOffset = directoryOffset + ResourceDirectoryHeaderSize; + + for (int i = 0; i < namedEntries + idEntries; i++, entryOffset += ResourceDirectoryEntrySize) { + RequireRange (section, entryOffset, ResourceDirectoryEntrySize); + uint offsetToData = ReadUInt32 (section, entryOffset + 4); + + if ((offsetToData & HighBit) != 0) { + CollectDataEntryOffsets (section, originalRva, (int) (offsetToData & ~HighBit), depth + 1, offsets, visited); + continue; + } + + int dataEntryOffset = (int) offsetToData; + RequireRange (section, dataEntryOffset, ResourceDataEntrySize); + RequireDataWithinCopiedSection (section, originalRva, dataEntryOffset); + offsets.Add (dataEntryOffset); + } + } + + /// + /// Validates that an IMAGE_RESOURCE_DATA_ENTRY's data - its absolute RVA plus its + /// size - lies entirely within the resource section this class copied. Without this + /// check a corrupt or crafted data entry could point past the copied bytes and would only + /// be caught (if at all) once something tried to actually read the relocated resource. + /// + static void RequireDataWithinCopiedSection (byte [] section, int originalRva, int dataEntryOffset) + { + uint dataRva = ReadUInt32 (section, dataEntryOffset); + uint dataSize = ReadUInt32 (section, dataEntryOffset + 4); + + long relativeOffset = (long) dataRva - originalRva; + if (relativeOffset < 0 || relativeOffset + dataSize > section.Length) { + throw new JniRewriteException ($"A Win32 resource data entry references data (RVA 0x{dataRva:X}, {dataSize} byte(s)) outside of the copied resource section."); + } + } + + static void RequireRange (byte [] section, int offset, int length) + { + if (offset < 0 || length < 0 || offset + length > section.Length) { + throw new JniRewriteException ("The Win32 resource directory references data outside of the resource section."); + } + } + + static ushort ReadUInt16 (byte [] data, int offset) => (ushort) (data [offset] | (data [offset + 1] << 8)); + + static uint ReadUInt32 (byte [] data, int offset) + => (uint) (data [offset] | (data [offset + 1] << 8) | (data [offset + 2] << 16) | (data [offset + 3] << 24)); + + static void WriteUInt32 (byte [] data, int offset, uint value) + { + data [offset] = (byte) value; + data [offset + 1] = (byte) (value >> 8); + data [offset + 2] = (byte) (value >> 16); + data [offset + 3] = (byte) (value >> 24); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs new file mode 100644 index 00000000000..fcdad86d567 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -0,0 +1,322 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// A parsed R8/ProGuard mapping.txt file, exposing the class, field, and method + /// renames it describes using JNI-style ('/'-separated, '$' for nested classes) names. + /// + sealed class R8Mapping + { + // Original JNI class name -> obfuscated JNI class name. + readonly Dictionary classes = new Dictionary (StringComparer.Ordinal); + + // Original JNI class name -> (original field name -> obfuscated field name). + readonly Dictionary> fields = new Dictionary> (StringComparer.Ordinal); + + // Original JNI class name -> ("name(javaParam,javaParam,...)" -> obfuscated method name). + readonly Dictionary> methods = new Dictionary> (StringComparer.Ordinal); + + public static R8Mapping Load (string path) + { + using var reader = new StreamReader (path); + return Parse (reader); + } + + public static R8Mapping Parse (TextReader reader) + { + var mapping = new R8Mapping (); + string? currentOriginalClass = null; + int lineNumber = 0; + string? line; + + while ((line = reader.ReadLine ()) != null) { + lineNumber++; + + if (line.Length == 0) { + continue; + } + + bool indented = line [0] == ' ' || line [0] == '\t'; + string trimmed = line.Trim (); + if (trimmed.Length == 0 || trimmed [0] == '#') { + // R8 emits indented "# {...}" metadata comments (e.g. inline source position + // info) under a member line; these are not member mappings. + continue; + } + + if (!indented) { + if (!TryParseClassLine (trimmed, out string originalClass, out string obfuscatedClass)) { + throw new FormatException ($"mapping.txt:{lineNumber}: expected a class mapping line ('original -> obfuscated:'), got '{line}'."); + } + + currentOriginalClass = JavaNameToJni (originalClass); + mapping.classes [currentOriginalClass] = JavaNameToJni (obfuscatedClass); + continue; + } + + if (currentOriginalClass == null) { + throw new FormatException ($"mapping.txt:{lineNumber}: member mapping line found before any class mapping line: '{line}'."); + } + + if (!TryParseMemberLine (trimmed, out string memberName, out string []? javaParameterTypes, out string obfuscatedName)) { + throw new FormatException ($"mapping.txt:{lineNumber}: could not parse member mapping line: '{line}'."); + } + + if (javaParameterTypes == null) { + // Field. + if (!mapping.fields.TryGetValue (currentOriginalClass, out var classFields)) { + mapping.fields [currentOriginalClass] = classFields = new Dictionary (StringComparer.Ordinal); + } + classFields [memberName] = obfuscatedName; + } else { + // R8 emits fully-qualified source methods as inline call-frame records beneath + // the destination method. They are retrace metadata, not member mappings for + // the current class, and one source method may appear under many destinations. + if (memberName.IndexOf ('.') >= 0) { + continue; + } + + // Method. + string key = BuildMethodKey (memberName, javaParameterTypes); + if (!mapping.methods.TryGetValue (currentOriginalClass, out var classMethods)) { + mapping.methods [currentOriginalClass] = classMethods = new Dictionary (StringComparer.Ordinal); + } + if (classMethods.TryGetValue (key, out string? existing) && existing != obfuscatedName) { + // An optimized method can be inlined into several surviving methods. R8 + // then emits one retrace record per destination, so there is no single + // runtime name to use for this source member. + classMethods [key] = ""; + continue; + } + if (existing == null) { + classMethods [key] = obfuscatedName; + } + } + } + + return mapping; + } + + /// + /// Builds the lookup key used for methods: the JNI/Java member name plus its + /// parameter types (in Java source form, e.g. "int", "android.os.Bundle", "java.lang.String[]"). + /// + internal static string BuildMethodKey (string javaMethodName, IReadOnlyList javaParameterTypes) + => javaMethodName + "(" + string.Join (",", javaParameterTypes) + ")"; + + /// + /// Translates a JNI member name (as it appears in a RegisterAttribute / encoded JniPeerMembers + /// string) to the corresponding name used in a mapping.txt member line. + /// + internal static string JniMemberNameToMappingName (string jniMemberName) + => jniMemberName switch { + ".ctor" => "", + ".cctor" => "", + _ => jniMemberName, + }; + + public bool TryGetRenamedClass (string originalJniClassName, out string obfuscatedJniClassName) + { + if (classes.TryGetValue (originalJniClassName, out string? renamed)) { + obfuscatedJniClassName = renamed; + return true; + } + obfuscatedJniClassName = ""; + return false; + } + + public bool TryGetRenamedField (string owningJniClassName, string originalFieldName, out string obfuscatedFieldName) + { + obfuscatedFieldName = ""; + if (!fields.TryGetValue (owningJniClassName, out var classFields) || + !classFields.TryGetValue (originalFieldName, out string? renamed)) { + return false; + } + obfuscatedFieldName = renamed; + return true; + } + + public bool TryGetRenamedMethod (string owningJniClassName, string javaMethodName, IReadOnlyList javaParameterTypes, out string obfuscatedMethodName) + { + obfuscatedMethodName = ""; + if (!methods.TryGetValue (owningJniClassName, out var classMethods)) { + return false; + } + if (!classMethods.TryGetValue (BuildMethodKey (javaMethodName, javaParameterTypes), out string? renamed) || renamed.Length == 0) { + return false; + } + obfuscatedMethodName = renamed; + return true; + } + + /// + /// Best-effort lookup used when only a member name (no parameter types) is available: + /// succeeds only if the name is unambiguous (a single overload) within the class. + /// + public bool TryGetRenamedMethodByNameOnly (string owningJniClassName, string javaMethodName, out string obfuscatedMethodName) + { + obfuscatedMethodName = ""; + if (!methods.TryGetValue (owningJniClassName, out var classMethods)) { + return false; + } + + string? match = null; + string prefix = javaMethodName + "("; + foreach (var kvp in classMethods) { + if (!kvp.Key.StartsWith (prefix, StringComparison.Ordinal)) { + continue; + } + if (kvp.Value.Length == 0) { + return false; + } + if (match != null && match != kvp.Value) { + return false; // Ambiguous - multiple differently-renamed overloads. + } + match = kvp.Value; + } + + if (match == null) { + return false; + } + + obfuscatedMethodName = match; + return true; + } + + static string JavaNameToJni (string javaBinaryName) => javaBinaryName.Replace ('.', '/'); + + static bool TryParseClassLine (string trimmed, out string originalClass, out string obfuscatedClass) + { + originalClass = ""; + obfuscatedClass = ""; + + if (!trimmed.EndsWith (":", StringComparison.Ordinal)) { + return false; + } + + const string arrow = " -> "; + int arrowIndex = trimmed.IndexOf (arrow, StringComparison.Ordinal); + if (arrowIndex < 0) { + return false; + } + + originalClass = trimmed.Substring (0, arrowIndex); + obfuscatedClass = trimmed.Substring (arrowIndex + arrow.Length, trimmed.Length - arrowIndex - arrow.Length - 1); + return originalClass.Length > 0 && obfuscatedClass.Length > 0; + } + + static bool TryParseMemberLine (string trimmed, out string name, out string []? javaParameterTypes, out string obfuscatedName) + { + name = ""; + javaParameterTypes = null; + obfuscatedName = ""; + + const string arrow = " -> "; + int arrowIndex = trimmed.LastIndexOf (arrow, StringComparison.Ordinal); + if (arrowIndex < 0) { + return false; + } + + string left = trimmed.Substring (0, arrowIndex); + obfuscatedName = trimmed.Substring (arrowIndex + arrow.Length).Trim (); + if (obfuscatedName.Length == 0) { + return false; + } + + left = StripLeadingLineRange (left); + left = StripTrailingLineRange (left); + + int parenOpen = left.IndexOf ('('); + if (parenOpen >= 0 && left.EndsWith (")", StringComparison.Ordinal)) { + string beforeParen = left.Substring (0, parenOpen); + string paramList = left.Substring (parenOpen + 1, left.Length - parenOpen - 2); + + int lastSpace = beforeParen.LastIndexOf (' '); + if (lastSpace < 0) { + return false; + } + + name = beforeParen.Substring (lastSpace + 1); + javaParameterTypes = paramList.Length == 0 + ? Array.Empty () + : paramList.Split (','); + return name.Length > 0; + } else { + int lastSpace = left.LastIndexOf (' '); + if (lastSpace < 0) { + return false; + } + + name = left.Substring (lastSpace + 1); + javaParameterTypes = null; + return name.Length > 0; + } + } + + /// + /// Strips a leading "startLine:endLine:" prefix used on some method mapping lines, e.g. + /// "4:10:void onCreate(...)" -> "void onCreate(...)". + /// + static string StripLeadingLineRange (string s) + { + int i = 0; + while (i < s.Length && char.IsDigit (s [i])) { + i++; + } + if (i == 0 || i >= s.Length || s [i] != ':') { + return s; + } + + int secondStart = i + 1; + int j = secondStart; + while (j < s.Length && char.IsDigit (s [j])) { + j++; + } + if (j == secondStart || j >= s.Length || s [j] != ':') { + return s; + } + + return s.Substring (j + 1); + } + + /// + /// Strips a trailing ":originalStartLine[:originalEndLine]" suffix used on some method + /// mapping lines, e.g. "void onCreate(...):23:29" -> "void onCreate(...)" (two original + /// line numbers) and "void run(...):2" -> "void run(...)" (a single original line number, + /// emitted when the original range collapses to one line). + /// + static string StripTrailingLineRange (string s) + { + if (s.Length == 0 || !char.IsDigit (s [s.Length - 1])) { + return s; + } + + int i = s.Length - 1; + while (i >= 0 && char.IsDigit (s [i])) { + i--; + } + if (i < 0 || s [i] != ':') { + return s; + } + + int lastColon = i; + + // Look for a second, earlier number - ":originalStartLine:originalEndLine". + int j = i - 1; + while (j >= 0 && char.IsDigit (s [j])) { + j--; + } + if (j >= 0 && j != i - 1 && s [j] == ':') { + return s.Substring (0, j); + } + + // Only one trailing number - ":originalStartLine". + return s.Substring (0, lastColon); + } + } +} From f442d93bd869bbc321c62e7eb7c909cecd76d900 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 31 Aug 2026 11:03:44 +0200 Subject: [PATCH 02/14] Prototype two-pass R8 JNI rewriting Run a naming-only R8 pass before managed optimization, stage rewritten inputs for ILLink and ILC, and reuse the mapping during final Java shrinking. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 39 ++++++- ...id.Sdk.TypeMap.Trimmable.NativeAOT.targets | 34 ++++++ ...soft.Android.Sdk.TypeMap.Trimmable.targets | 100 +++++++++++++++++ ...teCompressedAssembliesNativeSourceFiles.cs | 67 ------------ .../Tasks/GenerateProguardConfiguration.cs | 33 +++++- src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 103 ++++++++++++++---- .../Tasks/RewriteJniNamesForR8.cs | 24 +++- .../Utilities/JniRemapping/R8MappingTests.cs | 15 +++ .../Utilities/JniRemapping/R8Mapping.cs | 35 +++++- .../Xamarin.Android.D8.targets | 2 + 10 files changed, 349 insertions(+), 103 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index b12f80fdc8f..1cb3330201e 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets @@ -5,10 +5,11 @@ <_TrimmableRuntimeProviderJavaName Condition=" '$(_TrimmableRuntimeProviderJavaName)' == '' ">mono.MonoRuntimeProvider + <_CompileToDalvikDependsOnTargets>$(_CompileToDalvikDependsOnTargets);_GenerateProguardConfiguration - <_GenerateProguardAfterTargets Condition=" '$(_GenerateProguardAfterTargets)' == '' ">ILLink + <_GenerateProguardAfterTargets Condition=" '$(_GenerateProguardAfterTargets)' == '' ">_RunILLink @@ -23,6 +24,32 @@ + + + <_AndroidR8JniRewrittenAssemblyDirectory>$(IntermediateOutputPath)r8-jni-rewritten/ + + + <_AndroidR8JniOriginalManagedAssembly Include="@(ManagedAssemblyToLink)" /> + + + + + + + + + + + + + + OutputFile="$(_ProguardProjectConfiguration)" + R8MappingFile="$(_AndroidR8JniSeedMapping)" /> <_PostTrimTrimmableTypeMapInputAssemblies Remove="@(_PostTrimTrimmableTypeMapInputAssemblies)" /> @@ -70,7 +99,7 @@ a real output but leaves the stamp, invalidate the stamp so the producer restores the complete post-trim output set instead of letting _GenerateJavaStubs create an empty acw-map placeholder. --> + Condition=" '$(AndroidTypeMapImplementation)' == 'trimmable' and '$(PublishTrimmed)' == 'true' and '$(_ComputeFilesToPublishForRuntimeIdentifiers)' != 'true' and '$(_AndroidEnableR8JniNameRewriting)' != 'true' "> <_ExpectedPostTrimJavaFile Remove="@(_ExpectedPostTrimJavaFile)" /> <_ActualPostTrimJavaFile Remove="@(_ActualPostTrimJavaFile)" /> @@ -99,7 +128,7 @@ true <_TrimmableNativeAotProguardConfigurationInputsStamp>$(_AndroidStampDirectory)_GenerateTrimmableTypeMapProguardConfiguration.inputs <_CompileToDalvikDependsOnTargets>$(_CompileToDalvikDependsOnTargets);_GenerateTrimmableTypeMapProguardConfiguration + <_AndroidBuildRuntimeIdentifiersInParallel Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">false <_PreTrimTypeMapAcwMapOutputFile Condition=" '$(_AndroidRuntime)' != 'CoreCLR' or '$(PublishTrimmed)' != 'true' ">$(IntermediateOutputPath)acw-map.txt <_PreTrimTypeMapApplicationRegistrationOutputFile Condition=" '$(_AndroidRuntime)' != 'CoreCLR' or '$(PublishTrimmed)' != 'true' ">$(IntermediateOutputPath)android/src/net/dot/android/ApplicationRegistration.java + <_PreTrimTypeMapAcwMapOutputFile Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)acw-map.txt + <_PreTrimTypeMapApplicationRegistrationOutputFile Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)android/src/net/dot/android/ApplicationRegistration.java <_TrimmableTypeMapOutputStamp>$(_TypeMapOutputDirectory)_GenerateTrimmableTypeMap.stamp + <_AndroidR8JniSeedDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_TypeMapBaseOutputDir)r8-jni-seed/ + <_AndroidR8JniSeedMapping Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)mapping.txt + <_AndroidR8JniSeedApplicationConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)acw-keep.cfg + <_AndroidR8JniSeedXamarinConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)xamarin.cfg + <_AndroidR8JniSeedAaptConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)aapt_rules.txt + <_AndroidR8JniSeedJavaClassDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)classes/ + <_AndroidR8JniSeedJavaStamp Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)compile-java.stamp <_TrimmableRemoveRegisterFlag>$(_AndroidStampDirectory)_RemoveRegisterAttribute.stamp <_TrimmableRemoveRegisterTarget Condition=" '$(_AndroidRuntime)' == 'CoreCLR' ">_RemoveRegisterAttributeCoreClr <_TrimmableRemoveRegisterTarget Condition=" '$(_AndroidRuntime)' == 'NativeAOT' ">_RemoveRegisterAttributeNativeAot @@ -54,6 +66,7 @@ Both are touched only when their producing target actually runs, so _GenerateJavaStubs stays incremental while still reacting to post-trim JCW regeneration. --> <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' and '$(_AndroidRuntime)' == 'CoreCLR' and '$(PublishTrimmed)' == 'true' ">$(_PostTrimTrimmableTypeMapJavaStamp) + <_TrimmableJavaSourceStamp Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_TrimmableTypeMapOutputStamp) <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' ">$(_TrimmableTypeMapOutputStamp) @@ -67,6 +80,93 @@ + + + <_AndroidR8JniSeedJavaSource Include="$(_TypeMapJavaOutputDirectory)/**/*.java" /> + <_AndroidR8JniSeedJavaSource Include="$(IntermediateOutputPath)android/src/**/*.java" /> + + + + + + + + + + + + + + + + <_AndroidR8JniSeedClassFile Include="$(_AndroidR8JniSeedJavaClassDirectory)**\*.class" /> + <_AndroidR8JniSeedProguardConfiguration Include="@(_ProguardConfiguration)" /> + <_AndroidR8JniSeedProguardConfiguration Remove="$(IntermediateOutputPath)proguard\proguard_xamarin.cfg;$(_ProguardProjectConfiguration);$(IntermediateOutputPath)proguard\proguard_project_primary.cfg" /> + <_AndroidR8JniSeedProguardConfiguration Include="$(_AndroidR8JniSeedAaptConfiguration)" Condition="Exists('$(_AndroidR8JniSeedAaptConfiguration)')" /> + <_AndroidR8JniSeedMapDiagnostics Condition=" '$(AndroidR8IgnoreWarnings)' == 'true' " Include="warning" To="info" /> + + + + + + + + + + + + + + + Condition=" '$(AndroidTypeMapImplementation)' == 'trimmable' and '$(PublishTrimmed)' == 'true' and '$(_ComputeFilesToPublishForRuntimeIdentifiers)' != 'true' "> <_ExpectedPostTrimJavaFile Remove="@(_ExpectedPostTrimJavaFile)" /> <_ActualPostTrimJavaFile Remove="@(_ActualPostTrimJavaFile)" /> @@ -128,7 +128,7 @@ = 2 && args.FixedArguments[0].Value is string jname && - args.FixedArguments[1].Value is string) { + args.FixedArguments[1].Value is string jniDescriptor) { if (jname == ".ctor" || jname == "") { writer.WriteLine (" (...);"); } else { bool wroteOriginalName = false; - if (r8Mapping != null) { - foreach (string originalName in r8Mapping.GetOriginalMethodNames (originalJniClassName, jname)) { - writer.WriteLine ($" *** {originalName}(...);"); - wroteOriginalName = true; - } + if (r8Mapping != null && + TryGetOriginalMethodName (originalJniClassName, jname, jniDescriptor, out string originalName)) { + writer.WriteLine ($" *** {originalName}(...);"); + wroteOriginalName = true; } if (!wroteOriginalName) { writer.WriteLine ($" *** {jname}(...);"); @@ -154,5 +153,22 @@ args.FixedArguments[0].Value is string jname && } } } + + string? GetOriginalClassName (string rewrittenJniName) + => r8Mapping?.TryGetOriginalClass (rewrittenJniName, out string originalJniName) == true ? originalJniName : null; + + bool TryGetOriginalMethodName (string originalJniClassName, string rewrittenMethodName, string rewrittenDescriptor, out string originalMethodName) + { + JniDescriptorText.TryRewriteDescriptor (rewrittenDescriptor, GetOriginalClassName, out string originalDescriptor); + if (r8Mapping != null && JniDescriptorText.TryParseMethodDescriptor (originalDescriptor, out var originalParameterTypes, out _)) { + return r8Mapping.TryGetOriginalMethodName ( + originalJniClassName, + rewrittenMethodName, + originalParameterTypes.ConvertAll (JniDescriptorText.JniTypeTokenToJavaSource), + out originalMethodName); + } + originalMethodName = ""; + return false; + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs index 1f00ea90e55..f6a09d77186 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs @@ -35,6 +35,10 @@ public void LooksUpOriginalClassAndMethodNames () Assert.IsTrue (mapping.TryGetOriginalClass ("a/b/C", out string originalClass)); Assert.AreEqual ("acme/orig/MyView", originalClass); CollectionAssert.AreEquivalent (new [] { "first", "second" }, mapping.GetOriginalMethodNames (originalClass, "x")); + Assert.IsTrue (mapping.TryGetOriginalMethodName (originalClass, "x", new [] { "int" }, out string first)); + Assert.AreEqual ("first", first); + Assert.IsTrue (mapping.TryGetOriginalMethodName (originalClass, "x", Array.Empty (), out string second)); + Assert.AreEqual ("second", second); Assert.IsFalse (mapping.TryGetOriginalClass ("a/b/Missing", out _)); } @@ -42,9 +46,12 @@ public void LooksUpOriginalClassAndMethodNames () public void ParsesMethodMappingWithParameters () { var mapping = R8Mapping.Parse (new StringReader ( - "acme.orig.MyView -> a.b.C:\n" + - " void onClick(android.view.View) -> a\n" + - " void onClick(android.view.View,int) -> b\n")); + """ + acme.orig.MyView -> a.b.C: + void onClick(android.view.View) -> a + void onClick(android.view.View,int) -> b + + """)); Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View" }, out string renamed1)); Assert.AreEqual ("a", renamed1); diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index b69701b2668..966706aab8c 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -163,6 +163,30 @@ public IEnumerable GetOriginalMethodNames (string originalJniClassName, } } + public bool TryGetOriginalMethodName (string originalJniClassName, string obfuscatedMethodName, IReadOnlyList originalJavaParameterTypes, out string originalMethodName) + { + originalMethodName = ""; + if (!methods.TryGetValue (originalJniClassName, out var classMethods)) { + return false; + } + + string parameters = "(" + string.Join (",", originalJavaParameterTypes) + ")"; + foreach (var entry in classMethods) { + if (!String.Equals (entry.Value, obfuscatedMethodName, StringComparison.Ordinal) || + !entry.Key.EndsWith (parameters, StringComparison.Ordinal)) { + continue; + } + int parameterStart = entry.Key.Length - parameters.Length; + string candidate = entry.Key.Substring (0, parameterStart); + if (originalMethodName.Length != 0 && !String.Equals (originalMethodName, candidate, StringComparison.Ordinal)) { + originalMethodName = ""; + return false; + } + originalMethodName = candidate; + } + return originalMethodName.Length != 0; + } + public bool TryGetRenamedField (string owningJniClassName, string originalFieldName, out string obfuscatedFieldName) { obfuscatedFieldName = ""; From eacacea2582c8c145b4c0322784a7bb49ef65085 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 31 Aug 2026 12:22:33 +0200 Subject: [PATCH 04/14] Preserve NativeAOT runtime JNI names Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Resources/proguard_trimmable_nativeaot.cfg | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Xamarin.Android.Build.Tasks/Resources/proguard_trimmable_nativeaot.cfg b/src/Xamarin.Android.Build.Tasks/Resources/proguard_trimmable_nativeaot.cfg index c12ac57637c..f4bd50cfd2c 100644 --- a/src/Xamarin.Android.Build.Tasks/Resources/proguard_trimmable_nativeaot.cfg +++ b/src/Xamarin.Android.Build.Tasks/Resources/proguard_trimmable_nativeaot.cfg @@ -4,6 +4,8 @@ -keep class net.dot.jni.** { *; (...); } -keep class net.dot.android.crypto.** { *; (...); } +# The prebuilt native runtime resolves this class and its fields by JNI name. +-keep class mono.android.Runtime { *; } # NativeAOT resolves these interface methods through JNI during startup. -keep class mono.android.IGCUserPeer { *; } From e7213d3a13ca1bad2903be10afdc37df86ac3642 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 31 Aug 2026 13:19:42 +0200 Subject: [PATCH 05/14] Rewrite trimmable typemap JNI metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JniRemapping/JniAssemblyRewriterTests.cs | 47 ++++++++++++- .../JniRemapping/JniFixtureBuilder.cs | 62 +++++++++++++++++ .../CustomAttributeStringRewriter.cs | 69 +++++++++++++++++-- .../JniRemapping/JniRewritePlanner.cs | 62 +++++++++++++++++ .../Utilities/MetadataExtensions.cs | 16 ++++- 5 files changed, 246 insertions(+), 10 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs index 66926cfa312..4fa3d72bd85 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs @@ -30,11 +30,11 @@ static JniRewriteResult Rewrite (byte [] sourceImage, R8Mapping mapping) static R8Mapping Mapping (string text) => R8Mapping.Parse (new StringReader (text)); - static IReadOnlyList AttributeStringArgs (MetadataReader reader, CustomAttributeHandleCollection attributes, MethodDefinitionHandle ctor) + static IReadOnlyList AttributeStringArgs (MetadataReader reader, CustomAttributeHandleCollection attributes, EntityHandle ctor) { foreach (CustomAttributeHandle handle in attributes) { CustomAttribute attribute = reader.GetCustomAttribute (handle); - if (attribute.Constructor.Kind != HandleKind.MethodDefinition || (MethodDefinitionHandle) attribute.Constructor != ctor) { + if (attribute.Constructor != ctor) { continue; } @@ -48,12 +48,30 @@ static IReadOnlyList AttributeStringArgs (MetadataReader reader, CustomA return Array.Empty (); } - static string FirstAttributeStringArg (MetadataReader reader, CustomAttributeHandleCollection attributes, MethodDefinitionHandle ctor) + static string FirstAttributeStringArg (MetadataReader reader, CustomAttributeHandleCollection attributes, EntityHandle ctor) { var args = AttributeStringArgs (reader, attributes, ctor); return args.Count > 0 ? args [0] : null; } + static IReadOnlyList AttributeStringArrayArg (MetadataReader reader, CustomAttributeHandleCollection attributes, EntityHandle ctor) + { + foreach (CustomAttributeHandle handle in attributes) { + CustomAttribute attribute = reader.GetCustomAttribute (handle); + if (attribute.Constructor != ctor) { + continue; + } + + var decoded = attribute.DecodeValue (Xamarin.Android.Tasks.DummyCustomAttributeProvider.Instance); + var result = new List (); + foreach (var element in (ImmutableArray>) decoded.FixedArguments [0].Value) { + result.Add ((string) element.Value); + } + return result; + } + return []; + } + /// /// Collects the ldstr operand offsets and the strings they load, so a rebuilt body /// can be compared against the source instruction-for-instruction. @@ -216,6 +234,29 @@ public void RewritesAttributesAndLoadedStrings () CollectionAssert.AreEqual (new [] { "b:()V:n_Run" }, ValuesOf (LoadedStrings (peReader, reader, run))); } + [Test] + public void RewritesTrimmableTypeMapKeysAndAliases () + { + var fixture = new JniFixtureBuilder (); + fixture.Metadata.AddCustomAttribute (EntityHandle.AssemblyDefinition, fixture.TypeMapCtor3, + fixture.AttributeBlob ("acme/orig/MyView[1]", "Acme.Proxy, Fixture", "Acme.Target, Fixture")); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + TypeDefinitionHandle aliasHolder = fixture.AddType ("Acme", "AliasHolder", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (aliasHolder, fixture.JavaPeerAliasesCtor1, + fixture.StringArrayAttributeBlob ("acme/orig/MyView[0]", "acme/orig/MyView[1]", "unmapped/Type[0]")); + + JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ("acme.orig.MyView -> a.b.C:\n")); + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + + CollectionAssert.AreEqual (new [] { "a/b/C[1]", "Acme.Proxy, Fixture", "Acme.Target, Fixture" }, + AttributeStringArgs (reader, reader.GetAssemblyDefinition ().GetCustomAttributes (), fixture.TypeMapCtor3)); + CollectionAssert.AreEqual (new [] { "a/b/C[0]", "a/b/C[1]", "unmapped/Type[0]" }, + AttributeStringArrayArg (reader, reader.GetTypeDefinition (aliasHolder).GetCustomAttributes (), fixture.JavaPeerAliasesCtor1)); + } + static List ValuesOf (List> pairs) { var values = new List (pairs.Count); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniFixtureBuilder.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniFixtureBuilder.cs index b081a4611ed..2ef001cf079 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniFixtureBuilder.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniFixtureBuilder.cs @@ -18,6 +18,7 @@ class JniFixtureBuilder public const string RegisterAttributeNamespace = "Android.Runtime"; public const string RegisterAttributeName = "RegisterAttribute"; public const string JavaInteropNamespace = "Java.Interop"; + public const string RuntimeInteropServicesNamespace = "System.Runtime.InteropServices"; public const string PrivateImplementationDetails = ""; public MetadataBuilder Metadata { get; } = new MetadataBuilder (); @@ -42,6 +43,8 @@ class JniFixtureBuilder public MethodDefinitionHandle JniTypeSignatureCtor1 { get; } public MethodDefinitionHandle JniMethodSignatureCtor2 { get; } public MethodDefinitionHandle JniConstructorSignatureCtor1 { get; } + public MethodDefinitionHandle JavaPeerAliasesCtor1 { get; } + public MemberReferenceHandle TypeMapCtor3 { get; } readonly MethodBodyStreamEncoder bodyEncoder; TypeDefinitionHandle privateImplementationDetails; @@ -86,6 +89,13 @@ public JniFixtureBuilder () methodStart = NextMethodRid; JniConstructorSignatureCtor1 = AddAttributeCtor (1); AddType (JavaInteropNamespace, "JniConstructorSignatureAttribute", fieldStart, methodStart); + + fieldStart = NextFieldRid; + methodStart = NextMethodRid; + JavaPeerAliasesCtor1 = AddStringArrayAttributeCtor (); + AddType (JavaInteropNamespace, "JavaPeerAliasesAttribute", fieldStart, methodStart); + + TypeMapCtor3 = AddGenericStringAttributeCtor (RuntimeInteropServicesNamespace, "TypeMapAttribute`1", 3); } public int NextFieldRid => Metadata.GetRowCount (TableIndex.Field) + 1; @@ -132,6 +142,46 @@ MethodDefinitionHandle AddAttributeCtor (int argCount) MetadataTokens.ParameterHandle (Metadata.GetRowCount (TableIndex.Param) + 1)); } + MethodDefinitionHandle AddStringArrayAttributeCtor () + { + var signature = new BlobBuilder (); + new BlobEncoder (signature).MethodSignature (isInstanceMethod: true) + .Parameters (1, out ReturnTypeEncoder returnType, out ParametersEncoder parameters); + returnType.Void (); + parameters.AddParameter ().Type ().SZArray ().String (); + + return Metadata.AddMethodDefinition ( + MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName, + MethodImplAttributes.IL, + Metadata.GetOrAddString (".ctor"), + Metadata.GetOrAddBlob (signature), + EmitReturnOnlyBody (), + MetadataTokens.ParameterHandle (Metadata.GetRowCount (TableIndex.Param) + 1)); + } + + MemberReferenceHandle AddGenericStringAttributeCtor (string ns, string name, int argCount) + { + TypeReferenceHandle openType = Metadata.AddTypeReference (CoreLibraryReference, + Metadata.GetOrAddString (ns), Metadata.GetOrAddString (name)); + var typeSpec = new BlobBuilder (); + typeSpec.WriteByte ((byte) SignatureTypeCode.GenericTypeInstance); + typeSpec.WriteByte ((byte) SignatureTypeKind.Class); + typeSpec.WriteCompressedInteger (CodedIndex.TypeDefOrRefOrSpec (openType)); + typeSpec.WriteCompressedInteger (1); + typeSpec.WriteByte ((byte) SignatureTypeKind.Class); + typeSpec.WriteCompressedInteger (CodedIndex.TypeDefOrRefOrSpec (ValueTypeReference)); + TypeSpecificationHandle closedType = Metadata.AddTypeSpecification (Metadata.GetOrAddBlob (typeSpec)); + + var signature = new BlobBuilder (); + new BlobEncoder (signature).MethodSignature (isInstanceMethod: true) + .Parameters (argCount, out ReturnTypeEncoder returnType, out ParametersEncoder parameters); + returnType.Void (); + for (int i = 0; i < argCount; i++) { + parameters.AddParameter ().Type ().String (); + } + return Metadata.AddMemberReference (closedType, Metadata.GetOrAddString (".ctor"), Metadata.GetOrAddBlob (signature)); + } + static BlobBuilder BuildVoidNoArgsSignature () { var signature = new BlobBuilder (); @@ -174,6 +224,18 @@ public BlobHandle AttributeBlob (params string [] fixedStringArgs) return Metadata.GetOrAddBlob (blob); } + public BlobHandle StringArrayAttributeBlob (params string [] values) + { + var blob = new BlobBuilder (); + blob.WriteUInt16 (0x0001); + blob.WriteInt32 (values.Length); + foreach (string value in values) { + blob.WriteSerializedString (value); + } + blob.WriteUInt16 (0x0000); + return Metadata.GetOrAddBlob (blob); + } + public void AddEmbeddedResource (string name, byte [] content) { ManagedResources.Align (8); diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs index 4d9ae49f628..060c81fe201 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs @@ -13,16 +13,18 @@ namespace Xamarin.Android.Tasks.JniRemapping /// /// This only supports (and only needs to support) the attributes this task rewrites - /// Android.Runtime.RegisterAttribute, Java.Interop.JniTypeSignatureAttribute, - /// Java.Interop.JniMethodSignatureAttribute, and Java.Interop.JniConstructorSignatureAttribute - /// - whose constructors take only string fixed arguments. + /// Java.Interop.JniMethodSignatureAttribute, Java.Interop.JniConstructorSignatureAttribute, + /// System.Runtime.InteropServices.TypeMapAttribute, and Java.Interop.JavaPeerAliasesAttribute. /// static class CustomAttributeStringRewriter { /// - /// Rewrites the fixed string arguments of a CustomAttribute value blob whose constructor - /// takes exactly string arguments. + /// Rewrites the leading fixed string arguments of a CustomAttribute value blob. + /// /// is invoked with the (0-based) argument index and its original value, and should return /// the replacement value, or null if that argument should be left unchanged. + /// Bytes following those leading strings are copied verbatim, allowing a string prefix to + /// be changed even when later fixed arguments have other SerString-encoded types. /// /// Returns null if no argument was rewritten (i.e. the blob does not need to change). /// @@ -69,10 +71,67 @@ static class CustomAttributeStringRewriter } } - // Tail: NumNamed (uint16, not compressed) + NamedArg*, copied verbatim. + // Remaining fixed arguments and NumNamed/NamedArg tail, copied verbatim. ms.Write (originalContent, pos, originalContent.Length - pos); return changed ? ms.ToArray () : null; } + + /// + /// Rewrites the elements of the first fixed argument when it is a string[]. + /// All bytes following the array are copied verbatim. + /// + public static byte []? TryRewriteStringArray (byte [] originalContent, Func rewriteElement) + { + if (originalContent.Length < 6) { + throw new JniRewriteException ("Malformed custom attribute value blob: missing string array length."); + } + + using var ms = new MemoryStream (originalContent.Length); + ms.Write (originalContent, 0, 6); // Prolog (uint16) and array length (int32), verbatim. + int count = BitConverter.ToInt32 (originalContent, 2); + if (count < -1) { + throw new JniRewriteException ($"Malformed custom attribute value blob: invalid string array length {count}."); + } + if (count == -1) { + return null; + } + + int pos = 6; + bool changed = false; + for (int i = 0; i < count; i++) { + if (pos >= originalContent.Length) { + throw new JniRewriteException ("Malformed custom attribute value blob: ran out of bytes while reading string array."); + } + + int elementStart = pos; + if (originalContent [pos] == 0xFF) { + pos++; + ms.WriteByte (0xFF); + continue; + } + + int prefixWidth = MetadataEncoding.ReadCompressedInteger (originalContent, pos, out int strByteLength); + pos += prefixWidth + strByteLength; + if (pos > originalContent.Length) { + throw new JniRewriteException ("Malformed custom attribute value blob: string array element extends past the end of the blob."); + } + + string value = Encoding.UTF8.GetString (originalContent, elementStart + prefixWidth, strByteLength); + string? newValue = rewriteElement (value); + if (newValue != null && !string.Equals (newValue, value, StringComparison.Ordinal)) { + changed = true; + byte [] utf8 = Encoding.UTF8.GetBytes (newValue); + byte [] prefix = MetadataEncoding.EncodeCompressedInteger (utf8.Length); + ms.Write (prefix, 0, prefix.Length); + ms.Write (utf8, 0, utf8.Length); + } else { + ms.Write (originalContent, elementStart, pos - elementStart); + } + } + + ms.Write (originalContent, pos, originalContent.Length - pos); + return changed ? ms.ToArray () : null; + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs index f8c5f221079..3efadf26f52 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs @@ -21,6 +21,8 @@ sealed class JniRewritePlanner const string JniTypeSignatureAttributeFullName = "Java.Interop.JniTypeSignatureAttribute"; const string JniMethodSignatureAttributeFullName = "Java.Interop.JniMethodSignatureAttribute"; const string JniConstructorSignatureAttributeFullName = "Java.Interop.JniConstructorSignatureAttribute"; + const string JavaPeerAliasesAttributeFullName = "Java.Interop.JavaPeerAliasesAttribute"; + const string TypeMapAttributeFullName = "System.Runtime.InteropServices.TypeMapAttribute`1"; const string JavaPeerProxyNamespace = "Java.Interop"; const string JavaPeerProxyName = "JavaPeerProxy"; @@ -69,6 +71,7 @@ public JniRewritePlan CreatePlan () { var plan = new JniRewritePlan (); + PlanAssemblyAttributes (plan); foreach (TypeDefinitionHandle typeHandle in reader.TypeDefinitions) { PlanType (plan, typeHandle); } @@ -82,6 +85,7 @@ void PlanType (JniRewritePlan plan, TypeDefinitionHandle typeHandle) TypeDefinition typeDef = reader.GetTypeDefinition (typeHandle); string? ownerJniName = ResolveOwnerJniName (typeHandle); + PlanJavaPeerAliasesAttributes (plan, typeDef.GetCustomAttributes ()); PlanTypeLevelAttributes (plan, typeDef, ownerJniName); foreach (MethodDefinitionHandle methodHandle in typeDef.GetMethods ()) { @@ -102,6 +106,64 @@ void PlanType (JniRewritePlan plan, TypeDefinitionHandle typeHandle) } } + void PlanAssemblyAttributes (JniRewritePlan plan) + { + foreach (CustomAttributeHandle caHandle in reader.GetAssemblyDefinition ().GetCustomAttributes ()) { + CustomAttribute ca = reader.GetCustomAttribute (caHandle); + if (reader.GetCustomAttributeFullName (ca, log) != TypeMapAttributeFullName) { + continue; + } + + // TypeMapAttribute's first argument is the JNI map key. Its following + // System.Type arguments are also SerStrings, but must remain unchanged. + PlanCustomAttributeRewrite (plan, caHandle, ca, fixedArgCount: 1, + (i, value) => value != null ? TryRewriteTypeMapKey (value) : null); + } + } + + void PlanJavaPeerAliasesAttributes (JniRewritePlan plan, CustomAttributeHandleCollection attributes) + { + foreach (CustomAttributeHandle caHandle in attributes) { + CustomAttribute ca = reader.GetCustomAttribute (caHandle); + if (reader.GetCustomAttributeFullName (ca, log) != JavaPeerAliasesAttributeFullName) { + continue; + } + + BlobReader blobReader = reader.GetBlobReader (ca.Value); + byte [] originalContent = blobReader.ReadBytes (blobReader.Length); + byte []? newContent = CustomAttributeStringRewriter.TryRewriteStringArray (originalContent, TryRewriteTypeMapKey); + if (newContent != null) { + plan.AddCustomAttributeBlob (caHandle, newContent); + } + } + } + + string? TryRewriteTypeMapKey (string value) + { + int suffixStart = value.LastIndexOf ('['); + string suffix = ""; + string jniName = value; + if (suffixStart > 0 && value [value.Length - 1] == ']' && IsDecimalIndex (value, suffixStart + 1, value.Length - 1)) { + suffix = value.Substring (suffixStart); + jniName = value.Substring (0, suffixStart); + } + + return mapping.TryGetRenamedClass (jniName, out string renamed) ? renamed + suffix : null; + } + + static bool IsDecimalIndex (string value, int start, int end) + { + if (start == end) { + return false; + } + for (int i = start; i < end; i++) { + if (value [i] < '0' || value [i] > '9') { + return false; + } + } + return true; + } + /// /// Resolves the JNI class name that "owns" a type: its own Register/JniTypeSignature /// argument, the JNI name a generated JavaPeerProxy passes to its base constructor, diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/MetadataExtensions.cs b/src/Xamarin.Android.Build.Tasks/Utilities/MetadataExtensions.cs index 75427f3aa2a..cdea0e8e457 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/MetadataExtensions.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/MetadataExtensions.cs @@ -24,9 +24,21 @@ public static class MetadataExtensions var type = reader.GetTypeSpecification ((TypeSpecificationHandle)ctor.Parent); BlobReader blobReader = reader.GetBlobReader (type.Signature); SignatureTypeCode typeCode = blobReader.ReadSignatureTypeCode (); + if (typeCode != SignatureTypeCode.GenericTypeInstance) { + log.LogDebugMessage ($"Unsupported TypeSpecification signature: {typeCode}"); + return null; + } + blobReader.ReadByte (); // SignatureTypeKind.Class or SignatureTypeKind.ValueType. EntityHandle typeHandle = blobReader.ReadTypeHandle (); - TypeReference typeRef = reader.GetTypeReference ((TypeReferenceHandle)typeHandle); - return reader.GetString (typeRef.Namespace) + "." + reader.GetString (typeRef.Name); + if (typeHandle.Kind == HandleKind.TypeReference) { + TypeReference typeRef = reader.GetTypeReference ((TypeReferenceHandle)typeHandle); + return reader.GetString (typeRef.Namespace) + "." + reader.GetString (typeRef.Name); + } else if (typeHandle.Kind == HandleKind.TypeDefinition) { + TypeDefinition typeDef = reader.GetTypeDefinition ((TypeDefinitionHandle)typeHandle); + return reader.GetString (typeDef.Namespace) + "." + reader.GetString (typeDef.Name); + } + log.LogDebugMessage ($"Unsupported generic type handle kind: {typeHandle.Kind}"); + return null; } else { log.LogDebugMessage ($"Unsupported EntityHandle.Kind: {ctor.Parent.Kind}"); return null; From 3fe7c2287ca77c4e024c0c9afd066270ba43134d Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 31 Aug 2026 14:16:37 +0200 Subject: [PATCH 06/14] Validate final R8 JNI mappings Record the exact seed mapping entries consumed while rewriting managed assemblies and verify that final R8 preserves every surviving name. Removed entries remain valid linker or R8 trimming outcomes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 3 +- ...id.Sdk.TypeMap.Trimmable.NativeAOT.targets | 3 +- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 1 + src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 43 +++++++++- .../Tasks/RewriteJniNamesForR8.cs | 25 ++++++ .../Tasks/RewriteJniNamesForR8Tests.cs | 4 + .../Utilities/JniRemapping/R8MappingTests.cs | 79 +++++++++++++++++++ .../Utilities/JniRemapping/R8Mapping.cs | 68 ++++++++++++++++ .../Xamarin.Android.Common.targets | 2 +- .../Xamarin.Android.D8.targets | 2 + 10 files changed, 226 insertions(+), 4 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index 0849c476e01..a96f70d1c3f 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets @@ -38,7 +38,8 @@ + MappingFile="$(_AndroidR8JniSeedMapping)" + RewriteManifestFile="$(_AndroidR8JniRewriteManifest)"> diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets index 4b924acafef..834bd99fbf8 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets @@ -93,7 +93,8 @@ + MappingFile="$(_AndroidR8JniSeedMapping)" + RewriteManifestFile="$(_AndroidR8JniRewriteManifest)"> diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index c69bb7eb5bb..c2488ded7ef 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -53,6 +53,7 @@ <_TrimmableTypeMapOutputStamp>$(_TypeMapOutputDirectory)_GenerateTrimmableTypeMap.stamp <_AndroidR8JniSeedDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_TypeMapBaseOutputDir)r8-jni-seed/ <_AndroidR8JniSeedMapping Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)mapping.txt + <_AndroidR8JniRewriteManifest Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)r8-jni-rewrite-manifest.txt <_AndroidR8JniSeedApplicationConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)acw-keep.cfg <_AndroidR8JniSeedXamarinConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)xamarin.cfg <_AndroidR8JniSeedAaptConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)aapt_rules.txt diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index bca1afcb6a3..1834effa975 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using Microsoft.Android.Build.Tasks; +using Xamarin.Android.Tasks.JniRemapping; namespace Xamarin.Android.Tasks { @@ -39,6 +40,8 @@ public class R8 : D8 public bool UseTrimmableNativeAotProguardConfiguration { get; set; } public bool GenerateSeedMapping { get; set; } public bool EnableObfuscation { get; set; } + public bool ValidateProguardMappingFileInput { get; set; } + public string? ProguardMappingRequiredEntriesFile { get; set; } // User-authored AndroidJavaSource (Bind != true) .java files. These have no managed peer and are // therefore absent from the acw-map, so they must be kept explicitly when shrinking is enabled. @@ -51,12 +54,50 @@ public class R8 : D8 public override bool RunTask () { try { - return base.RunTask (); + bool result = base.RunTask (); + if (result && ValidateProguardMappingFileInput) { + ValidateAppliedMapping (); + } + return result && !Log.HasLoggedErrors; } finally { foreach (var temp in tempFiles) { File.Delete (temp); } } + + void ValidateAppliedMapping () + { + if (ProguardMappingFileInput.IsNullOrEmpty () || !File.Exists (ProguardMappingFileInput)) { + Log.LogCodedError ("XA4307", Properties.Resources.XA4307, + $"The R8 JNI seed mapping file '{ProguardMappingFileInput}' was not found."); + return; + } + if (ProguardMappingFileOutput.IsNullOrEmpty () || !File.Exists (ProguardMappingFileOutput)) { + Log.LogCodedError ("XA4307", Properties.Resources.XA4307, + $"The final R8 mapping file '{ProguardMappingFileOutput}' was not generated, so the applied JNI names could not be validated."); + return; + } + if (ProguardMappingRequiredEntriesFile.IsNullOrEmpty () || !File.Exists (ProguardMappingRequiredEntriesFile)) { + Log.LogCodedError ("XA4307", Properties.Resources.XA4307, + $"The R8 JNI rewrite manifest '{ProguardMappingRequiredEntriesFile}' was not found."); + return; + } + + R8Mapping seedMapping = R8Mapping.Load (ProguardMappingFileInput); + R8Mapping finalMapping = R8Mapping.Load (ProguardMappingFileOutput); + int conflictCount = 0; + foreach (string conflict in seedMapping.GetCompatibilityConflicts (finalMapping, File.ReadLines (ProguardMappingRequiredEntriesFile))) { + conflictCount++; + if (conflictCount <= 20) { + Log.LogCodedError ("XA4307", Properties.Resources.XA4307, + $"The final R8 mapping did not preserve the JNI seed mapping for {conflict}."); + } + } + if (conflictCount > 20) { + Log.LogCodedError ("XA4307", Properties.Resources.XA4307, + $"The final R8 mapping contains {conflictCount - 20} additional conflicts with managed JNI names."); + } + } } // Derive the fully-qualified Java type name from each user .java source file. Java requires the diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs index 7dc7d021950..3201502b144 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.IO; using Microsoft.Android.Build.Tasks; using Microsoft.Build.Framework; @@ -39,6 +40,8 @@ public class RewriteJniNamesForR8 : AndroidTask [Required] public string MappingFile { get; set; } = ""; + public string? RewriteManifestFile { get; set; } + [Output] public ITaskItem [] RewrittenFiles { get; set; } = []; @@ -70,9 +73,31 @@ public override bool RunTask () } RewrittenFiles = rewrittenFiles; + if (!Log.HasLoggedErrors && !RewriteManifestFile.IsNullOrEmpty ()) { + WriteRewriteManifest (RewriteManifestFile, mapping.AccessedEntries); + } return !Log.HasLoggedErrors; } + static void WriteRewriteManifest (string path, IEnumerable entries) + { + string? directory = Path.GetDirectoryName (path); + if (!directory.IsNullOrEmpty ()) { + Directory.CreateDirectory (directory); + } + + var sortedEntries = new List (entries); + sortedEntries.Sort (StringComparer.Ordinal); + using var content = new MemoryStream (); + using (var writer = new StreamWriter (content, new System.Text.UTF8Encoding (encoderShouldEmitUTF8Identifier: false), 1024, leaveOpen: true)) { + foreach (string entry in sortedEntries) { + writer.WriteLine (entry); + } + } + content.Position = 0; + Files.CopyIfStreamChanged (content, path); + } + void RewriteAssembly (string sourcePath, string destinationPath, R8Mapping mapping) { string? destinationDirectory = Path.GetDirectoryName (destinationPath); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs index 884494ff36f..67c6cbeb2af 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs @@ -52,18 +52,22 @@ public void CopiesSourceToDestinationAndAdjacentPdbUnchanged () string destinationDll = Path.Combine (path, "destination", "nested", "Test.dll"); string destinationPdb = Path.ChangeExtension (destinationDll, "pdb"); + string rewriteManifest = Path.Combine (path, "destination", "rewrite-manifest.txt"); var task = new RewriteJniNamesForR8 { BuildEngine = new MockBuildEngine (TestContext.Out), SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem (sourceDll) }, DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem (destinationDll) }, MappingFile = mappingFile, + RewriteManifestFile = rewriteManifest, }; Assert.IsTrue (task.Execute (), "Task should succeed."); FileAssert.Exists (destinationDll); FileAssert.Exists (destinationPdb); + FileAssert.Exists (rewriteManifest); + Assert.AreEqual ("", File.ReadAllText (rewriteManifest)); CollectionAssert.AreEqual (File.ReadAllBytes (sourceDll), File.ReadAllBytes (destinationDll), "An assembly with no JNI replacements must remain byte-identical."); CollectionAssert.AreEqual (pdbContent, File.ReadAllBytes (destinationPdb), "The adjacent PDB must be copied unchanged."); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs index f6a09d77186..b8402e8ed4a 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs @@ -235,5 +235,84 @@ public void ThrowsOnMemberLineBeforeAnyClassLine () { Assert.Throws (() => R8Mapping.Parse (new StringReader (" int someField -> x\n"))); } + + [Test] + public void ReportsNamesThatDifferBetweenSeedAndFinalMappings () + { + R8Mapping seed = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + int count -> a + void onClick(android.view.View) -> b + void removed() -> c + + """)); + R8Mapping final = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> x.y.Z: + int count -> d + void onClick(android.view.View) -> e + acme.final.Only -> q.r.S: + + """)); + + CollectionAssert.AreEqual (new [] { + "class 'acme/orig/MyView': seed name 'a/b/C', final name 'x/y/Z'", + "field 'acme/orig/MyView.count': seed name 'a', final name 'd'", + "method 'acme/orig/MyView.onClick(android.view.View)': seed name 'b', final name 'e'", + }, seed.GetCompatibilityConflicts (final, new [] { + "C\tacme/orig/MyView", + "F\tacme/orig/MyView\tcount", + "M\tacme/orig/MyView\tonClick(android.view.View)", + })); + } + + [Test] + public void IgnoresMappingsRemovedByFinalShrinking () + { + R8Mapping seed = R8Mapping.Parse (new StringReader (""" + acme.orig.Kept -> a.b.C: + void kept() -> a + void removed() -> b + acme.orig.Removed -> a.b.D: + int value -> a + void removed() -> b + + """)); + R8Mapping final = R8Mapping.Parse (new StringReader (""" + acme.orig.Kept -> a.b.C: + void kept() -> a + acme.orig.Removed -> R8$$REMOVED$$CLASS$$0: + int value -> z + void removed() -> z + + """)); + + CollectionAssert.IsEmpty (seed.GetCompatibilityConflicts (final, new [] { + "C\tacme/orig/Removed", + "F\tacme/orig/Removed\tvalue", + "M\tacme/orig/Removed\tremoved()", + "M\tacme/orig/Kept\tremoved()", + })); + } + + [Test] + public void TracksMappingsUsedByManagedRewriting () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + int count -> a + void onClick(android.view.View) -> b + + """)); + + Assert.IsTrue (mapping.TryGetRenamedClass ("acme/orig/MyView", out _)); + Assert.IsTrue (mapping.TryGetRenamedField ("acme/orig/MyView", "count", out _)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View" }, out _)); + + CollectionAssert.AreEquivalent (new [] { + "C\tacme/orig/MyView", + "F\tacme/orig/MyView\tcount", + "M\tacme/orig/MyView\tonClick(android.view.View)", + }, mapping.AccessedEntries); + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index 966706aab8c..6f3392062a2 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -23,6 +23,9 @@ sealed class R8Mapping // Original JNI class name -> ("name(javaParam,javaParam,...)" -> obfuscated method name). readonly Dictionary> methods = new Dictionary> (StringComparer.Ordinal); + readonly HashSet accessedEntries = new HashSet (StringComparer.Ordinal); + + public IEnumerable AccessedEntries => accessedEntries; public static R8Mapping Load (string path) { @@ -129,6 +132,7 @@ public bool TryGetRenamedClass (string originalJniClassName, out string obfuscat { if (classes.TryGetValue (originalJniClassName, out string? renamed)) { obfuscatedJniClassName = renamed; + accessedEntries.Add (BuildClassEntry (originalJniClassName)); return true; } obfuscatedJniClassName = ""; @@ -195,6 +199,7 @@ public bool TryGetRenamedField (string owningJniClassName, string originalFieldN return false; } obfuscatedFieldName = renamed; + accessedEntries.Add (BuildFieldEntry (owningJniClassName, originalFieldName)); return true; } @@ -208,6 +213,7 @@ public bool TryGetRenamedMethod (string owningJniClassName, string javaMethodNam return false; } obfuscatedMethodName = renamed; + accessedEntries.Add (BuildMethodEntry (owningJniClassName, BuildMethodKey (javaMethodName, javaParameterTypes))); return true; } @@ -241,10 +247,72 @@ public bool TryGetRenamedMethodByNameOnly (string owningJniClassName, string jav return false; } + foreach (var kvp in classMethods) { + if (kvp.Key.StartsWith (prefix, StringComparison.Ordinal) && kvp.Value == match) { + accessedEntries.Add (BuildMethodEntry (owningJniClassName, kvp.Key)); + } + } obfuscatedMethodName = match; return true; } + /// + /// Reports required mappings that are present in both this seed mapping and + /// , but whose obfuscated names differ. Entries absent from the + /// final mapping are intentionally ignored because ILLink, ILC, or final R8 shrinking may have + /// removed them. + /// + public IEnumerable GetCompatibilityConflicts (R8Mapping finalMapping, IEnumerable requiredEntries) + { + foreach (string requiredEntry in requiredEntries) { + string [] parts = requiredEntry.Split ('\t'); + switch (parts.Length > 0 ? parts [0] : "") { + case "C" when parts.Length == 2: + if (classes.TryGetValue (parts [1], out string? seedClassName) && + finalMapping.classes.TryGetValue (parts [1], out string? finalClassName) && + !IsRemovedClassName (finalClassName) && + !String.Equals (seedClassName, finalClassName, StringComparison.Ordinal)) { + yield return $"class '{parts [1]}': seed name '{seedClassName}', final name '{finalClassName}'"; + } + break; + case "F" when parts.Length == 3: + if (!finalMapping.IsRemovedClass (parts [1]) && + fields.TryGetValue (parts [1], out var seedFields) && + seedFields.TryGetValue (parts [2], out string? seedFieldName) && + finalMapping.fields.TryGetValue (parts [1], out var finalFields) && + finalFields.TryGetValue (parts [2], out string? finalFieldName) && + !String.Equals (seedFieldName, finalFieldName, StringComparison.Ordinal)) { + yield return $"field '{parts [1]}.{parts [2]}': seed name '{seedFieldName}', final name '{finalFieldName}'"; + } + break; + case "M" when parts.Length == 3: + if (!finalMapping.IsRemovedClass (parts [1]) && + methods.TryGetValue (parts [1], out var seedMethods) && + seedMethods.TryGetValue (parts [2], out string? seedMethodName) && + seedMethodName.Length != 0 && + finalMapping.methods.TryGetValue (parts [1], out var finalMethods) && + finalMethods.TryGetValue (parts [2], out string? finalMethodName) && + finalMethodName.Length != 0 && + !String.Equals (seedMethodName, finalMethodName, StringComparison.Ordinal)) { + yield return $"method '{parts [1]}.{parts [2]}': seed name '{seedMethodName}', final name '{finalMethodName}'"; + } + break; + default: + throw new FormatException ($"Invalid R8 JNI rewrite manifest entry '{requiredEntry}'."); + } + } + } + + static string BuildClassEntry (string className) => $"C\t{className}"; + static string BuildFieldEntry (string className, string fieldName) => $"F\t{className}\t{fieldName}"; + static string BuildMethodEntry (string className, string methodKey) => $"M\t{className}\t{methodKey}"; + + static bool IsRemovedClassName (string className) + => className.StartsWith ("R8$$REMOVED$$CLASS$$", StringComparison.Ordinal); + + bool IsRemovedClass (string originalClassName) + => classes.TryGetValue (originalClassName, out string? className) && IsRemovedClassName (className); + static string JavaNameToJni (string javaBinaryName) => javaBinaryName.Replace ('.', '/'); static bool TryParseClassLine (string trimmed, out string originalClass, out string obfuscatedClass) diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 72b19c3ffec..06fb4cac68c 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -2031,7 +2031,7 @@ because xbuild doesn't support framework reference assemblies. - $(OutputPath)mapping.txt + $(OutputPath)mapping.txt diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets index 704eefeb789..31017784eba 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets @@ -82,6 +82,8 @@ Copyright (C) 2018 Xamarin. All rights reserved. ProguardConfigurationFiles="@(_ProguardConfiguration)" UseTrimmableNativeAotProguardConfiguration="$(_UseTrimmableNativeAotProguardConfiguration)" EnableObfuscation="$(_AndroidEnableR8JniNameRewriting)" + ValidateProguardMappingFileInput="$(_AndroidEnableR8JniNameRewriting)" + ProguardMappingRequiredEntriesFile="$(_AndroidR8JniRewriteManifest)" EnableShrinking="$(_R8EnableShrinking)" EnableMultiDex="$(AndroidEnableMultiDex)" MultiDexMainDexListFile="$(_AndroidMainDexListFile)" From 44964552ed73439c6e8270474a791b7e1586c2fb Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 31 Aug 2026 14:38:33 +0200 Subject: [PATCH 07/14] Validate final R8 JNI reachability Generate post-link manifests from the CoreCLR and NativeAOT keep-rule inputs, then fail final R8 when a required Java class or member disappears. Cover registered fields and overload-aware methods in the CoreCLR rules. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 10 +- ...id.Sdk.TypeMap.Trimmable.NativeAOT.targets | 9 +- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 1 + .../GenerateNativeAotProguardConfiguration.cs | 29 ++++- .../Tasks/GenerateProguardConfiguration.cs | 98 +++++++++++++-- src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 25 +++- .../Tasks/RewriteJniNamesForR8.cs | 12 +- .../GenerateProguardConfigurationTests.cs | 114 ++++++++++++++++++ .../Tasks/GenerateTrimmableTypeMapTests.cs | 15 +++ .../Utilities/JniRemapping/R8MappingTests.cs | 54 +++++++++ .../Utilities/JniRemapping/R8Mapping.cs | 86 ++++++++++++- .../Xamarin.Android.D8.targets | 1 + 12 files changed, 421 insertions(+), 33 deletions(-) create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index a96f70d1c3f..f3f793a0a5c 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets @@ -72,12 +72,16 @@ AfterTargets="_PrepareLinkedAssembliesForProguard" DependsOnTargets="_PrepareLinkedAssembliesForProguard" Condition=" '$(PublishTrimmed)' == 'true' and '$(_ProguardProjectConfiguration)' != '' " - Inputs="@(_LinkedAssemblyForProguard)" - Outputs="$(_ProguardProjectConfiguration)"> + Inputs="@(_LinkedAssemblyForProguard);$(_AndroidR8JniSeedMapping)" + Outputs="$(_ProguardProjectConfiguration);$(_AndroidR8JniReachabilityManifest)"> + R8MappingFile="$(_AndroidR8JniSeedMapping)" + R8ReachabilityManifestFile="$(_AndroidR8JniReachabilityManifest)" /> + + + + Inputs="$(_TrimmableNativeAotProguardConfigurationInputsStamp);@(_TrimmableNativeAotDgmlFiles);$(IntermediateOutputPath)acw-map.txt;$(_AndroidR8JniSeedMapping)" + Outputs="$(_ProguardProjectConfiguration);$(_AndroidR8JniReachabilityManifest)"> + OutputFile="$(_ProguardProjectConfiguration)" + R8MappingFile="$(_AndroidR8JniSeedMapping)" + R8ReachabilityManifestFile="$(_AndroidR8JniReachabilityManifest)" /> + diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index c2488ded7ef..b135c8da75a 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -54,6 +54,7 @@ <_AndroidR8JniSeedDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_TypeMapBaseOutputDir)r8-jni-seed/ <_AndroidR8JniSeedMapping Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)mapping.txt <_AndroidR8JniRewriteManifest Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)r8-jni-rewrite-manifest.txt + <_AndroidR8JniReachabilityManifest Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)r8-jni-reachability-manifest.txt <_AndroidR8JniSeedApplicationConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)acw-keep.cfg <_AndroidR8JniSeedXamarinConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)xamarin.cfg <_AndroidR8JniSeedAaptConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)aapt_rules.txt diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeAotProguardConfiguration.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeAotProguardConfiguration.cs index b8369374be9..3a155d19640 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeAotProguardConfiguration.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeAotProguardConfiguration.cs @@ -6,6 +6,7 @@ using System.Xml; using Microsoft.Build.Framework; using Microsoft.Android.Build.Tasks; +using Xamarin.Android.Tasks.JniRemapping; namespace Xamarin.Android.Tasks; @@ -23,6 +24,10 @@ public class GenerateNativeAotProguardConfiguration : AndroidTask [Required] public string OutputFile { get; set; } = ""; + public string? R8MappingFile { get; set; } + + public string? R8ReachabilityManifestFile { get; set; } + // When false, the ILC DGML is not consulted (it may not have been generated at all) and a // -keep rule is emitted for every Java type in the ACW map, so R8 keeps them all instead of // shrinking the unused ones. Large binding closures can add several MB of compressed DEX, but @@ -58,13 +63,26 @@ public override bool RunTask () // A null retainedTypeKeys means "keep every Java type in the ACW map" (Java trimming disabled). var javaTypes = LoadJavaTypesFromAcwMap (retainedTypeKeys); + var reachableR8Entries = new HashSet (StringComparer.Ordinal); + if (!R8MappingFile.IsNullOrEmpty ()) { + R8Mapping mapping = R8Mapping.Load (R8MappingFile); + foreach (string javaTypeName in javaTypes) { + string jniTypeName = javaTypeName.Replace ('.', '/'); + if (mapping.TryGetRenamedClass (jniTypeName, out _)) { + reachableR8Entries.Add (R8Mapping.BuildClassEntry (jniTypeName)); + } + } + } using var writer = new StringWriter (); writer.WriteLine ("# ACWs retained by NativeAOT ILC"); foreach (var javaTypeName in javaTypes) { writer.WriteLine ($"-keep class {javaTypeName} {{ *; }}"); } - Files.CopyIfStringChanged (writer.ToString (), OutputFile); + File.WriteAllText (OutputFile, writer.ToString ()); + if (!R8ReachabilityManifestFile.IsNullOrEmpty ()) { + WriteReachabilityManifest (R8ReachabilityManifestFile, reachableR8Entries); + } if (TrimJavaCallableWrappers) { Log.LogMessage (MessageImportance.Low, "Generated {0} NativeAOT trimmable typemap ProGuard rules from {1} DGML file(s).", javaTypes.Count, NativeAotDgmlFiles.Length); @@ -74,6 +92,15 @@ public override bool RunTask () return !Log.HasLoggedErrors; } + static void WriteReachabilityManifest (string path, IEnumerable entries) + { + string? directory = Path.GetDirectoryName (path); + if (!directory.IsNullOrEmpty ()) { + Directory.CreateDirectory (directory); + } + File.WriteAllText (path, R8Mapping.CreateManifestContent (entries)); + } + List LoadJavaTypesFromAcwMap (HashSet? retainedTypeKeys) { var javaTypes = new List (retainedTypeKeys?.Count ?? 0); diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateProguardConfiguration.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateProguardConfiguration.cs index b4e3d8d5a61..319c82d6cea 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateProguardConfiguration.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateProguardConfiguration.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.IO; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; @@ -15,14 +16,17 @@ public class GenerateProguardConfiguration : AndroidTask public override string TaskPrefix => "GPC"; [Required] - public ITaskItem[] LinkedAssemblies { get; set; } = []; + public ITaskItem [] LinkedAssemblies { get; set; } = []; [Required] public string OutputFile { get; set; } = ""; public string? R8MappingFile { get; set; } + public string? R8ReachabilityManifestFile { get; set; } + R8Mapping? r8Mapping; + readonly HashSet reachableR8Entries = new HashSet (StringComparer.Ordinal); public override bool RunTask () { @@ -33,15 +37,28 @@ public override bool RunTask () if (!dir.IsNullOrEmpty () && !Directory.Exists (dir)) { Directory.CreateDirectory (dir); } - using var writer = File.CreateText (OutputFile); + using var writer = new StringWriter (); foreach (var assembly in LinkedAssemblies) { ProcessAssembly (assembly.ItemSpec, writer); } + File.WriteAllText (OutputFile, writer.ToString ()); + if (!R8ReachabilityManifestFile.IsNullOrEmpty ()) { + WriteReachabilityManifest (R8ReachabilityManifestFile); + } return !Log.HasLoggedErrors; } + void WriteReachabilityManifest (string path) + { + string? directory = Path.GetDirectoryName (path); + if (!directory.IsNullOrEmpty ()) { + Directory.CreateDirectory (directory); + } + File.WriteAllText (path, R8Mapping.CreateManifestContent (reachableR8Entries)); + } + void ProcessAssembly (string assemblyPath, TextWriter writer) { try { @@ -109,15 +126,32 @@ void ProcessType (MetadataReader reader, TypeDefinition type, TextWriter writer) return; string rewrittenJniName = javaTypeName.Replace ('.', '/'); - string originalJniName = r8Mapping?.TryGetOriginalClass (rewrittenJniName, out string original) == true ? original : rewrittenJniName; + bool hasR8ClassMapping = false; + string originalJniName = rewrittenJniName; + if (r8Mapping?.TryGetOriginalClass (rewrittenJniName, out string original) == true) { + hasR8ClassMapping = true; + originalJniName = original; + } string ruleTypeName = originalJniName.Replace ('/', '.'); string allowObfuscation = r8Mapping == null ? "" : ",allowobfuscation"; + if (hasR8ClassMapping) { + reachableR8Entries.Add (R8Mapping.BuildClassEntry (originalJniName)); + } writer.WriteLine ($"-keep{allowObfuscation} class {ruleTypeName}"); writer.WriteLine ($"-keepclassmembers{allowObfuscation} class {ruleTypeName} {{"); foreach (var methodHandle in type.GetMethods ()) { ProcessMethod (reader, methodHandle, originalJniName, writer); } + foreach (var fieldHandle in type.GetFields ()) { + ProcessFieldLikeMember (reader, reader.GetFieldDefinition (fieldHandle).GetCustomAttributes (), originalJniName, writer); + } + foreach (var propertyHandle in type.GetProperties ()) { + ProcessFieldLikeMember (reader, reader.GetPropertyDefinition (propertyHandle).GetCustomAttributes (), originalJniName, writer); + } + foreach (var eventHandle in type.GetEvents ()) { + ProcessFieldLikeMember (reader, reader.GetEventDefinition (eventHandle).GetCustomAttributes (), originalJniName, writer); + } writer.WriteLine ("}"); writer.WriteLine (); @@ -137,37 +171,85 @@ args.FixedArguments[0].Value is string jname && args.FixedArguments[1].Value is string jniDescriptor) { if (jname == ".ctor" || jname == "") { writer.WriteLine (" (...);"); + if (TryGetOriginalParameterTypes (jniDescriptor, out var originalParameterTypes) && + r8Mapping?.TryGetRenamedMethod (originalJniClassName, "", originalParameterTypes, out _) == true) { + reachableR8Entries.Add (R8Mapping.BuildMethodEntry ( + originalJniClassName, + R8Mapping.BuildMethodKey ("", originalParameterTypes))); + } } else { bool wroteOriginalName = false; if (r8Mapping != null && - TryGetOriginalMethodName (originalJniClassName, jname, jniDescriptor, out string originalName)) { + TryGetOriginalMethodName (originalJniClassName, jname, jniDescriptor, out string originalName, out var originalParameterTypes)) { writer.WriteLine ($" *** {originalName}(...);"); + reachableR8Entries.Add (R8Mapping.BuildMethodEntry ( + originalJniClassName, + R8Mapping.BuildMethodKey (originalName, originalParameterTypes))); wroteOriginalName = true; } if (!wroteOriginalName) { writer.WriteLine ($" *** {jname}(...);"); } } + } break; } } } + void ProcessFieldLikeMember (MetadataReader reader, CustomAttributeHandleCollection attributes, string originalJniClassName, TextWriter writer) + { + foreach (var attrHandle in attributes) { + var attr = reader.GetCustomAttribute (attrHandle); + if (reader.GetCustomAttributeFullName (attr, Log) != "Android.Runtime.RegisterAttribute") { + continue; + } + var args = attr.GetCustomAttributeArguments (); + if (args.FixedArguments.Length == 0 || args.FixedArguments [0].Value is not string rewrittenFieldName) { + break; + } + + string fieldName = rewrittenFieldName; + if (r8Mapping?.TryGetOriginalFieldName (originalJniClassName, rewrittenFieldName, out string originalFieldName) == true) { + fieldName = originalFieldName; + reachableR8Entries.Add (R8Mapping.BuildFieldEntry (originalJniClassName, originalFieldName)); + } + writer.WriteLine ($" *** {fieldName};"); + break; + } + } + string? GetOriginalClassName (string rewrittenJniName) => r8Mapping?.TryGetOriginalClass (rewrittenJniName, out string originalJniName) == true ? originalJniName : null; - bool TryGetOriginalMethodName (string originalJniClassName, string rewrittenMethodName, string rewrittenDescriptor, out string originalMethodName) + bool TryGetOriginalMethodName ( + string originalJniClassName, + string rewrittenMethodName, + string rewrittenDescriptor, + out string originalMethodName, + out List originalParameterTypes) { - JniDescriptorText.TryRewriteDescriptor (rewrittenDescriptor, GetOriginalClassName, out string originalDescriptor); - if (r8Mapping != null && JniDescriptorText.TryParseMethodDescriptor (originalDescriptor, out var originalParameterTypes, out _)) { + if (r8Mapping != null && TryGetOriginalParameterTypes (rewrittenDescriptor, out originalParameterTypes)) { return r8Mapping.TryGetOriginalMethodName ( originalJniClassName, rewrittenMethodName, - originalParameterTypes.ConvertAll (JniDescriptorText.JniTypeTokenToJavaSource), + originalParameterTypes, out originalMethodName); } originalMethodName = ""; + originalParameterTypes = []; + return false; + } + + bool TryGetOriginalParameterTypes (string rewrittenDescriptor, out List originalParameterTypes) + { + JniDescriptorText.TryRewriteDescriptor (rewrittenDescriptor, GetOriginalClassName, out string originalDescriptor); + if (JniDescriptorText.TryParseMethodDescriptor (originalDescriptor, out var originalParameterTokens, out _)) { + originalParameterTypes = originalParameterTokens.ConvertAll (JniDescriptorText.JniTypeTokenToJavaSource); + return true; + } + originalParameterTypes = []; return false; } } diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index 1834effa975..f71701b7a2c 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -42,6 +42,7 @@ public class R8 : D8 public bool EnableObfuscation { get; set; } public bool ValidateProguardMappingFileInput { get; set; } public string? ProguardMappingRequiredEntriesFile { get; set; } + public string? ProguardMappingRequiredReachabilityEntriesFile { get; set; } // User-authored AndroidJavaSource (Bind != true) .java files. These have no managed peer and are // therefore absent from the acw-map, so they must be kept explicitly when shrinking is enabled. @@ -82,20 +83,36 @@ void ValidateAppliedMapping () $"The R8 JNI rewrite manifest '{ProguardMappingRequiredEntriesFile}' was not found."); return; } + if (ProguardMappingRequiredReachabilityEntriesFile.IsNullOrEmpty () || !File.Exists (ProguardMappingRequiredReachabilityEntriesFile)) { + Log.LogCodedError ("XA4307", Properties.Resources.XA4307, + $"The post-link R8 JNI reachability manifest '{ProguardMappingRequiredReachabilityEntriesFile}' was not found."); + return; + } R8Mapping seedMapping = R8Mapping.Load (ProguardMappingFileInput); R8Mapping finalMapping = R8Mapping.Load (ProguardMappingFileOutput); + LogMappingConflicts ( + seedMapping.GetCompatibilityConflicts (finalMapping, File.ReadLines (ProguardMappingRequiredEntriesFile)), + "The final R8 mapping did not preserve the JNI seed mapping for ", + "additional conflicts with managed JNI names"); + LogMappingConflicts ( + seedMapping.GetReachabilityConflicts (finalMapping, File.ReadLines (ProguardMappingRequiredReachabilityEntriesFile)), + "Final R8 removed a post-link reachable JNI ", + "additional post-link reachable JNI entries removed by final R8"); + } + + void LogMappingConflicts (IEnumerable conflicts, string prefix, string overflowDescription) + { int conflictCount = 0; - foreach (string conflict in seedMapping.GetCompatibilityConflicts (finalMapping, File.ReadLines (ProguardMappingRequiredEntriesFile))) { + foreach (string conflict in conflicts) { conflictCount++; if (conflictCount <= 20) { - Log.LogCodedError ("XA4307", Properties.Resources.XA4307, - $"The final R8 mapping did not preserve the JNI seed mapping for {conflict}."); + Log.LogCodedError ("XA4307", Properties.Resources.XA4307, prefix + conflict + "."); } } if (conflictCount > 20) { Log.LogCodedError ("XA4307", Properties.Resources.XA4307, - $"The final R8 mapping contains {conflictCount - 20} additional conflicts with managed JNI names."); + $"The final R8 mapping contains {conflictCount - 20} {overflowDescription}."); } } } diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs index 3201502b144..e06612c3a4f 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs @@ -85,17 +85,7 @@ static void WriteRewriteManifest (string path, IEnumerable entries) if (!directory.IsNullOrEmpty ()) { Directory.CreateDirectory (directory); } - - var sortedEntries = new List (entries); - sortedEntries.Sort (StringComparer.Ordinal); - using var content = new MemoryStream (); - using (var writer = new StreamWriter (content, new System.Text.UTF8Encoding (encoderShouldEmitUTF8Identifier: false), 1024, leaveOpen: true)) { - foreach (string entry in sortedEntries) { - writer.WriteLine (entry); - } - } - content.Position = 0; - Files.CopyIfStreamChanged (content, path); + Files.CopyIfStringChanged (R8Mapping.CreateManifestContent (entries), path); } void RewriteAssembly (string sourcePath, string destinationPath, R8Mapping mapping) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs new file mode 100644 index 00000000000..858b3f6c066 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs @@ -0,0 +1,114 @@ +using System; +using System.IO; +using System.Reflection; +using System.Reflection.Metadata; +using Microsoft.Build.Utilities; +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class GenerateProguardConfigurationTests : BaseTest + { + [Test] + public void WritesOriginalKeepRulesAndReachabilityManifest () + { + string path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + string assembly = Path.Combine (path, "Linked.dll"); + string mapping = Path.Combine (path, "mapping.txt"); + string proguard = Path.Combine (path, "proguard.cfg"); + string manifest = Path.Combine (path, "reachability.txt"); + + var fixture = new JniFixtureBuilder (); + fixture.Metadata.AddAssemblyReference ( + fixture.Metadata.GetOrAddString ("Mono.Android"), + new Version (1, 0, 0, 0), + default, + default, + default, + default); + + int fieldStart = fixture.NextFieldRid; + var field = fixture.Metadata.AddFieldDefinition ( + FieldAttributes.Public, + fixture.Metadata.GetOrAddString ("Count"), + fixture.Metadata.GetOrAddBlob (new byte [] { 0x06, 0x08 })); + + int methodStart = fixture.NextMethodRid; + var constructor = fixture.AddVoidMethod (".ctor", fixture.EmitReturnOnlyBody (), + MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName); + var firstOverload = fixture.AddVoidMethod ("OnClick", fixture.EmitReturnOnlyBody ()); + var secondOverload = fixture.AddVoidMethod ("OnClickWithIndex", fixture.EmitReturnOnlyBody ()); + + var type = fixture.AddType ("Managed", "MyView", fieldStart, methodStart); + var property = fixture.Metadata.AddProperty ( + PropertyAttributes.None, + fixture.Metadata.GetOrAddString ("Enabled"), + fixture.Metadata.GetOrAddBlob (new byte [] { 0x28, 0x00, 0x08 })); + fixture.Metadata.AddPropertyMap (type, property); + var @event = fixture.Metadata.AddEvent ( + EventAttributes.None, + fixture.Metadata.GetOrAddString ("Listener"), + fixture.ValueTypeReference); + fixture.Metadata.AddEventMap (type, @event); + + fixture.Metadata.AddCustomAttribute (type, fixture.RegisterCtor1, fixture.AttributeBlob ("a/b/C")); + fixture.Metadata.AddCustomAttribute (constructor, fixture.RegisterCtor3, fixture.AttributeBlob (".ctor", "()V", "")); + fixture.Metadata.AddCustomAttribute (firstOverload, fixture.RegisterCtor3, fixture.AttributeBlob ("a", "(La/b/D;)V", "")); + fixture.Metadata.AddCustomAttribute (secondOverload, fixture.RegisterCtor3, fixture.AttributeBlob ("b", "(La/b/D;I)V", "")); + fixture.Metadata.AddCustomAttribute (field, fixture.RegisterCtor1, fixture.AttributeBlob ("c")); + fixture.Metadata.AddCustomAttribute (property, fixture.RegisterCtor1, fixture.AttributeBlob ("d")); + fixture.Metadata.AddCustomAttribute (@event, fixture.RegisterCtor1, fixture.AttributeBlob ("e")); + File.WriteAllBytes (assembly, fixture.Serialize ()); + + File.WriteAllText (mapping, """ + acme.orig.MyView -> a.b.C: + void () -> + void onClick(android.view.View) -> a + void onClick(android.view.View,int) -> b + int count -> c + boolean enabled -> d + java.lang.Object listener -> e + android.view.View -> a.b.D: + + """); + + var task = new GenerateProguardConfiguration { + BuildEngine = new MockBuildEngine (TestContext.Out), + LinkedAssemblies = new [] { new TaskItem (assembly) }, + OutputFile = proguard, + R8MappingFile = mapping, + R8ReachabilityManifestFile = manifest, + }; + + Assert.IsTrue (task.Execute (), "Task should succeed."); + Assert.AreEqual (""" + # ACW for Fixture + -keep,allowobfuscation class acme.orig.MyView + -keepclassmembers,allowobfuscation class acme.orig.MyView { + (...); + *** onClick(...); + *** onClick(...); + *** count; + *** enabled; + *** listener; + } + + + """, File.ReadAllText (proguard)); + Assert.AreEqual (""" + C acme/orig/MyView + F acme/orig/MyView count + F acme/orig/MyView enabled + F acme/orig/MyView listener + M acme/orig/MyView () + M acme/orig/MyView onClick(android.view.View) + M acme/orig/MyView onClick(android.view.View,int) + + """, File.ReadAllText (manifest)); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs index e4b82397080..5ca1005473b 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs @@ -284,6 +284,8 @@ public void Execute_GenerateNativeAotProguardConfiguration_UsesDgmlTypeMetadata var path = Path.Combine (Root, "temp", TestName); var dgmlFile = Path.Combine (path, "app.scan.dgml.xml"); var acwMapFile = Path.Combine (path, "acw-map.txt"); + var mappingFile = Path.Combine (path, "mapping.txt"); + var reachabilityManifestFile = Path.Combine (path, "r8-jni-reachability-manifest.txt"); var outputFile = Path.Combine (path, "proguard", "proguard_project_references.cfg"); Directory.CreateDirectory (path); File.WriteAllText (dgmlFile, """ @@ -309,12 +311,20 @@ public void Execute_GenerateNativeAotProguardConfiguration_UsesDgmlTypeMetadata Duplicate.Type;wrong.Duplicate Other.Type;other.Type """); + File.WriteAllText (mappingFile, """ + crc64a1.MainActivity -> a.a: + my.app.Duplicate -> a.b: + androidx.activity.result.contract.ActivityResultContracts$TakePicture -> a.c: + + """); var task = new GenerateNativeAotProguardConfiguration { BuildEngine = new MockBuildEngine (TestContext.Out), NativeAotDgmlFiles = new [] { new TaskItem (dgmlFile) }, AcwMapFile = acwMapFile, OutputFile = outputFile, + R8MappingFile = mappingFile, + R8ReachabilityManifestFile = reachabilityManifestFile, TrimJavaCallableWrappers = true, }; @@ -326,6 +336,11 @@ public void Execute_GenerateNativeAotProguardConfiguration_UsesDgmlTypeMetadata StringAssert.Contains ("-keep class androidx.activity.result.contract.ActivityResultContracts$TakePicture { *; }", proguard); StringAssert.DoesNotContain ("wrong.Duplicate", proguard); StringAssert.DoesNotContain ("other.Type", proguard); + CollectionAssert.AreEqual (new [] { + "C\tandroidx/activity/result/contract/ActivityResultContracts$TakePicture", + "C\tcrc64a1/MainActivity", + "C\tmy/app/Duplicate", + }, File.ReadAllLines (reachabilityManifestFile)); } [Test] diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs index b8402e8ed4a..b508de720d0 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs @@ -314,5 +314,59 @@ void onClick(android.view.View) -> b "M\tacme/orig/MyView\tonClick(android.view.View)", }, mapping.AccessedEntries); } + + [Test] + public void ReportsPostLinkEntriesRemovedByFinalR8 () + { + R8Mapping seed = R8Mapping.Parse (new StringReader (""" + acme.orig.Missing -> a.b.A: + acme.orig.Removed -> a.b.B: + acme.orig.Members -> a.b.C: + int keptField -> a + int missingField -> b + void kept() -> a + void missing() -> b + + """)); + R8Mapping final = R8Mapping.Parse (new StringReader (""" + acme.orig.Removed -> R8$$REMOVED$$CLASS$$0: + acme.orig.Members -> a.b.C: + int keptField -> a + void kept() -> a + + """)); + + CollectionAssert.AreEqual (new [] { + "class 'acme/orig/Missing'", + "class 'acme/orig/Removed'", + "field 'acme/orig/Members.missingField'", + "method 'acme/orig/Members.missing()'", + }, seed.GetReachabilityConflicts (final, new [] { + "C\tacme/orig/Missing", + "C\tacme/orig/Removed", + "C\tacme/orig/Members", + "F\tacme/orig/Members\tkeptField", + "F\tacme/orig/Members\tmissingField", + "M\tacme/orig/Members\tkept()", + "M\tacme/orig/Members\tmissing()", + })); + } + + [Test] + public void LooksUpOriginalFieldNameOnlyWhenUnambiguous () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + int first -> a + int second -> b + int ambiguous1 -> c + int ambiguous2 -> c + + """)); + + Assert.IsTrue (mapping.TryGetOriginalFieldName ("acme/orig/MyView", "a", out string original)); + Assert.AreEqual ("first", original); + Assert.IsFalse (mapping.TryGetOriginalFieldName ("acme/orig/MyView", "c", out _)); + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index 6f3392062a2..afcf9dca96a 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -191,6 +191,25 @@ public bool TryGetOriginalMethodName (string originalJniClassName, string obfusc return originalMethodName.Length != 0; } + public bool TryGetOriginalFieldName (string originalJniClassName, string obfuscatedFieldName, out string originalFieldName) + { + originalFieldName = ""; + if (!fields.TryGetValue (originalJniClassName, out var classFields)) { + return false; + } + foreach (var entry in classFields) { + if (!String.Equals (entry.Value, obfuscatedFieldName, StringComparison.Ordinal)) { + continue; + } + if (originalFieldName.Length != 0 && !String.Equals (originalFieldName, entry.Key, StringComparison.Ordinal)) { + originalFieldName = ""; + return false; + } + originalFieldName = entry.Key; + } + return originalFieldName.Length != 0; + } + public bool TryGetRenamedField (string owningJniClassName, string originalFieldName, out string obfuscatedFieldName) { obfuscatedFieldName = ""; @@ -303,9 +322,67 @@ public IEnumerable GetCompatibilityConflicts (R8Mapping finalMapping, IE } } - static string BuildClassEntry (string className) => $"C\t{className}"; - static string BuildFieldEntry (string className, string fieldName) => $"F\t{className}\t{fieldName}"; - static string BuildMethodEntry (string className, string methodKey) => $"M\t{className}\t{methodKey}"; + /// + /// Reports entries that post-link analysis says must remain reachable but which final R8 + /// removed. A required member is only reported separately when its declaring class survived. + /// + public IEnumerable GetReachabilityConflicts (R8Mapping finalMapping, IEnumerable requiredEntries) + { + foreach (string requiredEntry in requiredEntries) { + string [] parts = requiredEntry.Split ('\t'); + switch (parts.Length > 0 ? parts [0] : "") { + case "C" when parts.Length == 2: + if (!classes.ContainsKey (parts [1])) { + throw new FormatException ($"R8 reachability manifest class '{parts [1]}' is absent from the seed mapping."); + } + if (!finalMapping.classes.TryGetValue (parts [1], out string? finalClassName) || IsRemovedClassName (finalClassName)) { + yield return $"class '{parts [1]}'"; + } + break; + case "F" when parts.Length == 3: + if (!fields.TryGetValue (parts [1], out var seedFields) || !seedFields.ContainsKey (parts [2])) { + throw new FormatException ($"R8 reachability manifest field '{parts [1]}.{parts [2]}' is absent from the seed mapping."); + } + if (!finalMapping.IsLiveClass (parts [1])) { + continue; + } + if (!finalMapping.fields.TryGetValue (parts [1], out var finalFields) || !finalFields.ContainsKey (parts [2])) { + yield return $"field '{parts [1]}.{parts [2]}'"; + } + break; + case "M" when parts.Length == 3: + if (!methods.TryGetValue (parts [1], out var seedMethods) || !seedMethods.ContainsKey (parts [2])) { + throw new FormatException ($"R8 reachability manifest method '{parts [1]}.{parts [2]}' is absent from the seed mapping."); + } + if (!finalMapping.IsLiveClass (parts [1])) { + continue; + } + if (!finalMapping.methods.TryGetValue (parts [1], out var finalMethods) || + !finalMethods.TryGetValue (parts [2], out string? finalMethodName) || + finalMethodName.Length == 0) { + yield return $"method '{parts [1]}.{parts [2]}'"; + } + break; + default: + throw new FormatException ($"Invalid R8 JNI reachability manifest entry '{requiredEntry}'."); + } + } + } + + internal static string BuildClassEntry (string className) => $"C\t{className}"; + internal static string BuildFieldEntry (string className, string fieldName) => $"F\t{className}\t{fieldName}"; + internal static string BuildMethodEntry (string className, string methodKey) => $"M\t{className}\t{methodKey}"; + + internal static string CreateManifestContent (IEnumerable entries) + { + var sortedEntries = new List (entries); + sortedEntries.Sort (StringComparer.Ordinal); + using var writer = new StringWriter (); + foreach (string entry in sortedEntries) { + writer.WriteLine (entry); + } + return writer.ToString (); + } static bool IsRemovedClassName (string className) => className.StartsWith ("R8$$REMOVED$$CLASS$$", StringComparison.Ordinal); @@ -313,6 +390,9 @@ static bool IsRemovedClassName (string className) bool IsRemovedClass (string originalClassName) => classes.TryGetValue (originalClassName, out string? className) && IsRemovedClassName (className); + bool IsLiveClass (string originalClassName) + => classes.TryGetValue (originalClassName, out string? className) && !IsRemovedClassName (className); + static string JavaNameToJni (string javaBinaryName) => javaBinaryName.Replace ('.', '/'); static bool TryParseClassLine (string trimmed, out string originalClass, out string obfuscatedClass) diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets index 31017784eba..7f0de6ecf31 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets @@ -84,6 +84,7 @@ Copyright (C) 2018 Xamarin. All rights reserved. EnableObfuscation="$(_AndroidEnableR8JniNameRewriting)" ValidateProguardMappingFileInput="$(_AndroidEnableR8JniNameRewriting)" ProguardMappingRequiredEntriesFile="$(_AndroidR8JniRewriteManifest)" + ProguardMappingRequiredReachabilityEntriesFile="$(_AndroidR8JniReachabilityManifest)" EnableShrinking="$(_R8EnableShrinking)" EnableMultiDex="$(AndroidEnableMultiDex)" MultiDexMainDexListFile="$(_AndroidMainDexListFile)" From 40649082ffd886f2724c99c6b02c742917a7a52c Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 31 Aug 2026 15:42:14 +0200 Subject: [PATCH 08/14] Scan linked assemblies for JNI reachability Reuse the full JNI rewrite planner in reverse over linked CoreCLR assemblies so final R8 keep rules cover attributes, direct lookups, descriptors, interfaces, typemap metadata, and FieldRVA data. Preserve every ambiguous reverse member candidate and index exact lookups. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/GenerateProguardConfiguration.cs | 164 +++++++------- .../GenerateProguardConfigurationTests.cs | 58 +++-- .../JniRemapping/JniAssemblyRewriterTests.cs | 44 +++- .../Utilities/JniRemapping/R8MappingTests.cs | 24 +++ .../Utilities/JniRemapping/FieldRvaTable.cs | 13 +- .../JniRemapping/JniAssemblyRewriter.cs | 17 ++ .../JniRemapping/JniRewritePlanner.cs | 26 +-- .../Utilities/JniRemapping/LdstrRewriter.cs | 18 +- .../Utilities/JniRemapping/R8Mapping.cs | 200 +++++++++++++++--- 9 files changed, 403 insertions(+), 161 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateProguardConfiguration.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateProguardConfiguration.cs index 319c82d6cea..936dde02b99 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateProguardConfiguration.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateProguardConfiguration.cs @@ -26,7 +26,6 @@ public class GenerateProguardConfiguration : AndroidTask public string? R8ReachabilityManifestFile { get; set; } R8Mapping? r8Mapping; - readonly HashSet reachableR8Entries = new HashSet (StringComparer.Ordinal); public override bool RunTask () { @@ -39,8 +38,16 @@ public override bool RunTask () } using var writer = new StringWriter (); - foreach (var assembly in LinkedAssemblies) { - ProcessAssembly (assembly.ItemSpec, writer); + R8Mapping? mapping = r8Mapping; + if (mapping != null) { + foreach (var assembly in LinkedAssemblies) { + ScanRewrittenAssembly (assembly.ItemSpec, mapping); + } + WriteMappedRules (writer, mapping); + } else { + foreach (var assembly in LinkedAssemblies) { + ProcessAssembly (assembly.ItemSpec, writer); + } } File.WriteAllText (OutputFile, writer.ToString ()); if (!R8ReachabilityManifestFile.IsNullOrEmpty ()) { @@ -56,7 +63,67 @@ void WriteReachabilityManifest (string path) if (!directory.IsNullOrEmpty ()) { Directory.CreateDirectory (directory); } - File.WriteAllText (path, R8Mapping.CreateManifestContent (reachableR8Entries)); + File.WriteAllText (path, R8Mapping.CreateManifestContent (r8Mapping?.AccessedEntries ?? [])); + } + + void ScanRewrittenAssembly (string assemblyPath, R8Mapping mapping) + { + try { + using var stream = File.OpenRead (assemblyPath); + using var pe = new PEReader (stream); + if (!pe.HasMetadata) { + return; + } + MetadataReader reader = pe.GetMetadataReader (); + JniAssemblyRewriter.ScanRewrittenAssembly (pe, reader, mapping, Log); + } catch (BadImageFormatException ex) { + Log.LogDebugMessage ($"Could not read assembly '{assemblyPath}': {ex.Message}"); + } catch (JniRewriteException ex) { + Log.LogCodedError ("XA4307", Properties.Resources.XA4307, + $"Could not scan the linked assembly '{assemblyPath}' for rewritten JNI references: {ex.Message}"); + } + } + + void WriteMappedRules (TextWriter writer, R8Mapping mapping) + { + var rules = new SortedDictionary> (StringComparer.Ordinal); + foreach (string entry in mapping.AccessedEntries) { + string [] parts = entry.Split ('\t'); + if (parts.Length < 2) { + continue; + } + if (!rules.TryGetValue (parts [1], out var members)) { + rules [parts [1]] = members = new SortedSet (StringComparer.Ordinal); + } + if (parts.Length != 3) { + continue; + } + + if (parts [0] == "F") { + members.Add ($" *** {parts [2]};"); + } else if (parts [0] == "M") { + int parameterStart = parts [2].IndexOf ('('); + string methodName = parameterStart < 0 ? parts [2] : parts [2].Substring (0, parameterStart); + if (methodName == "") { + members.Add (" (...);"); + } else if (methodName == "") { + members.Add (" ;"); + } else { + members.Add ($" *** {methodName}(...);"); + } + } + } + + foreach (var rule in rules) { + string javaClassName = rule.Key.Replace ('/', '.'); + writer.WriteLine ($"-keep,allowobfuscation class {javaClassName}"); + writer.WriteLine ($"-keepclassmembers,allowobfuscation class {javaClassName} {{"); + foreach (string member in rule.Value) { + writer.WriteLine (member); + } + writer.WriteLine ("}"); + writer.WriteLine (); + } } void ProcessAssembly (string assemblyPath, TextWriter writer) @@ -125,39 +192,27 @@ void ProcessType (MetadataReader reader, TypeDefinition type, TextWriter writer) if (javaTypeName == null) return; - string rewrittenJniName = javaTypeName.Replace ('.', '/'); - bool hasR8ClassMapping = false; - string originalJniName = rewrittenJniName; - if (r8Mapping?.TryGetOriginalClass (rewrittenJniName, out string original) == true) { - hasR8ClassMapping = true; - originalJniName = original; - } - string ruleTypeName = originalJniName.Replace ('/', '.'); - string allowObfuscation = r8Mapping == null ? "" : ",allowobfuscation"; - if (hasR8ClassMapping) { - reachableR8Entries.Add (R8Mapping.BuildClassEntry (originalJniName)); - } - writer.WriteLine ($"-keep{allowObfuscation} class {ruleTypeName}"); - writer.WriteLine ($"-keepclassmembers{allowObfuscation} class {ruleTypeName} {{"); + writer.WriteLine ($"-keep class {javaTypeName}"); + writer.WriteLine ($"-keepclassmembers class {javaTypeName} {{"); foreach (var methodHandle in type.GetMethods ()) { - ProcessMethod (reader, methodHandle, originalJniName, writer); + ProcessMethod (reader, methodHandle, writer); } foreach (var fieldHandle in type.GetFields ()) { - ProcessFieldLikeMember (reader, reader.GetFieldDefinition (fieldHandle).GetCustomAttributes (), originalJniName, writer); + ProcessFieldLikeMember (reader, reader.GetFieldDefinition (fieldHandle).GetCustomAttributes (), writer); } foreach (var propertyHandle in type.GetProperties ()) { - ProcessFieldLikeMember (reader, reader.GetPropertyDefinition (propertyHandle).GetCustomAttributes (), originalJniName, writer); + ProcessFieldLikeMember (reader, reader.GetPropertyDefinition (propertyHandle).GetCustomAttributes (), writer); } foreach (var eventHandle in type.GetEvents ()) { - ProcessFieldLikeMember (reader, reader.GetEventDefinition (eventHandle).GetCustomAttributes (), originalJniName, writer); + ProcessFieldLikeMember (reader, reader.GetEventDefinition (eventHandle).GetCustomAttributes (), writer); } writer.WriteLine ("}"); writer.WriteLine (); } - void ProcessMethod (MetadataReader reader, MethodDefinitionHandle methodHandle, string originalJniClassName, TextWriter writer) + void ProcessMethod (MetadataReader reader, MethodDefinitionHandle methodHandle, TextWriter writer) { var method = reader.GetMethodDefinition (methodHandle); @@ -168,28 +223,11 @@ void ProcessMethod (MetadataReader reader, MethodDefinitionHandle methodHandle, var args = attr.GetCustomAttributeArguments (); if (args.FixedArguments.Length >= 2 && args.FixedArguments[0].Value is string jname && - args.FixedArguments[1].Value is string jniDescriptor) { + args.FixedArguments[1].Value is string) { if (jname == ".ctor" || jname == "") { writer.WriteLine (" (...);"); - if (TryGetOriginalParameterTypes (jniDescriptor, out var originalParameterTypes) && - r8Mapping?.TryGetRenamedMethod (originalJniClassName, "", originalParameterTypes, out _) == true) { - reachableR8Entries.Add (R8Mapping.BuildMethodEntry ( - originalJniClassName, - R8Mapping.BuildMethodKey ("", originalParameterTypes))); - } } else { - bool wroteOriginalName = false; - if (r8Mapping != null && - TryGetOriginalMethodName (originalJniClassName, jname, jniDescriptor, out string originalName, out var originalParameterTypes)) { - writer.WriteLine ($" *** {originalName}(...);"); - reachableR8Entries.Add (R8Mapping.BuildMethodEntry ( - originalJniClassName, - R8Mapping.BuildMethodKey (originalName, originalParameterTypes))); - wroteOriginalName = true; - } - if (!wroteOriginalName) { - writer.WriteLine ($" *** {jname}(...);"); - } + writer.WriteLine ($" *** {jname}(...);"); } } @@ -198,7 +236,7 @@ args.FixedArguments[0].Value is string jname && } } - void ProcessFieldLikeMember (MetadataReader reader, CustomAttributeHandleCollection attributes, string originalJniClassName, TextWriter writer) + void ProcessFieldLikeMember (MetadataReader reader, CustomAttributeHandleCollection attributes, TextWriter writer) { foreach (var attrHandle in attributes) { var attr = reader.GetCustomAttribute (attrHandle); @@ -210,47 +248,9 @@ void ProcessFieldLikeMember (MetadataReader reader, CustomAttributeHandleCollect break; } - string fieldName = rewrittenFieldName; - if (r8Mapping?.TryGetOriginalFieldName (originalJniClassName, rewrittenFieldName, out string originalFieldName) == true) { - fieldName = originalFieldName; - reachableR8Entries.Add (R8Mapping.BuildFieldEntry (originalJniClassName, originalFieldName)); - } - writer.WriteLine ($" *** {fieldName};"); + writer.WriteLine ($" *** {rewrittenFieldName};"); break; } } - - string? GetOriginalClassName (string rewrittenJniName) - => r8Mapping?.TryGetOriginalClass (rewrittenJniName, out string originalJniName) == true ? originalJniName : null; - - bool TryGetOriginalMethodName ( - string originalJniClassName, - string rewrittenMethodName, - string rewrittenDescriptor, - out string originalMethodName, - out List originalParameterTypes) - { - if (r8Mapping != null && TryGetOriginalParameterTypes (rewrittenDescriptor, out originalParameterTypes)) { - return r8Mapping.TryGetOriginalMethodName ( - originalJniClassName, - rewrittenMethodName, - originalParameterTypes, - out originalMethodName); - } - originalMethodName = ""; - originalParameterTypes = []; - return false; - } - - bool TryGetOriginalParameterTypes (string rewrittenDescriptor, out List originalParameterTypes) - { - JniDescriptorText.TryRewriteDescriptor (rewrittenDescriptor, GetOriginalClassName, out string originalDescriptor); - if (JniDescriptorText.TryParseMethodDescriptor (originalDescriptor, out var originalParameterTokens, out _)) { - originalParameterTypes = originalParameterTokens.ConvertAll (JniDescriptorText.JniTypeTokenToJavaSource); - return true; - } - originalParameterTypes = []; - return false; - } } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs index 858b3f6c066..6f9938fc8cd 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs @@ -23,14 +23,6 @@ public void WritesOriginalKeepRulesAndReachabilityManifest () string manifest = Path.Combine (path, "reachability.txt"); var fixture = new JniFixtureBuilder (); - fixture.Metadata.AddAssemblyReference ( - fixture.Metadata.GetOrAddString ("Mono.Android"), - new Version (1, 0, 0, 0), - default, - default, - default, - default); - int fieldStart = fixture.NextFieldRid; var field = fixture.Metadata.AddFieldDefinition ( FieldAttributes.Public, @@ -42,6 +34,10 @@ public void WritesOriginalKeepRulesAndReachabilityManifest () MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName); var firstOverload = fixture.AddVoidMethod ("OnClick", fixture.EmitReturnOnlyBody ()); var secondOverload = fixture.AddVoidMethod ("OnClickWithIndex", fixture.EmitReturnOnlyBody ()); + fixture.AddVoidMethod ("DirectLookup", fixture.EmitLoadStringBody ( + fixture.String ("a/b/E"), + fixture.String ("x"), + fixture.String ("()V"))); var type = fixture.AddType ("Managed", "MyView", fieldStart, methodStart); var property = fixture.Metadata.AddProperty ( @@ -55,6 +51,21 @@ public void WritesOriginalKeepRulesAndReachabilityManifest () fixture.ValueTypeReference); fixture.Metadata.AddEventMap (type, @event); + int interfaceMethodStart = fixture.NextMethodRid; + var interfaceMethod = fixture.Metadata.AddMethodDefinition ( + MethodAttributes.Public | MethodAttributes.Abstract | MethodAttributes.Virtual | MethodAttributes.NewSlot, + MethodImplAttributes.IL, + fixture.Metadata.GetOrAddString ("Invoke"), + fixture.Metadata.GetOrAddBlob (new byte [] { 0x20, 0x00, 0x01 }), + 0, + default); + var interfaceType = fixture.AddType ( + "Managed", + "ICallback", + fixture.NextFieldRid, + interfaceMethodStart, + TypeAttributes.Public | TypeAttributes.Interface | TypeAttributes.Abstract); + fixture.Metadata.AddCustomAttribute (type, fixture.RegisterCtor1, fixture.AttributeBlob ("a/b/C")); fixture.Metadata.AddCustomAttribute (constructor, fixture.RegisterCtor3, fixture.AttributeBlob (".ctor", "()V", "")); fixture.Metadata.AddCustomAttribute (firstOverload, fixture.RegisterCtor3, fixture.AttributeBlob ("a", "(La/b/D;)V", "")); @@ -62,6 +73,8 @@ public void WritesOriginalKeepRulesAndReachabilityManifest () fixture.Metadata.AddCustomAttribute (field, fixture.RegisterCtor1, fixture.AttributeBlob ("c")); fixture.Metadata.AddCustomAttribute (property, fixture.RegisterCtor1, fixture.AttributeBlob ("d")); fixture.Metadata.AddCustomAttribute (@event, fixture.RegisterCtor1, fixture.AttributeBlob ("e")); + fixture.Metadata.AddCustomAttribute (interfaceType, fixture.RegisterCtor1, fixture.AttributeBlob ("a/b/F")); + fixture.Metadata.AddCustomAttribute (interfaceMethod, fixture.RegisterCtor3, fixture.AttributeBlob ("y", "()V", "")); File.WriteAllBytes (assembly, fixture.Serialize ()); File.WriteAllText (mapping, """ @@ -73,6 +86,10 @@ int count -> c boolean enabled -> d java.lang.Object listener -> e android.view.View -> a.b.D: + acme.orig.DirectTarget -> a.b.E: + void run() -> x + acme.orig.ICallback -> a.b.F: + void invoke() -> y """); @@ -86,24 +103,41 @@ java.lang.Object listener -> e Assert.IsTrue (task.Execute (), "Task should succeed."); Assert.AreEqual (""" - # ACW for Fixture + -keep,allowobfuscation class acme.orig.DirectTarget + -keepclassmembers,allowobfuscation class acme.orig.DirectTarget { + *** run(...); + } + + -keep,allowobfuscation class acme.orig.ICallback + -keepclassmembers,allowobfuscation class acme.orig.ICallback { + *** invoke(...); + } + -keep,allowobfuscation class acme.orig.MyView -keepclassmembers,allowobfuscation class acme.orig.MyView { - (...); - *** onClick(...); - *** onClick(...); *** count; *** enabled; *** listener; + *** onClick(...); + (...); + } + + -keep,allowobfuscation class android.view.View + -keepclassmembers,allowobfuscation class android.view.View { } """, File.ReadAllText (proguard)); Assert.AreEqual (""" + C acme/orig/DirectTarget + C acme/orig/ICallback C acme/orig/MyView + C android/view/View F acme/orig/MyView count F acme/orig/MyView enabled F acme/orig/MyView listener + M acme/orig/DirectTarget run() + M acme/orig/ICallback invoke() M acme/orig/MyView () M acme/orig/MyView onClick(android.view.View) M acme/orig/MyView onClick(android.view.View,int) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs index 4fa3d72bd85..e937aa1ba3d 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.IO; +using System.Linq; using System.Reflection; using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; @@ -30,6 +31,17 @@ static JniRewriteResult Rewrite (byte [] sourceImage, R8Mapping mapping) static R8Mapping Mapping (string text) => R8Mapping.Parse (new StringReader (text)); + static void AssertReverseScanMatchesRewrite (byte [] rewrittenImage, R8Mapping rewriteMapping, string mappingText) + { + R8Mapping scanMapping = Mapping (mappingText); + var log = new TaskLoggingHelper (new MockBuildEngine (TestContext.Out), nameof (JniAssemblyRewriterTests)); + JniAssemblyRewriter.ScanRewrittenAssembly (rewrittenImage, scanMapping, log); + CollectionAssert.AreEquivalent ( + rewriteMapping.AccessedEntries.ToArray (), + scanMapping.AccessedEntries.ToArray (), + "The reverse post-link scan should recognize every mapping consumed by forward rewriting."); + } + static IReadOnlyList AttributeStringArgs (MetadataReader reader, CustomAttributeHandleCollection attributes, EntityHandle ctor) { foreach (CustomAttributeHandle handle in attributes) { @@ -190,14 +202,17 @@ public void RewritesAttributesAndLoadedStrings () fixture.Metadata.AddNestedType (nested, myView); byte [] source = fixture.Serialize (); - JniRewriteResult result = Rewrite (source, Mapping ( + const string mappingText = "acme.orig.MyView -> a.b.C:\n" + " void onClick(acme.orig.Callback) -> a\n" + " int someField -> x\n" + " void run() -> b\n" + " void (acme.orig.Callback) -> \n" + "acme.orig.Callback -> a.b.Cb:\n" + - "acme.orig.Marker -> a.b.D:\n")); + "acme.orig.Marker -> a.b.D:\n"; + R8Mapping mapping = Mapping (mappingText); + JniRewriteResult result = Rewrite (source, mapping); + AssertReverseScanMatchesRewrite (result.Image, mapping, mappingText); Assert.Greater (result.ReplacementCount, 0); @@ -247,7 +262,10 @@ public void RewritesTrimmableTypeMapKeysAndAliases () fixture.Metadata.AddCustomAttribute (aliasHolder, fixture.JavaPeerAliasesCtor1, fixture.StringArrayAttributeBlob ("acme/orig/MyView[0]", "acme/orig/MyView[1]", "unmapped/Type[0]")); - JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ("acme.orig.MyView -> a.b.C:\n")); + const string mappingText = "acme.orig.MyView -> a.b.C:\n"; + R8Mapping mapping = Mapping (mappingText); + JniRewriteResult result = Rewrite (fixture.Serialize (), mapping); + AssertReverseScanMatchesRewrite (result.Image, mapping, mappingText); using var peReader = new PEReader (ImmutableArray.Create (result.Image)); MetadataReader reader = peReader.GetMetadataReader (); @@ -349,12 +367,13 @@ public void IdentifiesUtf8FieldRvaDataStructurally () var fixture = new JniFixtureBuilder (); FieldDefinitionHandle nameField = fixture.AddUtf8Field ("onClick"); FieldDefinitionHandle signatureField = fixture.AddUtf8Field ("(Lacme/orig/Callback;)V"); + FieldDefinitionHandle embeddedNullField = fixture.AddUtf8Field ("onClick\0not-padding"); using var peReader = new PEReader (ImmutableArray.Create (fixture.Serialize ())); MetadataReader reader = peReader.GetMetadataReader (); FieldRvaTable table = FieldRvaTable.Read (peReader, reader); - Assert.AreEqual (2, table.Entries.Count); + Assert.AreEqual (3, table.Entries.Count); FieldRvaEntry name = table.Get (nameField); Assert.IsNotNull (name); @@ -365,6 +384,10 @@ public void IdentifiesUtf8FieldRvaDataStructurally () Assert.IsNotNull (signature); Assert.IsTrue (signature.IsUtf8Datum); Assert.AreEqual ("(Lacme/orig/Callback;)V", signature.Utf8Value); + + FieldRvaEntry embeddedNull = table.Get (embeddedNullField); + Assert.IsNotNull (embeddedNull); + Assert.IsFalse (embeddedNull.IsUtf8Datum, "Non-zero data after the first NUL is not rewrite padding."); } [Test] @@ -410,11 +433,20 @@ public void RewritesUtf8FieldRvaJniNamesAndSignatures () TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class, fixture.JavaPeerProxyReference); byte [] source = fixture.Serialize (); - JniRewriteResult result = Rewrite (source, Mapping ( + const string mappingText = "acme.orig.MyView -> a.b.C:\n" + " void onClick(acme.orig.Callback) -> a\n" + " void run(acme.orig.Callback) -> aMuchLongerObfuscatedName\n" + - "acme.orig.Callback -> a.b.Cb:\n")); + "acme.orig.Callback -> a.b.Cb:\n"; + R8Mapping mapping = Mapping (mappingText); + JniRewriteResult result = Rewrite (source, mapping); + using (var rewrittenReader = new PEReader (ImmutableArray.Create (result.Image))) { + MetadataReader rewrittenMetadata = rewrittenReader.GetMetadataReader (); + FieldRvaTable rewrittenFields = FieldRvaTable.Read (rewrittenReader, rewrittenMetadata); + Assert.IsTrue (rewrittenFields.Get (nameField)?.IsUtf8Datum, "Rewritten method-name FieldRVA data should remain structurally recognizable."); + Assert.IsTrue (rewrittenFields.Get (signatureField)?.IsUtf8Datum, "Rewritten signature FieldRVA data should remain structurally recognizable."); + } + AssertReverseScanMatchesRewrite (result.Image, mapping, mappingText); using var peReader = new PEReader (ImmutableArray.Create (result.Image)); MetadataReader reader = peReader.GetMetadataReader (); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs index b508de720d0..c37daa8ef67 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs @@ -368,5 +368,29 @@ int ambiguous2 -> c Assert.AreEqual ("first", original); Assert.IsFalse (mapping.TryGetOriginalFieldName ("acme/orig/MyView", "c", out _)); } + + [Test] + public void ReverseMappingRecordsEveryAmbiguousMemberCandidate () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + int first -> a + java.lang.String second -> a + void run() -> b + void invoke(int) -> b + + """)); + IJniNameMapping reverse = mapping.CreateReverseMapping (); + + Assert.IsTrue (reverse.TryMapField ("a/b/C", "a", out _)); + Assert.IsTrue (reverse.TryMapMethodByNameOnly ("a/b/C", "b", out _)); + CollectionAssert.AreEquivalent (new [] { + "C\tacme/orig/MyView", + "F\tacme/orig/MyView\tfirst", + "F\tacme/orig/MyView\tsecond", + "M\tacme/orig/MyView\tinvoke(int)", + "M\tacme/orig/MyView\trun()", + }, mapping.AccessedEntries); + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/FieldRvaTable.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/FieldRvaTable.cs index b11449fd82b..d7400ffe698 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/FieldRvaTable.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/FieldRvaTable.cs @@ -128,19 +128,22 @@ static byte [] ReadMappedData (PEReader peReader, int rva, int size, string fiel return null; } - // Exactly one NUL, at the very end: a C string that fully occupies the field. - if (data.Length == 0 || data [data.Length - 1] != 0) { + // Rewriting a JNI value to a shorter string preserves the original field type and + // zero-fills its remaining bytes so metadata tokens do not move. Accept that padding, + // but reject non-zero data after the first C-string terminator. + int terminator = Array.IndexOf (data, (byte) 0); + if (terminator < 0) { return null; } - for (int i = 0; i < data.Length - 1; i++) { - if (data [i] == 0) { + for (int i = terminator + 1; i < data.Length; i++) { + if (data [i] != 0) { return null; } } try { return new UTF8Encoding (encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) - .GetString (data, 0, data.Length - 1); + .GetString (data, 0, terminator); } catch (ArgumentException) { return null; } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs index fa47fe17310..3736805550b 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs @@ -55,5 +55,22 @@ public static JniRewriteResult Rewrite (byte [] sourceImage, R8Mapping mapping, return new JniRewriteResult (rebuilt.Image, plan.ReplacementCount, rebuilt.StrongNameSignatureCleared); } + + public static void ScanRewrittenAssembly (byte [] sourceImage, R8Mapping mapping, TaskLoggingHelper log) + { + using var peReader = new PEReader (ImmutableArray.Create (sourceImage)); + if (!peReader.HasMetadata) { + throw new JniRewriteException ("The file contains no managed metadata."); + } + + MetadataReader reader = peReader.GetMetadataReader (); + ScanRewrittenAssembly (peReader, reader, mapping, log); + } + + public static void ScanRewrittenAssembly (PEReader peReader, MetadataReader reader, R8Mapping mapping, TaskLoggingHelper log) + { + FieldRvaTable fieldRvaTable = FieldRvaTable.Read (peReader, reader); + new JniRewritePlanner (peReader, reader, mapping.CreateReverseMapping (), fieldRvaTable, log).CreatePlan (); + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs index 3efadf26f52..3244336103c 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs @@ -50,21 +50,21 @@ public Utf8Use (Utf8Role role, string? ownerJniName, string? pairedSignature) readonly PEReader peReader; readonly MetadataReader reader; - readonly R8Mapping mapping; + readonly IJniNameMapping mapping; readonly FieldRvaTable fieldRvaTable; readonly TaskLoggingHelper log; readonly Func renameClass; readonly Dictionary ownerJniNameCache = new (); readonly Dictionary> utf8Uses = new (); - public JniRewritePlanner (PEReader peReader, MetadataReader reader, R8Mapping mapping, FieldRvaTable fieldRvaTable, TaskLoggingHelper log) + public JniRewritePlanner (PEReader peReader, MetadataReader reader, IJniNameMapping mapping, FieldRvaTable fieldRvaTable, TaskLoggingHelper log) { this.peReader = peReader; this.reader = reader; this.mapping = mapping; this.fieldRvaTable = fieldRvaTable; this.log = log; - renameClass = className => mapping.TryGetRenamedClass (className, out string renamed) ? renamed : null; + renameClass = className => mapping.TryMapClass (className, out string renamed) ? renamed : null; } public JniRewritePlan CreatePlan () @@ -148,7 +148,7 @@ void PlanJavaPeerAliasesAttributes (JniRewritePlan plan, CustomAttributeHandleCo jniName = value.Substring (0, suffixStart); } - return mapping.TryGetRenamedClass (jniName, out string renamed) ? renamed + suffix : null; + return mapping.TryMapClass (jniName, out string renamed) ? renamed + suffix : null; } static bool IsDecimalIndex (string value, int start, int end) @@ -271,7 +271,7 @@ bool IsJavaPeerProxy (EntityHandle baseType) void PlanTypeLevelAttributes (JniRewritePlan plan, TypeDefinition typeDef, string? ownerJniName) { - if (ownerJniName == null || !mapping.TryGetRenamedClass (ownerJniName, out string renamedClass)) { + if (ownerJniName == null || !mapping.TryMapClass (ownerJniName, out string renamedClass)) { return; } @@ -364,7 +364,7 @@ void PlanMemberNameAttributes (JniRewritePlan plan, CustomAttributeHandleCollect continue; } - if (!mapping.TryGetRenamedField (ownerJniName, jniFieldName, out string renamedField)) { + if (!mapping.TryMapField (ownerJniName, jniFieldName, out string renamedField)) { continue; } @@ -382,10 +382,10 @@ void PlanMemberNameAttributes (JniRewritePlan plan, CustomAttributeHandleCollect if (jniDescriptor != null && JniDescriptorText.IsValidMethodDescriptor (jniDescriptor)) { var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (jniDescriptor); - return mapping.TryGetRenamedMethod (ownerJniName, mappingName, javaParams, out string renamed) ? renamed : null; + return mapping.TryMapMethod (ownerJniName, mappingName, javaParams, out string renamed) ? renamed : null; } - return mapping.TryGetRenamedMethodByNameOnly (ownerJniName, mappingName, out string renamedByNameOnly) + return mapping.TryMapMethodByNameOnly (ownerJniName, mappingName, out string renamedByNameOnly) ? renamedByNameOnly : null; } @@ -419,12 +419,12 @@ void PlanMethodBody (JniRewritePlan plan, MethodDefinitionHandle methodHandle, s string value = ReadUserString (il, operandOffset); if (referencedOwnerJniName != null && pendingMemberName != null) { if (JniDescriptorText.IsValidFieldDescriptor (value) && - mapping.TryGetRenamedField (referencedOwnerJniName, pendingMemberName, out string renamedField)) { + mapping.TryMapField (referencedOwnerJniName, pendingMemberName, out string renamedField)) { plan.AddUserString (methodHandle, pendingMemberNameOffset, renamedField); } else if (JniDescriptorText.IsValidMethodDescriptor (value)) { var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (value); string mappingName = R8Mapping.JniMemberNameToMappingName (pendingMemberName); - if (mapping.TryGetRenamedMethod (referencedOwnerJniName, mappingName, javaParams, out string renamedMethod)) { + if (mapping.TryMapMethod (referencedOwnerJniName, mappingName, javaParams, out string renamedMethod)) { plan.AddUserString (methodHandle, pendingMemberNameOffset, renamedMethod); } } @@ -486,7 +486,7 @@ void PlanMethodBody (JniRewritePlan plan, MethodDefinitionHandle methodHandle, s } string value = ReadUserString (il, operandOffset); - if (!mapping.TryGetRenamedClass (value, out _)) { + if (!mapping.TryMapClass (value, out _)) { return; } if (referencedClass != null && referencedClass != value) { @@ -574,14 +574,14 @@ IEnumerable GetUses (FieldDefinitionHandle field) if (use.Role == Utf8Role.MethodName && use.OwnerJniName != null && use.PairedSignature != null) { var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (use.PairedSignature); string mappingName = R8Mapping.JniMemberNameToMappingName (value); - return mapping.TryGetRenamedMethod (use.OwnerJniName, mappingName, javaParams, out string renamed) ? renamed : null; + return mapping.TryMapMethod (use.OwnerJniName, mappingName, javaParams, out string renamed) ? renamed : null; } if (JniDescriptorText.IsValidMethodDescriptor (value) || JniDescriptorText.IsValidFieldDescriptor (value)) { return JniDescriptorText.TryRewriteDescriptor (value, renameClass, out string rewritten) ? rewritten : null; } - if (use.Role == Utf8Role.Unknown && mapping.TryGetRenamedClass (value, out string renamedClass)) { + if (use.Role == Utf8Role.Unknown && mapping.TryMapClass (value, out string renamedClass)) { return renamedClass; } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs index 433d99d38a2..4f0112a0cf0 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs @@ -21,9 +21,9 @@ namespace Xamarin.Android.Tasks.JniRemapping /// static class LdstrRewriter { - public static bool TryRewrite (string value, string? ownerJniName, R8Mapping mapping, out string rewritten) + public static bool TryRewrite (string value, string? ownerJniName, IJniNameMapping mapping, out string rewritten) { - Func renameClass = className => mapping.TryGetRenamedClass (className, out string renamed) ? renamed : null; + Func renameClass = className => mapping.TryMapClass (className, out string renamed) ? renamed : null; if (value.IndexOf ('\n') >= 0) { return TryRewriteMultilineRegisterNatives (value, ownerJniName, mapping, renameClass, out rewritten); @@ -41,7 +41,7 @@ public static bool TryRewrite (string value, string? ownerJniName, R8Mapping map return JniDescriptorText.TryRewriteDescriptor (value, renameClass, out rewritten); } - if (mapping.TryGetRenamedClass (value, out string renamedWhole)) { + if (mapping.TryMapClass (value, out string renamedWhole)) { rewritten = renamedWhole; return true; } @@ -50,7 +50,7 @@ public static bool TryRewrite (string value, string? ownerJniName, R8Mapping map return false; } - static bool TryRewriteMultilineRegisterNatives (string value, string? ownerJniName, R8Mapping mapping, Func renameClass, out string rewritten) + static bool TryRewriteMultilineRegisterNatives (string value, string? ownerJniName, IJniNameMapping mapping, Func renameClass, out string rewritten) { string [] lines = value.Split ('\n'); bool changed = false; @@ -73,7 +73,7 @@ static bool TryRewriteMultilineRegisterNatives (string value, string? ownerJniNa /// Rewrites a single "name:descriptor:connector[:callbackDeclaringType]" line, as used by /// AndroidRuntime.RegisterNativeMembers / FastRegisterNativeMembers. /// - static bool TryRewriteRegisterNativesLine (string line, string? ownerJniName, R8Mapping mapping, Func renameClass, out string rewritten) + static bool TryRewriteRegisterNativesLine (string line, string? ownerJniName, IJniNameMapping mapping, Func renameClass, out string rewritten) { rewritten = line; @@ -99,7 +99,7 @@ static bool TryRewriteRegisterNativesLine (string line, string? ownerJniName, R8 if (ownerJniName != null) { var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (descriptor); string mappingName = R8Mapping.JniMemberNameToMappingName (name); - if (mapping.TryGetRenamedMethod (ownerJniName, mappingName, javaParams, out string renamedMethod)) { + if (mapping.TryMapMethod (ownerJniName, mappingName, javaParams, out string renamedMethod)) { newName = renamedMethod; changed = true; } @@ -120,7 +120,7 @@ static bool TryRewriteRegisterNativesLine (string line, string? ownerJniName, R8 /// Rewrites a JniPeerMembers encoded member id: "name.descriptor" for a method /// (descriptor starts with '(') or a field (descriptor is a single type token). /// - static bool TryRewriteJniPeerMemberId (string value, string? ownerJniName, R8Mapping mapping, Func renameClass, out string rewritten) + static bool TryRewriteJniPeerMemberId (string value, string? ownerJniName, IJniNameMapping mapping, Func renameClass, out string rewritten) { rewritten = value; @@ -144,12 +144,12 @@ static bool TryRewriteJniPeerMemberId (string value, string? ownerJniName, R8Map if (isMethod) { var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (descriptor); string mappingName = R8Mapping.JniMemberNameToMappingName (name); - if (mapping.TryGetRenamedMethod (ownerJniName, mappingName, javaParams, out string renamedMethod)) { + if (mapping.TryMapMethod (ownerJniName, mappingName, javaParams, out string renamedMethod)) { newName = renamedMethod; changed = true; } } else { - if (mapping.TryGetRenamedField (ownerJniName, name, out string renamedField)) { + if (mapping.TryMapField (ownerJniName, name, out string renamedField)) { newName = renamedField; changed = true; } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index afcf9dca96a..50746d51108 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -6,11 +6,19 @@ namespace Xamarin.Android.Tasks.JniRemapping { + interface IJniNameMapping + { + bool TryMapClass (string className, out string mappedClassName); + bool TryMapField (string owningClassName, string fieldName, out string mappedFieldName); + bool TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, out string mappedMethodName); + bool TryMapMethodByNameOnly (string owningClassName, string methodName, out string mappedMethodName); + } + /// /// A parsed R8/ProGuard mapping.txt file, exposing the class, field, and method /// renames it describes using JNI-style ('/'-separated, '$' for nested classes) names. /// - sealed class R8Mapping + sealed class R8Mapping : IJniNameMapping { // Original JNI class name -> obfuscated JNI class name. readonly Dictionary classes = new Dictionary (StringComparer.Ordinal); @@ -23,10 +31,29 @@ sealed class R8Mapping // Original JNI class name -> ("name(javaParam,javaParam,...)" -> obfuscated method name). readonly Dictionary> methods = new Dictionary> (StringComparer.Ordinal); + + // Reverse member indexes are scoped by original class. Fields retain every candidate because + // R8 may reuse an obfuscated field name for fields with different JVM descriptors. + readonly Dictionary>> originalFields = new Dictionary>> (StringComparer.Ordinal); + readonly Dictionary> originalMethods = new Dictionary> (StringComparer.Ordinal); readonly HashSet accessedEntries = new HashSet (StringComparer.Ordinal); public IEnumerable AccessedEntries => accessedEntries; + bool IJniNameMapping.TryMapClass (string className, out string mappedClassName) + => TryGetRenamedClass (className, out mappedClassName); + + bool IJniNameMapping.TryMapField (string owningClassName, string fieldName, out string mappedFieldName) + => TryGetRenamedField (owningClassName, fieldName, out mappedFieldName); + + bool IJniNameMapping.TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, out string mappedMethodName) + => TryGetRenamedMethod (owningClassName, methodName, javaParameterTypes, out mappedMethodName); + + bool IJniNameMapping.TryMapMethodByNameOnly (string owningClassName, string methodName, out string mappedMethodName) + => TryGetRenamedMethodByNameOnly (owningClassName, methodName, out mappedMethodName); + + internal IJniNameMapping CreateReverseMapping () => new ReverseR8Mapping (this); + public static R8Mapping Load (string path) { using var reader = new StreamReader (path); @@ -107,9 +134,47 @@ public static R8Mapping Parse (TextReader reader) } } + mapping.BuildReverseMemberIndexes (); return mapping; } + void BuildReverseMemberIndexes () + { + foreach (var classEntry in fields) { + var reverse = new Dictionary> (StringComparer.Ordinal); + originalFields [classEntry.Key] = reverse; + foreach (var field in classEntry.Value) { + if (!reverse.TryGetValue (field.Value, out var originalNames)) { + reverse [field.Value] = originalNames = new List (); + } + originalNames.Add (field.Key); + } + } + + foreach (var classEntry in methods) { + var reverseMethods = new Dictionary (StringComparer.Ordinal); + originalMethods [classEntry.Key] = reverseMethods; + foreach (var method in classEntry.Value) { + if (method.Value.Length == 0) { + continue; + } + int parameterStart = method.Key.IndexOf ('('); + string originalName = parameterStart < 0 ? method.Key : method.Key.Substring (0, parameterStart); + string parameters = parameterStart < 0 ? "()" : method.Key.Substring (parameterStart); + AddUnambiguousReverseEntry (reverseMethods, method.Value + parameters, originalName); + } + } + } + + static void AddUnambiguousReverseEntry (Dictionary entries, string key, string value) + { + if (entries.TryGetValue (key, out string? existing) && !String.Equals (existing, value, StringComparison.Ordinal)) { + entries [key] = ""; + } else if (existing == null) { + entries [key] = value; + } + } + /// /// Builds the lookup key used for methods: the JNI/Java member name plus its /// parameter types (in Java source form, e.g. "int", "android.os.Bundle", "java.lang.String[]"). @@ -170,44 +235,18 @@ public IEnumerable GetOriginalMethodNames (string originalJniClassName, public bool TryGetOriginalMethodName (string originalJniClassName, string obfuscatedMethodName, IReadOnlyList originalJavaParameterTypes, out string originalMethodName) { originalMethodName = ""; - if (!methods.TryGetValue (originalJniClassName, out var classMethods)) { - return false; - } - - string parameters = "(" + string.Join (",", originalJavaParameterTypes) + ")"; - foreach (var entry in classMethods) { - if (!String.Equals (entry.Value, obfuscatedMethodName, StringComparison.Ordinal) || - !entry.Key.EndsWith (parameters, StringComparison.Ordinal)) { - continue; - } - int parameterStart = entry.Key.Length - parameters.Length; - string candidate = entry.Key.Substring (0, parameterStart); - if (originalMethodName.Length != 0 && !String.Equals (originalMethodName, candidate, StringComparison.Ordinal)) { - originalMethodName = ""; - return false; - } - originalMethodName = candidate; - } - return originalMethodName.Length != 0; + return originalMethods.TryGetValue (originalJniClassName, out var classMethods) && + classMethods.TryGetValue (BuildMethodKey (obfuscatedMethodName, originalJavaParameterTypes), out originalMethodName) && + originalMethodName.Length != 0; } public bool TryGetOriginalFieldName (string originalJniClassName, string obfuscatedFieldName, out string originalFieldName) { originalFieldName = ""; - if (!fields.TryGetValue (originalJniClassName, out var classFields)) { - return false; - } - foreach (var entry in classFields) { - if (!String.Equals (entry.Value, obfuscatedFieldName, StringComparison.Ordinal)) { - continue; - } - if (originalFieldName.Length != 0 && !String.Equals (originalFieldName, entry.Key, StringComparison.Ordinal)) { - originalFieldName = ""; - return false; - } - originalFieldName = entry.Key; - } - return originalFieldName.Length != 0; + return originalFields.TryGetValue (originalJniClassName, out var classFields) && + classFields.TryGetValue (obfuscatedFieldName, out var originalNames) && + originalNames.Count == 1 && + (originalFieldName = originalNames [0]).Length != 0; } public bool TryGetRenamedField (string owningJniClassName, string originalFieldName, out string obfuscatedFieldName) @@ -384,6 +423,99 @@ internal static string CreateManifestContent (IEnumerable entries) return writer.ToString (); } + sealed class ReverseR8Mapping : IJniNameMapping + { + readonly R8Mapping mapping; + + public ReverseR8Mapping (R8Mapping mapping) + { + this.mapping = mapping; + } + + public bool TryMapClass (string className, out string mappedClassName) + { + if (!mapping.TryGetOriginalClass (className, out mappedClassName)) { + return false; + } + mapping.accessedEntries.Add (BuildClassEntry (mappedClassName)); + return true; + } + + public bool TryMapField (string owningClassName, string fieldName, out string mappedFieldName) + { + mappedFieldName = ""; + if (!mapping.TryGetOriginalClass (owningClassName, out string originalClassName) || + !mapping.originalFields.TryGetValue (originalClassName, out var classFields) || + !classFields.TryGetValue (fieldName, out var originalNames) || + originalNames.Count == 0) { + return false; + } + mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); + foreach (string originalName in originalNames) { + mapping.accessedEntries.Add (BuildFieldEntry (originalClassName, originalName)); + } + mappedFieldName = originalNames [0]; + return true; + } + + public bool TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, out string mappedMethodName) + { + mappedMethodName = ""; + if (!mapping.TryGetOriginalClass (owningClassName, out string originalClassName)) { + return false; + } + + var originalParameterTypes = new List (javaParameterTypes.Count); + foreach (string parameterType in javaParameterTypes) { + originalParameterTypes.Add (GetOriginalJavaType (parameterType)); + } + if (!mapping.TryGetOriginalMethodName (originalClassName, methodName, originalParameterTypes, out mappedMethodName)) { + return false; + } + mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); + mapping.accessedEntries.Add (BuildMethodEntry ( + originalClassName, + BuildMethodKey (mappedMethodName, originalParameterTypes))); + return true; + } + + public bool TryMapMethodByNameOnly (string owningClassName, string methodName, out string mappedMethodName) + { + mappedMethodName = ""; + if (!mapping.TryGetOriginalClass (owningClassName, out string originalClassName) || + !mapping.methods.TryGetValue (originalClassName, out var classMethods)) { + return false; + } + + string? firstOriginalName = null; + foreach (var entry in classMethods) { + if (!String.Equals (entry.Value, methodName, StringComparison.Ordinal)) { + continue; + } + int parameterStart = entry.Key.IndexOf ('('); + firstOriginalName ??= parameterStart < 0 ? entry.Key : entry.Key.Substring (0, parameterStart); + mapping.accessedEntries.Add (BuildMethodEntry (originalClassName, entry.Key)); + } + if (firstOriginalName == null) { + return false; + } + mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); + mappedMethodName = firstOriginalName; + return true; + } + + string GetOriginalJavaType (string javaType) + { + int suffixStart = javaType.IndexOf ('['); + string suffix = suffixStart < 0 ? "" : javaType.Substring (suffixStart); + string elementType = suffixStart < 0 ? javaType : javaType.Substring (0, suffixStart); + string jniType = JavaNameToJni (elementType); + return mapping.TryGetOriginalClass (jniType, out string originalJniType) + ? originalJniType.Replace ('/', '.') + suffix + : javaType; + } + } + static bool IsRemovedClassName (string className) => className.StartsWith ("R8$$REMOVED$$CLASS$$", StringComparison.Ordinal); From 569adaa537c1d36382b40f7128853a04c438447f Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 31 Aug 2026 20:40:12 +0200 Subject: [PATCH 09/14] Fix Proguard configuration test line endings Normalize expected multiline output to the host line ending so the new test passes on Windows as well as Unix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/GenerateProguardConfigurationTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs index 6f9938fc8cd..bdd89ce140a 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs @@ -127,7 +127,7 @@ void invoke() -> y } - """, File.ReadAllText (proguard)); + """.ReplaceLineEndings (), File.ReadAllText (proguard)); Assert.AreEqual (""" C acme/orig/DirectTarget C acme/orig/ICallback @@ -142,7 +142,7 @@ F acme/orig/MyView listener M acme/orig/MyView onClick(android.view.View) M acme/orig/MyView onClick(android.view.View,int) - """, File.ReadAllText (manifest)); + """.ReplaceLineEndings (), File.ReadAllText (manifest)); } } } From fa5b793f63d2166de12250ee04c3c96065a6cc41 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 31 Aug 2026 21:56:12 +0200 Subject: [PATCH 10/14] Harden R8 JNI rewrite incrementality Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 32 +++-- ...id.Sdk.TypeMap.Trimmable.NativeAOT.targets | 30 ++++- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 42 +++++-- .../TrimmableTypeMapBuildTests.cs | 112 ++++++++++++++++++ 4 files changed, 190 insertions(+), 26 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index f3f793a0a5c..1011d032ad0 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets @@ -24,30 +24,48 @@ - <_AndroidR8JniRewrittenAssemblyDirectory>$(IntermediateOutputPath)r8-jni-rewritten/ + <_AndroidR8JniRewriteStamp>$(_AndroidStampDirectory)_AndroidRewriteJniNamesBeforeILLink.stamp + <_AndroidR8JniOriginalManagedAssembly Remove="@(_AndroidR8JniOriginalManagedAssembly)" /> + <_AndroidR8JniRewrittenManagedAssembly Remove="@(_AndroidR8JniRewrittenManagedAssembly)" /> + <_AndroidR8JniExpectedRewriteOutput Remove="@(_AndroidR8JniExpectedRewriteOutput)" /> + <_AndroidR8JniMissingRewriteOutput Remove="@(_AndroidR8JniMissingRewriteOutput)" /> <_AndroidR8JniOriginalManagedAssembly Include="@(ManagedAssemblyToLink)" /> + <_AndroidR8JniRewrittenManagedAssembly Include="@(_AndroidR8JniOriginalManagedAssembly->'$(_AndroidR8JniRewrittenAssemblyDirectory)%(Filename)%(Extension)')" /> + <_AndroidR8JniExpectedRewriteOutput Include="@(_AndroidR8JniRewrittenManagedAssembly);$(_AndroidR8JniRewriteManifest)" /> + <_AndroidR8JniMissingRewriteOutput Include="@(_AndroidR8JniExpectedRewriteOutput)" Condition="!Exists('%(Identity)')" /> + + + + - - + RewriteManifestFile="$(_AndroidR8JniRewriteManifest)" /> + + + <_AndroidR8JniExpectedRewriteOutput Remove="@(_AndroidR8JniExpectedRewriteOutput)" /> + <_AndroidR8JniMissingRewriteOutput Remove="@(_AndroidR8JniMissingRewriteOutput)" /> diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets index e8ad3dca3da..927c0ece523 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets @@ -74,14 +74,18 @@ - <_AndroidR8JniRewrittenAssemblyDirectory>$(IntermediateOutputPath)r8-jni-rewritten/ + <_AndroidR8JniRewriteStamp>$(_AndroidStampDirectory)_AndroidRewriteJniNamesBeforeIlc.stamp + <_AndroidR8JniIlcAssembly Remove="@(_AndroidR8JniIlcAssembly)" /> + <_AndroidR8JniRewrittenIlcAssembly Remove="@(_AndroidR8JniRewrittenIlcAssembly)" /> + <_AndroidR8JniExpectedRewriteOutput Remove="@(_AndroidR8JniExpectedRewriteOutput)" /> + <_AndroidR8JniMissingRewriteOutput Remove="@(_AndroidR8JniMissingRewriteOutput)" /> <_AndroidR8JniIlcAssembly Include="@(ManagedBinary)"> ManagedBinary @@ -89,14 +93,25 @@ IlcReference <_AndroidR8JniIlcAssembly Remove="@(_AndroidR8JniIlcAssembly)" Condition=" '%(Extension)' != '.dll' " /> + <_AndroidR8JniRewrittenIlcAssembly Include="@(_AndroidR8JniIlcAssembly->'$(_AndroidR8JniRewrittenAssemblyDirectory)%(Filename)%(Extension)')" /> + <_AndroidR8JniExpectedRewriteOutput Include="@(_AndroidR8JniRewrittenIlcAssembly);$(_AndroidR8JniRewriteManifest)" /> + <_AndroidR8JniMissingRewriteOutput Include="@(_AndroidR8JniExpectedRewriteOutput)" Condition="!Exists('%(Identity)')" /> + + + + - - + RewriteManifestFile="$(_AndroidR8JniRewriteManifest)" /> + @@ -105,6 +120,9 @@ + + <_AndroidR8JniExpectedRewriteOutput Remove="@(_AndroidR8JniExpectedRewriteOutput)" /> + <_AndroidR8JniMissingRewriteOutput Remove="@(_AndroidR8JniMissingRewriteOutput)" /> diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index b135c8da75a..70b084d00fb 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -38,26 +38,27 @@ <_TypeMapJavaStubsSourceDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_TypeMapJavaOutputDirectory) <_TypeMapJavaStubsSourceDirectory Condition=" '$(_TypeMapJavaStubsSourceDirectory)' == '' ">$(_TypeMapJavaOutputDirectory) <_PostTrimTrimmableTypeMapJavaStamp>$(_PostTrimTypeMapJavaBaseOutputDir)stamp/_GeneratePostTrimTrimmableTypeMapJavaSources.stamp + <_AndroidR8JniSeedDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_TypeMapBaseOutputDir)r8-jni-seed/ + <_AndroidR8JniSeedAcwMap Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)acw-map.txt + <_AndroidR8JniSeedApplicationRegistration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)java/net/dot/android/ApplicationRegistration.java - <_PreTrimTypeMapAcwMapOutputFile Condition=" '$(_AndroidRuntime)' != 'CoreCLR' or '$(PublishTrimmed)' != 'true' ">$(IntermediateOutputPath)acw-map.txt - <_PreTrimTypeMapApplicationRegistrationOutputFile Condition=" '$(_AndroidRuntime)' != 'CoreCLR' or '$(PublishTrimmed)' != 'true' ">$(IntermediateOutputPath)android/src/net/dot/android/ApplicationRegistration.java - <_PreTrimTypeMapAcwMapOutputFile Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)acw-map.txt - <_PreTrimTypeMapApplicationRegistrationOutputFile Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)android/src/net/dot/android/ApplicationRegistration.java + <_PreTrimTypeMapAcwMapOutputFile Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedAcwMap) + <_PreTrimTypeMapApplicationRegistrationOutputFile Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedApplicationRegistration) + <_PreTrimTypeMapAcwMapOutputFile Condition=" '$(_PreTrimTypeMapAcwMapOutputFile)' == '' and ('$(_AndroidRuntime)' != 'CoreCLR' or '$(PublishTrimmed)' != 'true') ">$(IntermediateOutputPath)acw-map.txt + <_PreTrimTypeMapApplicationRegistrationOutputFile Condition=" '$(_PreTrimTypeMapApplicationRegistrationOutputFile)' == '' and ('$(_AndroidRuntime)' != 'CoreCLR' or '$(PublishTrimmed)' != 'true') ">$(IntermediateOutputPath)android/src/net/dot/android/ApplicationRegistration.java <_TrimmableTypeMapOutputStamp>$(_TypeMapOutputDirectory)_GenerateTrimmableTypeMap.stamp - <_AndroidR8JniSeedDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_TypeMapBaseOutputDir)r8-jni-seed/ <_AndroidR8JniSeedMapping Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)mapping.txt <_AndroidR8JniRewriteManifest Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)r8-jni-rewrite-manifest.txt <_AndroidR8JniReachabilityManifest Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)r8-jni-reachability-manifest.txt <_AndroidR8JniSeedApplicationConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)acw-keep.cfg <_AndroidR8JniSeedXamarinConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)xamarin.cfg - <_AndroidR8JniSeedAaptConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)aapt_rules.txt <_AndroidR8JniSeedJavaClassDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)classes/ <_AndroidR8JniSeedJavaStamp Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)compile-java.stamp <_TrimmableRemoveRegisterFlag>$(_AndroidStampDirectory)_RemoveRegisterAttribute.stamp @@ -87,7 +88,7 @@ DependsOnTargets="_GenerateTrimmableTypeMap;$(_OnResolveMonoAndroidSdks);_AdjustJavacVersionArguments;_CollectRuntimeJarFilenames;_GetLibraryImports;_DetermineJavaLibrariesToCompile"> <_AndroidR8JniSeedJavaSource Include="$(_TypeMapJavaOutputDirectory)/**/*.java" /> - <_AndroidR8JniSeedJavaSource Include="$(IntermediateOutputPath)android/src/**/*.java" /> + <_AndroidR8JniSeedJavaSource Include="$(_AndroidR8JniSeedApplicationRegistration)" /> @@ -100,8 +101,7 @@ <_AndroidR8JniSeedClassFile Include="$(_AndroidR8JniSeedJavaClassDirectory)**\*.class" /> <_AndroidR8JniSeedProguardConfiguration Include="@(_ProguardConfiguration)" /> - <_AndroidR8JniSeedProguardConfiguration Remove="$(IntermediateOutputPath)proguard\proguard_xamarin.cfg;$(_ProguardProjectConfiguration);$(IntermediateOutputPath)proguard\proguard_project_primary.cfg" /> - <_AndroidR8JniSeedProguardConfiguration Include="$(_AndroidR8JniSeedAaptConfiguration)" Condition="Exists('$(_AndroidR8JniSeedAaptConfiguration)')" /> + <_AndroidR8JniSeedProguardConfiguration Remove="$(IntermediateOutputPath)proguard\proguard_xamarin.cfg;$(_ProguardProjectConfiguration);$(IntermediateOutputPath)proguard\proguard_project_primary.cfg;$(IntermediateOutputPath)aapt_rules.txt" /> <_AndroidR8JniSeedMapDiagnostics Condition=" '$(AndroidR8IgnoreWarnings)' == 'true' " Include="warning" To="info" /> @@ -132,7 +131,7 @@ + + + + + + + + <_TrimmableTypeMapOutputStamp>$(_TypeMapOutputDirectory)_GenerateTrimmableTypeMap.stamp <_AndroidR8JniSeedMapping Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)mapping.txt - <_AndroidR8JniRewriteManifest Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)r8-jni-rewrite-manifest.txt - <_AndroidR8JniReachabilityManifest Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(IntermediateOutputPath)r8-jni-reachability-manifest.txt + <_AndroidR8JniRewriteManifest Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniManifestBaseOutputDir)r8-jni-rewrite-manifest.txt + <_AndroidR8JniReachabilityManifest Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniManifestBaseOutputDir)r8-jni-reachability-manifest.txt <_AndroidR8JniSeedApplicationConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)acw-keep.cfg <_AndroidR8JniSeedXamarinConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)xamarin.cfg <_AndroidR8JniSeedJavaClassDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)classes/ @@ -85,7 +88,7 @@ + DependsOnTargets="_CleanIntermediateIfNeeded;_GenerateTrimmableTypeMap;$(_OnResolveMonoAndroidSdks);_AdjustJavacVersionArguments;_CollectRuntimeJarFilenames;_GetLibraryImports;_DetermineJavaLibrariesToCompile"> <_AndroidR8JniSeedJavaSource Include="$(_TypeMapJavaOutputDirectory)/**/*.java" /> <_AndroidR8JniSeedJavaSource Include="$(_AndroidR8JniSeedApplicationRegistration)" /> diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index f71701b7a2c..d98bac6b98f 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -250,10 +250,7 @@ protected override string CreateResponseFile () } } else if (!AcwMapFile.IsNullOrEmpty ()) { var acwMap = MonoAndroidHelper.LoadMapFile (BuildEngine4, Path.GetFullPath (AcwMapFile), StringComparer.OrdinalIgnoreCase); - var javaTypes = new List (acwMap.Values.Count); - foreach (var v in acwMap.Values) { - javaTypes.Add (v); - } + var javaTypes = new List (new HashSet (acwMap.Values, StringComparer.Ordinal)); javaTypes.Sort (StringComparer.Ordinal); using (var appcfg = File.CreateText (ProguardGeneratedApplicationConfiguration)) { foreach (var java in javaTypes) { @@ -323,7 +320,7 @@ void GenerateApplicationConfiguration () } var acwMap = MonoAndroidHelper.LoadMapFile (BuildEngine4, Path.GetFullPath (AcwMapFile), StringComparer.OrdinalIgnoreCase); - var javaTypes = new List (acwMap.Values); + var javaTypes = new List (new HashSet (acwMap.Values, StringComparer.Ordinal)); javaTypes.Sort (StringComparer.Ordinal); using var appcfg = File.CreateText (ProguardGeneratedApplicationConfiguration); foreach (var java in javaTypes) { diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs index e06612c3a4f..1c50c24102c 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs @@ -99,7 +99,10 @@ void RewriteAssembly (string sourcePath, string destinationPath, R8Mapping mappi Log.LogDebugMessage ($"RewriteJniNamesForR8: rewrote {result.ReplacementCount} JNI name(s) in '{Path.GetFileName (sourcePath)}'."); if (result.StrongNameSignatureCleared) { - Log.LogCodedWarning ("RJN0002", $"'{Path.GetFileName (sourcePath)}' was strong-name signed; the rewritten assembly is left delay-signed (its signature directory space is preserved so it can be re-signed) because no signing key is available here."); + // These are private ILLink/ILC inputs. The original public key remains in the + // assembly identity, while the unverifiable signature flag is cleared just as it + // is for other linker-modified framework assemblies. + Log.LogDebugMessage ($"RewriteJniNamesForR8: '{Path.GetFileName (sourcePath)}' is strong-named; preserved its public-key identity and emitted a delay-signed linker input."); } bool inPlace = String.Equals (Path.GetFullPath (sourcePath), Path.GetFullPath (destinationPath), StringComparison.Ordinal); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index ba26df30e74..6da2a420d51 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -149,7 +149,7 @@ public R8JniPeer () { } proj.SetProperty (KnownProperties.RuntimeIdentifier, "android-arm64"); proj.SetProperty ("AndroidPackageFormat", "apk"); proj.SetProperty ("AndroidTypeMapImplementation", "trimmable"); - proj.SetProperty ("_AndroidEnableR8JniNameRewriting", "true"); + proj.SetProperty ("AndroidEnableR8JniNameObfuscation", "true"); using var builder = CreateApkBuilder (Path.Combine ("temp", $"R8JniNameRewriting_{runtime}_{Guid.NewGuid ():N}")); Assert.IsTrue (builder.Build (proj), "Clean R8 JNI name-rewriting build should have succeeded."); @@ -164,6 +164,10 @@ public R8JniPeer () { } .ToArray (); Assert.IsNotEmpty (rewrittenAssemblies, "The clean build should rewrite managed assemblies before trimming or AOT."); + foreach (var rewrittenAssembly in rewrittenAssemblies) { + var hashDirectory = Path.GetFileName (Path.GetDirectoryName (rewrittenAssembly)); + Assert.That (hashDirectory, Does.Match ("^[0-9a-fA-F]{16}$"), $"Rewritten assembly staging should include a source-path hash: {rewrittenAssembly}"); + } StringAssert.Contains ($"{originalJavaName.Replace ('/', '.')} ->", File.ReadAllText (seedMapping)); StringAssert.DoesNotContain ($"{userJavaName} ->", File.ReadAllText (seedMapping), "User Java sources are kept by final R8 and should not receive seed-only names."); StringAssert.Contains ($"C\t{originalJavaName}", File.ReadAllText (rewriteManifest)); @@ -210,6 +214,128 @@ public R8JniPeer () { } StringAssert.Contains ($"C\t{changedJavaName}", File.ReadAllText (rewriteManifest)); } + [Test] + public void Build_WithR8JniNameRewriting_SupportsMultipleRuntimeIdentifiers ([Values (AndroidRuntime.CoreCLR, AndroidRuntime.NativeAOT)] AndroidRuntime runtime) + { + const string javaName = "com/example/R8JniMultiAbiPeer"; + if (IgnoreUnsupportedConfiguration (runtime, release: true)) { + return; + } + + var proj = new XamarinAndroidApplicationProject { + IsRelease = true, + LinkTool = "r8", + Sources = { + new BuildItem.Source ("R8JniMultiAbiPeer.cs") { + TextContent = () => $$""" +using Android.Runtime; + +namespace UnnamedProject; + +[Register ("{{javaName}}")] +public class R8JniMultiAbiPeer : Java.Lang.Object +{ + public R8JniMultiAbiPeer () { } +} +""", + }, + }, + }; + proj.SetRuntime (runtime); + proj.SetRuntimeIdentifiers (AndroidTargetArch.Arm64, AndroidTargetArch.X86_64); + proj.SetProperty ("AndroidPackageFormat", "apk"); + proj.SetProperty ("AndroidTypeMapImplementation", "trimmable"); + proj.SetProperty ("AndroidEnableR8JniNameObfuscation", "true"); + + using var builder = CreateApkBuilder (Path.Combine ("temp", $"R8JniNameRewritingMultiAbi_{runtime}_{Guid.NewGuid ():N}")); + Assert.IsTrue (builder.Build (proj), "Multi-ABI R8 JNI name-rewriting build should have succeeded."); + + var projectDirectory = Path.Combine (Root, builder.ProjectDirectory); + var seedMapping = FindSingleFile (projectDirectory, "mapping.txt", path => path.Contains ("r8-jni-seed", StringComparison.Ordinal)); + var rewriteManifests = Directory.GetFiles (projectDirectory, "r8-jni-rewrite-manifest.txt", SearchOption.AllDirectories); + var reachabilityManifests = Directory.GetFiles (projectDirectory, "r8-jni-reachability-manifest.txt", SearchOption.AllDirectories); + var rewrittenAssemblies = Directory.GetFiles (projectDirectory, "*.dll", SearchOption.AllDirectories) + .Where (path => path.Contains ("r8-jni-rewritten", StringComparison.Ordinal)) + .ToArray (); + + StringAssert.Contains ($"{javaName.Replace ('/', '.')} ->", File.ReadAllText (seedMapping)); + foreach (var runtimeIdentifier in new [] { "android-arm64", "android-x64" }) { + Assert.That (rewrittenAssemblies, Has.Some.Contains (runtimeIdentifier), $"The {runtimeIdentifier} inner build should have isolated rewritten assemblies."); + Assert.That (rewriteManifests, Has.Some.Contains (runtimeIdentifier), $"The {runtimeIdentifier} inner build should have a rewrite manifest."); + } + Assert.That (reachabilityManifests, Has.Exactly (1).Contains ("android-arm64"), "Final R8 should consume the shared first-RID reachability manifest."); + Assert.That (new FileInfo (reachabilityManifests [0]).Length, Is.GreaterThan (0), "The shared reachability manifest should contain retained JNI entries."); + foreach (var manifest in rewriteManifests) { + StringAssert.Contains ($"C\t{javaName}", File.ReadAllText (manifest)); + } + } + + [Test] + public void Build_WithR8JniNameRewriting_SupportsProjectReferencesAndCustomRules ([Values (AndroidRuntime.CoreCLR, AndroidRuntime.NativeAOT)] AndroidRuntime runtime) + { + const string libraryJavaName = "com/example/R8JniLibraryPeer"; + if (IgnoreUnsupportedConfiguration (runtime, release: true)) { + return; + } + + string proguardRule = "-dontwarn com.example.UnusedOne"; + var library = new XamarinAndroidLibraryProject { + IsRelease = true, + ProjectName = "R8JniLibrary", + Sources = { + new BuildItem.Source ("R8JniLibraryPeer.cs") { + TextContent = () => $$""" +using Android.Runtime; + +namespace R8JniLibrary; + +[Register ("{{libraryJavaName}}")] +public class R8JniLibraryPeer : Java.Lang.Object +{ + public R8JniLibraryPeer () { } +} +""", + }, + }, + }; + library.SetRuntime (runtime); + + var app = new XamarinAndroidApplicationProject { + IsRelease = true, + LinkTool = "r8", + }; + app.SetRuntime (runtime); + app.SetProperty (KnownProperties.RuntimeIdentifier, "android-arm64"); + app.SetProperty ("AndroidPackageFormat", "apk"); + app.SetProperty ("AndroidTypeMapImplementation", "trimmable"); + app.SetProperty ("AndroidEnableR8JniNameObfuscation", "true"); + app.References.Add (new BuildItem.ProjectReference (Path.Combine ("..", library.ProjectName, $"{library.ProjectName}.csproj"), library.ProjectName, library.ProjectGuid)); + app.OtherBuildItems.Add (new AndroidItem.ProguardConfiguration ("r8-jni-rules.pro") { + TextContent = () => proguardRule, + }); + + string testDirectory = Path.Combine ("temp", $"R8JniNameRewritingReferences_{runtime}_{Guid.NewGuid ():N}"); + using var libraryBuilder = CreateDllBuilder (Path.Combine (testDirectory, library.ProjectName)); + using var appBuilder = CreateApkBuilder (Path.Combine (testDirectory, "App")); + Assert.IsTrue (libraryBuilder.Build (library), "Referenced library build should have succeeded."); + Assert.IsTrue (appBuilder.Build (app), "R8 JNI name-rewriting build with a project reference and custom rules should have succeeded."); + + var projectDirectory = Path.Combine (Root, appBuilder.ProjectDirectory); + var seedMapping = FindSingleFile (projectDirectory, "mapping.txt", path => path.Contains ("r8-jni-seed", StringComparison.Ordinal)); + var rewriteManifest = FindSingleFile (projectDirectory, "r8-jni-rewrite-manifest.txt"); + StringAssert.Contains ($"{libraryJavaName.Replace ('/', '.')} ->", File.ReadAllText (seedMapping)); + StringAssert.Contains ($"C\t{libraryJavaName}", File.ReadAllText (rewriteManifest)); + + proguardRule = "-dontwarn com.example.UnusedTwo"; + app.Touch ("r8-jni-rules.pro"); + Assert.IsTrue (appBuilder.Build (app, doNotCleanupOnUpdate: true, saveProject: false), "Custom-rule incremental build should have succeeded."); + appBuilder.Output.AssertTargetIsNotSkipped ("_AndroidGenerateR8JniSeedMapping"); + appBuilder.Output.AssertTargetIsNotSkipped (runtime == AndroidRuntime.CoreCLR + ? "_AndroidRewriteJniNamesBeforeILLink" + : "_AndroidRewriteJniNamesBeforeIlc"); + appBuilder.Output.AssertTargetIsNotSkipped ("_CompileToDalvik"); + } + [Test] public void Build_WithTrimmableTypeMap_MissingJavaListPreservesGeneratedJava () { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs index e937aa1ba3d..a5638fe3ae8 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs @@ -843,7 +843,7 @@ public void ClearsTheStrongNameSignedFlagButReservesTheSignatureDirectory () { const int OriginalStrongNameSignatureSize = 128; - var fixture = new JniFixtureBuilder { + var fixture = new JniFixtureBuilder (hasPublicKey: true) { Flags = CorFlags.ILOnly | CorFlags.StrongNameSigned, StrongNameSignatureSize = OriginalStrongNameSignatureSize, }; @@ -866,6 +866,16 @@ public void ClearsTheStrongNameSignedFlagButReservesTheSignatureDirectory () using var rewrittenPe = new PEReader (ImmutableArray.Create (result.Image)); CorHeader corHeader = rewrittenPe.PEHeaders.CorHeader; Assert.AreEqual (CorFlags.ILOnly, corHeader.Flags, "The StrongNameSigned flag must be cleared, not left stale."); + using var sourceIdentityPe = new PEReader (ImmutableArray.Create (source)); + MetadataReader sourceMetadata = sourceIdentityPe.GetMetadataReader (); + MetadataReader rewrittenMetadata = rewrittenPe.GetMetadataReader (); + AssemblyDefinition sourceAssembly = sourceMetadata.GetAssemblyDefinition (); + AssemblyDefinition rewrittenAssembly = rewrittenMetadata.GetAssemblyDefinition (); + Assert.AreEqual (sourceAssembly.Flags, rewrittenAssembly.Flags, "The assembly's public-key identity flag must be preserved."); + CollectionAssert.AreEqual ( + sourceMetadata.GetBlobBytes (sourceAssembly.PublicKey), + rewrittenMetadata.GetBlobBytes (rewrittenAssembly.PublicKey), + "The assembly public key must be preserved so references continue to resolve to the same identity."); // The output must be genuinely delay-signed / re-signable: the original signature // directory's size (and a real, non-zero RVA reserving that space in the image) must diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniFixtureBuilder.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniFixtureBuilder.cs index 2ef001cf079..417eebdf59d 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniFixtureBuilder.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniFixtureBuilder.cs @@ -51,12 +51,14 @@ class JniFixtureBuilder readonly Dictionary sizedTypes = new Dictionary (); int utf8FieldCounter; - public JniFixtureBuilder () + public JniFixtureBuilder (bool hasPublicKey = false) { bodyEncoder = new MethodBodyStreamEncoder (Il); Metadata.AddModule (0, Metadata.GetOrAddString ("Fixture.dll"), Metadata.GetOrAddGuid (Mvid), default, default); - Metadata.AddAssembly (Metadata.GetOrAddString ("Fixture"), new Version (1, 0, 0, 0), default, default, 0, AssemblyHashAlgorithm.Sha1); + BlobHandle publicKey = hasPublicKey ? Metadata.GetOrAddBlob (new byte [] { 1, 2, 3, 4, 5, 6, 7, 8 }) : default; + AssemblyFlags assemblyFlags = hasPublicKey ? AssemblyFlags.PublicKey : 0; + Metadata.AddAssembly (Metadata.GetOrAddString ("Fixture"), new Version (1, 0, 0, 0), default, publicKey, assemblyFlags, AssemblyHashAlgorithm.Sha1); Metadata.AddTypeDefinition (default, default, Metadata.GetOrAddString (""), default, MetadataTokens.FieldDefinitionHandle (1), MetadataTokens.MethodDefinitionHandle (1)); diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 06fb4cac68c..288b84b13c2 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -3102,6 +3102,12 @@ because xbuild doesn't support framework reference assemblies. Text="Invalid value for AndroidTypeMapImplementation: '$(AndroidTypeMapImplementation)'. Valid values are: llvm-ir, trimmable." /> + + <_AndroidEnableR8JniNameRewriting + Condition=" '$(_AndroidEnableR8JniNameRewriting)' == '' and '$(AndroidLinkTool)' == 'r8' and '$(AndroidTypeMapImplementation)' == 'trimmable' and ('$(_AndroidRuntime)' == 'CoreCLR' or '$(_AndroidRuntime)' == 'NativeAOT') ">$(AndroidEnableR8JniNameObfuscation) + <_AndroidEnableR8JniNameRewriting Condition=" '$(_AndroidEnableR8JniNameRewriting)' == '' ">false + + From 23fd224128f5b521841729b828b7be38ad395e89 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 12:07:37 +0200 Subject: [PATCH 12/14] Fix R8 JNI review findings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../building-apps/build-properties.md | 3 +- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 17 ++++- .../GenerateProguardConfigurationTests.cs | 10 +-- .../TrimmableTypeMapBuildTests.cs | 9 +++ .../JniRemapping/JniDescriptorTextTests.cs | 4 ++ .../NativeResourceSectionCopierTests.cs | 55 ++++++++++++++ .../Utilities/JniRemapping/R8MappingTests.cs | 72 +++++++++++-------- .../JniRemapping/JniDescriptorText.cs | 34 ++++++--- .../JniRemapping/JniRewritePlanner.cs | 12 ++-- .../Utilities/JniRemapping/LdstrRewriter.cs | 8 +-- .../NativeResourceSectionCopier.cs | 8 +-- .../Utilities/JniRemapping/R8Mapping.cs | 43 ++++++----- .../Xamarin.Android.Common.targets | 3 +- 13 files changed, 198 insertions(+), 80 deletions(-) diff --git a/Documentation/docs-mobile/building-apps/build-properties.md b/Documentation/docs-mobile/building-apps/build-properties.md index 3f0aa048920..faf7be24058 100644 --- a/Documentation/docs-mobile/building-apps/build-properties.md +++ b/Documentation/docs-mobile/building-apps/build-properties.md @@ -473,7 +473,8 @@ managed assemblies before trimming or NativeAOT compilation, then applies the sa mapping during the final R8 invocation. This property requires `AndroidLinkTool=r8`, -`AndroidTypeMapImplementation=trimmable`, and either the CoreCLR or NativeAOT runtime. +`AndroidTypeMapImplementation=trimmable`, and either NativeAOT or CoreCLR with +`PublishTrimmed=true`. The default value is `False`. ## AndroidEnableRestrictToAttributes diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index 60b41db332b..d2fbb15387f 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -36,7 +36,8 @@ <_PostTrimTypeMapFirstRuntimeIdentifier Condition=" '$(RuntimeIdentifiers)' != '' ">$([System.String]::Copy('$(RuntimeIdentifiers)').Split(';')[0]) <_PostTrimTypeMapFirstRuntimeIdentifier Condition=" '$(_PostTrimTypeMapFirstRuntimeIdentifier)' == '' ">$(RuntimeIdentifier) <_AndroidR8JniManifestBaseOutputDir>$(IntermediateOutputPath) - <_AndroidR8JniManifestBaseOutputDir Condition=" '$(RuntimeIdentifiers)' != '' and '$(_OuterIntermediateOutputPath)' == '' ">$(IntermediateOutputPath)$(_PostTrimTypeMapFirstRuntimeIdentifier)/ + <_AndroidR8JniManifestBaseOutputDir Condition=" '$(RuntimeIdentifiers)' != '' and '$(_OuterIntermediateOutputPath)' == '' and '$(RuntimeIdentifier)' == '' ">$(IntermediateOutputPath)$(_PostTrimTypeMapFirstRuntimeIdentifier)/ + <_AndroidR8JniManifestBaseOutputDir Condition=" '$(RuntimeIdentifiers)' != '' and '$(_OuterIntermediateOutputPath)' == '' and '$(RuntimeIdentifier)' != '' and '$(RuntimeIdentifier)' != '$(_PostTrimTypeMapFirstRuntimeIdentifier)' ">$(IntermediateOutputPath)../$(_PostTrimTypeMapFirstRuntimeIdentifier)/ <_TypeMapJavaStubsSourceDirectory Condition=" '$(_TypeMapJavaStubsSourceDirectory)' == '' and '$(_AndroidRuntime)' == 'CoreCLR' and '$(PublishTrimmed)' == 'true' ">$(_PostTrimTypeMapJavaOutputDirectory) <_TypeMapJavaStubsSourceDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_TypeMapJavaOutputDirectory) <_TypeMapJavaStubsSourceDirectory Condition=" '$(_TypeMapJavaStubsSourceDirectory)' == '' ">$(_TypeMapJavaOutputDirectory) @@ -171,6 +172,20 @@ + + + + + () - M acme/orig/MyView onClick(android.view.View) - M acme/orig/MyView onClick(android.view.View,int) + M acme/orig/DirectTarget run():void + M acme/orig/ICallback invoke():void + M acme/orig/MyView ():void + M acme/orig/MyView onClick(android.view.View):void + M acme/orig/MyView onClick(android.view.View,int):void """.ReplaceLineEndings (), File.ReadAllText (manifest)); } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index 6da2a420d51..bf0154bafa8 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -212,6 +212,15 @@ public R8JniPeer () { } StringAssert.DoesNotContain ($"{originalJavaName.Replace ('/', '.')} ->", File.ReadAllText (seedMapping)); StringAssert.Contains ($"{changedJavaName.Replace ('/', '.')} ->", File.ReadAllText (seedMapping)); StringAssert.Contains ($"C\t{changedJavaName}", File.ReadAllText (rewriteManifest)); + + proj.SetProperty ("AndroidEnableR8JniNameObfuscation", "false"); + Assert.IsTrue (builder.Build (proj, doNotCleanupOnUpdate: true), "Disabling R8 JNI name rewriting should invalidate the existing build outputs."); + builder.Output.AssertTargetIsNotSkipped ("_CleanIntermediateIfNeeded"); + + proj.SetProperty ("AndroidEnableR8JniNameObfuscation", "true"); + Assert.IsTrue (builder.Build (proj, doNotCleanupOnUpdate: true), "Re-enabling R8 JNI name rewriting should regenerate the pipeline outputs."); + builder.Output.AssertTargetIsNotSkipped ("_AndroidGenerateR8JniSeedMapping"); + FileAssert.Exists (seedMapping); } [Test] diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs index ddb9e07289e..57c5abe05ee 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs @@ -40,6 +40,8 @@ public void LeavesUnrelatedTypesAndPrimitivesAlone () [TestCase ("()V", true)] [TestCase ("(Ljava/lang/Object;)Z", true)] [TestCase ("(I)I", true)] + [TestCase ("(V)V", false)] + [TestCase ("()[V", false)] [TestCase ("I", false)] [TestCase ("Ljava/lang/Object;", false)] [TestCase ("not a descriptor", false)] @@ -51,6 +53,8 @@ public void ValidatesMethodDescriptors (string descriptor, bool expected) [TestCase ("I", true)] [TestCase ("[I", true)] [TestCase ("Ljava/lang/Object;", true)] + [TestCase ("V", false)] + [TestCase ("[V", false)] [TestCase ("()V", false)] [TestCase ("", false)] public void ValidatesFieldDescriptors (string descriptor, bool expected) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/NativeResourceSectionCopierTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/NativeResourceSectionCopierTests.cs index 25d7cce045b..a22042d0063 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/NativeResourceSectionCopierTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/NativeResourceSectionCopierTests.cs @@ -64,6 +64,38 @@ protected override void Serialize (BlobBuilder builder, SectionLocation location } } + sealed class SharedEntryResourceSectionBuilder : ResourceSectionBuilder + { + readonly byte [] data; + + public SharedEntryResourceSectionBuilder (byte [] data) + { + this.data = data; + } + + protected override void Serialize (BlobBuilder builder, SectionLocation location) + { + int dataEntryOffset = ResourceDirectoryHeaderSize + 2 * ResourceDirectoryEntrySize; + int dataOffset = dataEntryOffset + ResourceDataEntrySize; + + builder.WriteUInt32 (0); + builder.WriteUInt32 (0); + builder.WriteUInt16 (0); + builder.WriteUInt16 (0); + builder.WriteUInt16 (0); + builder.WriteUInt16 (2); + for (uint id = 1; id <= 2; id++) { + builder.WriteUInt32 (id); + builder.WriteUInt32 ((uint) dataEntryOffset); + } + builder.WriteUInt32 ((uint) (location.RelativeVirtualAddress + dataOffset)); + builder.WriteUInt32 ((uint) data.Length); + builder.WriteUInt32 (0); + builder.WriteUInt32 (0); + builder.WriteBytes (data); + } + } + static byte [] BuildAndReadBack (ResourceSectionBuilder nativeResources) { var fixture = new JniFixtureBuilder { @@ -120,5 +152,28 @@ public void AcceptsAndRelocatesAWellFormedDataEntry () .ReadBytes (payload.Length), "The payload bytes following the data entry should be exactly what was written."); } + + [Test] + public void RelocatesASharedDataEntryOnlyOnce () + { + byte [] payload = { 0x11, 0x22, 0x33, 0x44 }; + var sourceFixture = new JniFixtureBuilder { + NativeResources = new SharedEntryResourceSectionBuilder (payload), + }; + sourceFixture.AddEmbeddedResource ("padding", new byte [8192]); + byte [] source = sourceFixture.Serialize (); + using var sourceReader = new PEReader (ImmutableArray.Create (source)); + NativeResourceSectionCopier copier = NativeResourceSectionCopier.TryCreate (sourceReader); + Assert.IsNotNull (copier); + + byte [] rewritten = BuildAndReadBack (copier); + using var rewrittenReader = new PEReader (ImmutableArray.Create (rewritten)); + DirectoryEntry directory = rewrittenReader.PEHeaders.PEHeader.ResourceTableDirectory; + int dataEntryOffset = ResourceDirectoryHeaderSize + 2 * ResourceDirectoryEntrySize; + int dataRva = rewrittenReader.GetSectionData (directory.RelativeVirtualAddress).GetReader (dataEntryOffset, sizeof (int)).ReadInt32 (); + + CollectionAssert.AreEqual (payload, + rewrittenReader.GetSectionData (dataRva).GetReader (0, payload.Length).ReadBytes (payload.Length)); + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs index c37daa8ef67..fdf12957c34 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs @@ -35,9 +35,9 @@ public void LooksUpOriginalClassAndMethodNames () Assert.IsTrue (mapping.TryGetOriginalClass ("a/b/C", out string originalClass)); Assert.AreEqual ("acme/orig/MyView", originalClass); CollectionAssert.AreEquivalent (new [] { "first", "second" }, mapping.GetOriginalMethodNames (originalClass, "x")); - Assert.IsTrue (mapping.TryGetOriginalMethodName (originalClass, "x", new [] { "int" }, out string first)); + Assert.IsTrue (mapping.TryGetOriginalMethodName (originalClass, "x", new [] { "int" }, "void", out string first)); Assert.AreEqual ("first", first); - Assert.IsTrue (mapping.TryGetOriginalMethodName (originalClass, "x", Array.Empty (), out string second)); + Assert.IsTrue (mapping.TryGetOriginalMethodName (originalClass, "x", Array.Empty (), "void", out string second)); Assert.AreEqual ("second", second); Assert.IsFalse (mapping.TryGetOriginalClass ("a/b/Missing", out _)); } @@ -53,10 +53,10 @@ void onClick(android.view.View,int) -> b """)); - Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View" }, out string renamed1)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View" }, "void", out string renamed1)); Assert.AreEqual ("a", renamed1); - Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View", "int" }, out string renamed2)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View", "int" }, "void", out string renamed2)); Assert.AreEqual ("b", renamed2); } @@ -68,7 +68,7 @@ public void ParsesMethodMappingWithLeadingAndTrailingLineRanges () "acme.orig.MyView -> a.b.C:\n" + " 4:10:void onCreate(android.os.Bundle):23:29 -> a\n")); - Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onCreate", new [] { "android.os.Bundle" }, out string renamed)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onCreate", new [] { "android.os.Bundle" }, "void", out string renamed)); Assert.AreEqual ("a", renamed); } @@ -82,7 +82,7 @@ public void ParsesMethodMappingWithASingleTrailingLineNumber () "acme.orig.MyView -> a.b.D:\n" + " 4:4:void run(example.Foo):2 -> a\n")); - Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "run", new [] { "example.Foo" }, out string renamed)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "run", new [] { "example.Foo" }, "void", out string renamed)); Assert.AreEqual ("a", renamed); } @@ -94,7 +94,7 @@ public void ParsesMethodMappingWithOnlyATrailingLineRange () "acme.orig.MyView -> a.b.C:\n" + " void onPause():23:29 -> a\n")); - Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onPause", Array.Empty (), out string renamed)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onPause", Array.Empty (), "void", out string renamed)); Assert.AreEqual ("a", renamed); } @@ -105,7 +105,7 @@ public void ParsesNoArgMethodMapping () "acme.orig.MyView -> a.b.C:\n" + " void onStart() -> a\n")); - Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onStart", Array.Empty (), out string renamed)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onStart", Array.Empty (), "void", out string renamed)); Assert.AreEqual ("a", renamed); } @@ -127,7 +127,7 @@ public void TranslatesConstructorNameForLookup () " void (int) -> \n")); // JVM never renames , but the lookup key must still translate ".ctor" -> "". - Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", R8Mapping.JniMemberNameToMappingName (".ctor"), new [] { "int" }, out string renamed)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", R8Mapping.JniMemberNameToMappingName (".ctor"), new [] { "int" }, "void", out string renamed)); Assert.AreEqual ("", renamed); } @@ -161,7 +161,7 @@ public void UnknownClassOrMemberReturnsFalse () Assert.IsFalse (mapping.TryGetRenamedClass ("acme/orig/Other", out _)); Assert.IsFalse (mapping.TryGetRenamedField ("acme/orig/MyView", "missing", out _)); - Assert.IsFalse (mapping.TryGetRenamedMethod ("acme/orig/MyView", "missing", Array.Empty (), out _)); + Assert.IsFalse (mapping.TryGetRenamedMethod ("acme/orig/MyView", "missing", Array.Empty (), "void", out _)); } [Test] @@ -189,7 +189,7 @@ public void IgnoresIndentedMetadataComments () " # {'id':'com.android.tools.r8.synthesized'}\n" + " int someField -> x\n")); - Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onCreate", new [] { "android.os.Bundle" }, out string renamedMethod)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onCreate", new [] { "android.os.Bundle" }, "void", out string renamedMethod)); Assert.AreEqual ("a", renamedMethod); Assert.IsTrue (mapping.TryGetRenamedField ("acme/orig/MyView", "someField", out string renamedField)); @@ -206,11 +206,11 @@ public void IgnoresQualifiedInlineCallFrameMappings () " 307:307:void androidx.collection.LongSparseArrayKt.commonGc(androidx.collection.LongSparseArray) -> indexOfKey\n" + " 307:307:int indexOfKey(long):209 -> indexOfKey\n")); - Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "keyAt", new [] { "int" }, out string keyAt)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "keyAt", new [] { "int" }, "long", out string keyAt)); Assert.AreEqual ("keyAt", keyAt); - Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "indexOfKey", new [] { "long" }, out string indexOfKey)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "indexOfKey", new [] { "long" }, "int", out string indexOfKey)); Assert.AreEqual ("indexOfKey", indexOfKey); - Assert.IsFalse (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "androidx.collection.LongSparseArrayKt.commonGc", new [] { "androidx.collection.LongSparseArray" }, out _)); + Assert.IsFalse (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "androidx.collection.LongSparseArrayKt.commonGc", new [] { "androidx.collection.LongSparseArray" }, "void", out _)); } [Test] @@ -223,10 +223,10 @@ public void TreatsMethodInlinedIntoMultipleDestinationsAsAmbiguous () " 299:299:java.lang.Object getOrDefaultInternal(java.lang.Object,java.lang.Object) -> getOrDefault\n" + " 299:299:java.lang.Object getOrDefault(java.lang.Object,java.lang.Object):294 -> getOrDefault\n")); - Assert.IsFalse (mapping.TryGetRenamedMethod ("androidx/collection/SimpleArrayMap", "getOrDefaultInternal", new [] { "java.lang.Object", "java.lang.Object" }, out _)); - Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/SimpleArrayMap", "get", new [] { "java.lang.Object" }, out string get)); + Assert.IsFalse (mapping.TryGetRenamedMethod ("androidx/collection/SimpleArrayMap", "getOrDefaultInternal", new [] { "java.lang.Object", "java.lang.Object" }, "java.lang.Object", out _)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/SimpleArrayMap", "get", new [] { "java.lang.Object" }, "java.lang.Object", out string get)); Assert.AreEqual ("get", get); - Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/SimpleArrayMap", "getOrDefault", new [] { "java.lang.Object", "java.lang.Object" }, out string getOrDefault)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/SimpleArrayMap", "getOrDefault", new [] { "java.lang.Object", "java.lang.Object" }, "java.lang.Object", out string getOrDefault)); Assert.AreEqual ("getOrDefault", getOrDefault); } @@ -257,11 +257,11 @@ void onClick(android.view.View) -> e CollectionAssert.AreEqual (new [] { "class 'acme/orig/MyView': seed name 'a/b/C', final name 'x/y/Z'", "field 'acme/orig/MyView.count': seed name 'a', final name 'd'", - "method 'acme/orig/MyView.onClick(android.view.View)': seed name 'b', final name 'e'", + "method 'acme/orig/MyView.onClick(android.view.View):void': seed name 'b', final name 'e'", }, seed.GetCompatibilityConflicts (final, new [] { "C\tacme/orig/MyView", "F\tacme/orig/MyView\tcount", - "M\tacme/orig/MyView\tonClick(android.view.View)", + "M\tacme/orig/MyView\tonClick(android.view.View):void", })); } @@ -289,8 +289,8 @@ void removed() -> z CollectionAssert.IsEmpty (seed.GetCompatibilityConflicts (final, new [] { "C\tacme/orig/Removed", "F\tacme/orig/Removed\tvalue", - "M\tacme/orig/Removed\tremoved()", - "M\tacme/orig/Kept\tremoved()", + "M\tacme/orig/Removed\tremoved():void", + "M\tacme/orig/Kept\tremoved():void", })); } @@ -306,12 +306,12 @@ void onClick(android.view.View) -> b Assert.IsTrue (mapping.TryGetRenamedClass ("acme/orig/MyView", out _)); Assert.IsTrue (mapping.TryGetRenamedField ("acme/orig/MyView", "count", out _)); - Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View" }, out _)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View" }, "void", out _)); CollectionAssert.AreEquivalent (new [] { "C\tacme/orig/MyView", "F\tacme/orig/MyView\tcount", - "M\tacme/orig/MyView\tonClick(android.view.View)", + "M\tacme/orig/MyView\tonClick(android.view.View):void", }, mapping.AccessedEntries); } @@ -340,15 +340,15 @@ void kept() -> a "class 'acme/orig/Missing'", "class 'acme/orig/Removed'", "field 'acme/orig/Members.missingField'", - "method 'acme/orig/Members.missing()'", + "method 'acme/orig/Members.missing():void'", }, seed.GetReachabilityConflicts (final, new [] { "C\tacme/orig/Missing", "C\tacme/orig/Removed", "C\tacme/orig/Members", "F\tacme/orig/Members\tkeptField", "F\tacme/orig/Members\tmissingField", - "M\tacme/orig/Members\tkept()", - "M\tacme/orig/Members\tmissing()", + "M\tacme/orig/Members\tkept():void", + "M\tacme/orig/Members\tmissing():void", })); } @@ -388,9 +388,25 @@ void invoke(int) -> b "C\tacme/orig/MyView", "F\tacme/orig/MyView\tfirst", "F\tacme/orig/MyView\tsecond", - "M\tacme/orig/MyView\tinvoke(int)", - "M\tacme/orig/MyView\trun()", + "M\tacme/orig/MyView\tinvoke(int):void", + "M\tacme/orig/MyView\trun():void", }, mapping.AccessedEntries); } + + [Test] + public void DistinguishesMethodsByReturnType () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + java.lang.Object value() -> a + java.lang.String value() -> b + + """)); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "value", [], "java.lang.Object", out string objectMethod)); + Assert.AreEqual ("a", objectMethod); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "value", [], "java.lang.String", out string stringMethod)); + Assert.AreEqual ("b", stringMethod); + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs index 8e2fe422ae0..6ba1975616f 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs @@ -29,7 +29,7 @@ public static bool TryRewriteDescriptor (string descriptor, Func renam /// , advancing past it. Returns false (without /// advancing) if the character at cannot start a type token. /// - static bool TryScanSingleToken (string s, ref int i, bool allowParens) + static bool TryScanSingleToken (string s, ref int i, bool allowVoid) { int start = i; int j = i; @@ -94,7 +94,13 @@ static bool TryScanSingleToken (string s, ref int i, bool allowParens) } switch (s [j]) { - case 'V': case 'Z': case 'B': case 'C': case 'S': case 'I': case 'J': case 'F': case 'D': + case 'V': + if (!allowVoid || j != start) { + return false; + } + i = j + 1; + return true; + case 'Z': case 'B': case 'C': case 'S': case 'I': case 'J': case 'F': case 'D': i = j + 1; return true; case 'L': @@ -125,7 +131,7 @@ public static bool TryParseMethodDescriptor (string descriptor, out List int i = 1; while (i < descriptor.Length && descriptor [i] != ')') { int start = i; - if (!TryScanSingleToken (descriptor, ref i, allowParens: false)) { + if (!TryScanSingleToken (descriptor, ref i, allowVoid: false)) { return false; } parameterTypes.Add (descriptor.Substring (start, i - start)); @@ -137,7 +143,7 @@ public static bool TryParseMethodDescriptor (string descriptor, out List i++; int retStart = i; - if (!TryScanSingleToken (descriptor, ref i, allowParens: false) || i != descriptor.Length) { + if (!TryScanSingleToken (descriptor, ref i, allowVoid: true) || i != descriptor.Length) { return false; } @@ -159,7 +165,7 @@ public static bool IsValidMethodDescriptor (string descriptor) public static bool IsValidFieldDescriptor (string descriptor) { int i = 0; - return descriptor.Length > 0 && TryScanSingleToken (descriptor, ref i, allowParens: false) && i == descriptor.Length; + return descriptor.Length > 0 && TryScanSingleToken (descriptor, ref i, allowVoid: false) && i == descriptor.Length; } /// @@ -196,15 +202,21 @@ public static string JniTypeTokenToJavaSource (string token) /// public static List MethodDescriptorToJavaParameterTypes (string descriptor) { - if (!TryParseMethodDescriptor (descriptor, out var parameterTypes, out _)) { + MethodDescriptorToJavaTypes (descriptor, out var parameterTypes, out _); + return parameterTypes; + } + + public static void MethodDescriptorToJavaTypes (string descriptor, out List parameterTypes, out string returnType) + { + if (!TryParseMethodDescriptor (descriptor, out var jniParameterTypes, out string jniReturnType)) { throw new ArgumentException ($"Malformed JNI method descriptor '{descriptor}'.", nameof (descriptor)); } - var result = new List (parameterTypes.Count); - foreach (string p in parameterTypes) { - result.Add (JniTypeTokenToJavaSource (p)); + parameterTypes = new List (jniParameterTypes.Count); + foreach (string parameterType in jniParameterTypes) { + parameterTypes.Add (JniTypeTokenToJavaSource (parameterType)); } - return result; + returnType = JniTypeTokenToJavaSource (jniReturnType); } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs index 3244336103c..8c32a168579 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs @@ -381,8 +381,8 @@ void PlanMemberNameAttributes (JniRewritePlan plan, CustomAttributeHandleCollect string mappingName = R8Mapping.JniMemberNameToMappingName (jniMemberName); if (jniDescriptor != null && JniDescriptorText.IsValidMethodDescriptor (jniDescriptor)) { - var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (jniDescriptor); - return mapping.TryMapMethod (ownerJniName, mappingName, javaParams, out string renamed) ? renamed : null; + JniDescriptorText.MethodDescriptorToJavaTypes (jniDescriptor, out var javaParams, out string javaReturnType); + return mapping.TryMapMethod (ownerJniName, mappingName, javaParams, javaReturnType, out string renamed) ? renamed : null; } return mapping.TryMapMethodByNameOnly (ownerJniName, mappingName, out string renamedByNameOnly) @@ -422,9 +422,9 @@ void PlanMethodBody (JniRewritePlan plan, MethodDefinitionHandle methodHandle, s mapping.TryMapField (referencedOwnerJniName, pendingMemberName, out string renamedField)) { plan.AddUserString (methodHandle, pendingMemberNameOffset, renamedField); } else if (JniDescriptorText.IsValidMethodDescriptor (value)) { - var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (value); + JniDescriptorText.MethodDescriptorToJavaTypes (value, out var javaParams, out string javaReturnType); string mappingName = R8Mapping.JniMemberNameToMappingName (pendingMemberName); - if (mapping.TryMapMethod (referencedOwnerJniName, mappingName, javaParams, out string renamedMethod)) { + if (mapping.TryMapMethod (referencedOwnerJniName, mappingName, javaParams, javaReturnType, out string renamedMethod)) { plan.AddUserString (methodHandle, pendingMemberNameOffset, renamedMethod); } } @@ -572,9 +572,9 @@ IEnumerable GetUses (FieldDefinitionHandle field) string? ComputeNewUtf8Value (string value, Utf8Use use) { if (use.Role == Utf8Role.MethodName && use.OwnerJniName != null && use.PairedSignature != null) { - var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (use.PairedSignature); + JniDescriptorText.MethodDescriptorToJavaTypes (use.PairedSignature, out var javaParams, out string javaReturnType); string mappingName = R8Mapping.JniMemberNameToMappingName (value); - return mapping.TryMapMethod (use.OwnerJniName, mappingName, javaParams, out string renamed) ? renamed : null; + return mapping.TryMapMethod (use.OwnerJniName, mappingName, javaParams, javaReturnType, out string renamed) ? renamed : null; } if (JniDescriptorText.IsValidMethodDescriptor (value) || JniDescriptorText.IsValidFieldDescriptor (value)) { diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs index 4f0112a0cf0..21d02d6f450 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs @@ -97,9 +97,9 @@ static bool TryRewriteRegisterNativesLine (string line, string? ownerJniName, IJ bool changed = false; string newName = name; if (ownerJniName != null) { - var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (descriptor); + JniDescriptorText.MethodDescriptorToJavaTypes (descriptor, out var javaParams, out string javaReturnType); string mappingName = R8Mapping.JniMemberNameToMappingName (name); - if (mapping.TryMapMethod (ownerJniName, mappingName, javaParams, out string renamedMethod)) { + if (mapping.TryMapMethod (ownerJniName, mappingName, javaParams, javaReturnType, out string renamedMethod)) { newName = renamedMethod; changed = true; } @@ -142,9 +142,9 @@ static bool TryRewriteJniPeerMemberId (string value, string? ownerJniName, IJniN if (ownerJniName != null) { if (isMethod) { - var javaParams = JniDescriptorText.MethodDescriptorToJavaParameterTypes (descriptor); + JniDescriptorText.MethodDescriptorToJavaTypes (descriptor, out var javaParams, out string javaReturnType); string mappingName = R8Mapping.JniMemberNameToMappingName (name); - if (mapping.TryMapMethod (ownerJniName, mappingName, javaParams, out string renamedMethod)) { + if (mapping.TryMapMethod (ownerJniName, mappingName, javaParams, javaReturnType, out string renamedMethod)) { newName = renamedMethod; changed = true; } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/NativeResourceSectionCopier.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/NativeResourceSectionCopier.cs index 6c4b8117400..bd7da021ad1 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/NativeResourceSectionCopier.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/NativeResourceSectionCopier.cs @@ -22,9 +22,9 @@ sealed class NativeResourceSectionCopier : ResourceSectionBuilder readonly byte [] section; readonly int originalRva; - readonly List dataEntryRvaOffsets; + readonly HashSet dataEntryRvaOffsets; - NativeResourceSectionCopier (byte [] section, int originalRva, List dataEntryRvaOffsets) + NativeResourceSectionCopier (byte [] section, int originalRva, HashSet dataEntryRvaOffsets) { this.section = section; this.originalRva = originalRva; @@ -52,7 +52,7 @@ sealed class NativeResourceSectionCopier : ResourceSectionBuilder } byte [] section = block.GetReader (0, directory.Size).ReadBytes (directory.Size); - var offsets = new List (); + var offsets = new HashSet (); CollectDataEntryOffsets (section, directory.RelativeVirtualAddress, directoryOffset: 0, depth: 0, offsets, new HashSet ()); return new NativeResourceSectionCopier (section, directory.RelativeVirtualAddress, offsets); } @@ -70,7 +70,7 @@ protected override void Serialize (BlobBuilder builder, SectionLocation location builder.WriteBytes (relocated); } - static void CollectDataEntryOffsets (byte [] section, int originalRva, int directoryOffset, int depth, List offsets, HashSet visited) + static void CollectDataEntryOffsets (byte [] section, int originalRva, int directoryOffset, int depth, HashSet offsets, HashSet visited) { if (depth > 8) { throw new JniRewriteException ("The Win32 resource directory nests more deeply than the PE format allows."); diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index 50746d51108..6ca0e0d6652 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -10,7 +10,7 @@ interface IJniNameMapping { bool TryMapClass (string className, out string mappedClassName); bool TryMapField (string owningClassName, string fieldName, out string mappedFieldName); - bool TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, out string mappedMethodName); + bool TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, string javaReturnType, out string mappedMethodName); bool TryMapMethodByNameOnly (string owningClassName, string methodName, out string mappedMethodName); } @@ -29,7 +29,7 @@ sealed class R8Mapping : IJniNameMapping // Original JNI class name -> (original field name -> obfuscated field name). readonly Dictionary> fields = new Dictionary> (StringComparer.Ordinal); - // Original JNI class name -> ("name(javaParam,javaParam,...)" -> obfuscated method name). + // Original JNI class name -> ("name(javaParam,javaParam,...):javaReturn" -> obfuscated method name). readonly Dictionary> methods = new Dictionary> (StringComparer.Ordinal); // Reverse member indexes are scoped by original class. Fields retain every candidate because @@ -46,8 +46,8 @@ bool IJniNameMapping.TryMapClass (string className, out string mappedClassName) bool IJniNameMapping.TryMapField (string owningClassName, string fieldName, out string mappedFieldName) => TryGetRenamedField (owningClassName, fieldName, out mappedFieldName); - bool IJniNameMapping.TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, out string mappedMethodName) - => TryGetRenamedMethod (owningClassName, methodName, javaParameterTypes, out mappedMethodName); + bool IJniNameMapping.TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, string javaReturnType, out string mappedMethodName) + => TryGetRenamedMethod (owningClassName, methodName, javaParameterTypes, javaReturnType, out mappedMethodName); bool IJniNameMapping.TryMapMethodByNameOnly (string owningClassName, string methodName, out string mappedMethodName) => TryGetRenamedMethodByNameOnly (owningClassName, methodName, out mappedMethodName); @@ -98,7 +98,7 @@ public static R8Mapping Parse (TextReader reader) throw new FormatException ($"mapping.txt:{lineNumber}: member mapping line found before any class mapping line: '{line}'."); } - if (!TryParseMemberLine (trimmed, out string memberName, out string []? javaParameterTypes, out string obfuscatedName)) { + if (!TryParseMemberLine (trimmed, out string memberName, out string []? javaParameterTypes, out string? javaReturnType, out string obfuscatedName)) { throw new FormatException ($"mapping.txt:{lineNumber}: could not parse member mapping line: '{line}'."); } @@ -117,7 +117,7 @@ public static R8Mapping Parse (TextReader reader) } // Method. - string key = BuildMethodKey (memberName, javaParameterTypes); + string key = BuildMethodKey (memberName, javaParameterTypes, javaReturnType ?? ""); if (!mapping.methods.TryGetValue (currentOriginalClass, out var classMethods)) { mapping.methods [currentOriginalClass] = classMethods = new Dictionary (StringComparer.Ordinal); } @@ -160,8 +160,8 @@ void BuildReverseMemberIndexes () } int parameterStart = method.Key.IndexOf ('('); string originalName = parameterStart < 0 ? method.Key : method.Key.Substring (0, parameterStart); - string parameters = parameterStart < 0 ? "()" : method.Key.Substring (parameterStart); - AddUnambiguousReverseEntry (reverseMethods, method.Value + parameters, originalName); + string signature = parameterStart < 0 ? "()" : method.Key.Substring (parameterStart); + AddUnambiguousReverseEntry (reverseMethods, method.Value + signature, originalName); } } } @@ -179,8 +179,8 @@ static void AddUnambiguousReverseEntry (Dictionary entries, stri /// Builds the lookup key used for methods: the JNI/Java member name plus its /// parameter types (in Java source form, e.g. "int", "android.os.Bundle", "java.lang.String[]"). /// - internal static string BuildMethodKey (string javaMethodName, IReadOnlyList javaParameterTypes) - => javaMethodName + "(" + string.Join (",", javaParameterTypes) + ")"; + internal static string BuildMethodKey (string javaMethodName, IReadOnlyList javaParameterTypes, string javaReturnType) + => javaMethodName + "(" + string.Join (",", javaParameterTypes) + "):" + javaReturnType; /// /// Translates a JNI member name (as it appears in a RegisterAttribute / encoded JniPeerMembers @@ -232,11 +232,11 @@ public IEnumerable GetOriginalMethodNames (string originalJniClassName, } } - public bool TryGetOriginalMethodName (string originalJniClassName, string obfuscatedMethodName, IReadOnlyList originalJavaParameterTypes, out string originalMethodName) + public bool TryGetOriginalMethodName (string originalJniClassName, string obfuscatedMethodName, IReadOnlyList originalJavaParameterTypes, string originalJavaReturnType, out string originalMethodName) { originalMethodName = ""; return originalMethods.TryGetValue (originalJniClassName, out var classMethods) && - classMethods.TryGetValue (BuildMethodKey (obfuscatedMethodName, originalJavaParameterTypes), out originalMethodName) && + classMethods.TryGetValue (BuildMethodKey (obfuscatedMethodName, originalJavaParameterTypes, originalJavaReturnType), out originalMethodName) && originalMethodName.Length != 0; } @@ -261,17 +261,18 @@ public bool TryGetRenamedField (string owningJniClassName, string originalFieldN return true; } - public bool TryGetRenamedMethod (string owningJniClassName, string javaMethodName, IReadOnlyList javaParameterTypes, out string obfuscatedMethodName) + public bool TryGetRenamedMethod (string owningJniClassName, string javaMethodName, IReadOnlyList javaParameterTypes, string javaReturnType, out string obfuscatedMethodName) { obfuscatedMethodName = ""; if (!methods.TryGetValue (owningJniClassName, out var classMethods)) { return false; } - if (!classMethods.TryGetValue (BuildMethodKey (javaMethodName, javaParameterTypes), out string? renamed) || renamed.Length == 0) { + string methodKey = BuildMethodKey (javaMethodName, javaParameterTypes, javaReturnType); + if (!classMethods.TryGetValue (methodKey, out string? renamed) || renamed.Length == 0) { return false; } obfuscatedMethodName = renamed; - accessedEntries.Add (BuildMethodEntry (owningJniClassName, BuildMethodKey (javaMethodName, javaParameterTypes))); + accessedEntries.Add (BuildMethodEntry (owningJniClassName, methodKey)); return true; } @@ -458,7 +459,7 @@ public bool TryMapField (string owningClassName, string fieldName, out string ma return true; } - public bool TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, out string mappedMethodName) + public bool TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, string javaReturnType, out string mappedMethodName) { mappedMethodName = ""; if (!mapping.TryGetOriginalClass (owningClassName, out string originalClassName)) { @@ -469,13 +470,14 @@ public bool TryMapMethod (string owningClassName, string methodName, IReadOnlyLi foreach (string parameterType in javaParameterTypes) { originalParameterTypes.Add (GetOriginalJavaType (parameterType)); } - if (!mapping.TryGetOriginalMethodName (originalClassName, methodName, originalParameterTypes, out mappedMethodName)) { + string originalReturnType = GetOriginalJavaType (javaReturnType); + if (!mapping.TryGetOriginalMethodName (originalClassName, methodName, originalParameterTypes, originalReturnType, out mappedMethodName)) { return false; } mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); mapping.accessedEntries.Add (BuildMethodEntry ( originalClassName, - BuildMethodKey (mappedMethodName, originalParameterTypes))); + BuildMethodKey (mappedMethodName, originalParameterTypes, originalReturnType))); return true; } @@ -547,10 +549,11 @@ static bool TryParseClassLine (string trimmed, out string originalClass, out str return originalClass.Length > 0 && obfuscatedClass.Length > 0; } - static bool TryParseMemberLine (string trimmed, out string name, out string []? javaParameterTypes, out string obfuscatedName) + static bool TryParseMemberLine (string trimmed, out string name, out string []? javaParameterTypes, out string? javaReturnType, out string obfuscatedName) { name = ""; javaParameterTypes = null; + javaReturnType = null; obfuscatedName = ""; const string arrow = " -> "; @@ -579,6 +582,7 @@ static bool TryParseMemberLine (string trimmed, out string name, out string []? } name = beforeParen.Substring (lastSpace + 1); + javaReturnType = beforeParen.Substring (0, lastSpace); javaParameterTypes = paramList.Length == 0 ? Array.Empty () : paramList.Split (','); @@ -591,6 +595,7 @@ static bool TryParseMemberLine (string trimmed, out string name, out string []? name = left.Substring (lastSpace + 1); javaParameterTypes = null; + javaReturnType = null; return name.Length > 0; } } diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 288b84b13c2..824074116f0 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -994,6 +994,7 @@ because xbuild doesn't support framework reference assemblies. <_PropertyCacheItems Include="ProjectFullPath=$(MSBuildProjectFullPath)" /> <_PropertyCacheItems Include="AndroidUseDesignerAssembly=$(AndroidUseDesignerAssembly)" /> <_PropertyCacheItems Include="AndroidTypeMapImplementation=$(AndroidTypeMapImplementation)" /> + <_PropertyCacheItems Include="AndroidEnableR8JniNameObfuscation=$(AndroidEnableR8JniNameObfuscation)" /> <_PropertyCacheItems Include="_AndroidUseMarshalMethods=$(_AndroidUseMarshalMethods)" /> <_PropertyCacheItems Include="_AndroidJcwCodegenTarget=$(_AndroidJcwCodegenTarget)" /> <_PropertyCacheItems Include="_AndroidAssemblyStoreCompressionLevel=$(_AndroidAssemblyStoreCompressionLevel)" /> @@ -3104,7 +3105,7 @@ because xbuild doesn't support framework reference assemblies. <_AndroidEnableR8JniNameRewriting - Condition=" '$(_AndroidEnableR8JniNameRewriting)' == '' and '$(AndroidLinkTool)' == 'r8' and '$(AndroidTypeMapImplementation)' == 'trimmable' and ('$(_AndroidRuntime)' == 'CoreCLR' or '$(_AndroidRuntime)' == 'NativeAOT') ">$(AndroidEnableR8JniNameObfuscation) + Condition=" '$(_AndroidEnableR8JniNameRewriting)' == '' and '$(AndroidLinkTool)' == 'r8' and '$(AndroidTypeMapImplementation)' == 'trimmable' and ('$(_AndroidRuntime)' == 'NativeAOT' or ('$(_AndroidRuntime)' == 'CoreCLR' and '$(PublishTrimmed)' == 'true')) ">$(AndroidEnableR8JniNameObfuscation) <_AndroidEnableR8JniNameRewriting Condition=" '$(_AndroidEnableR8JniNameRewriting)' == '' ">false From 5f550710366615b5f7b4499e2076262847ceeae8 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 13:36:54 +0200 Subject: [PATCH 13/14] Honor R8 JNI opt-in with NativeAOT defaults Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets | 1 - .../Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs | 4 +++- .../Xamarin.Android.Common.targets | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets index 422496f9601..5a8e0fa26dc 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets @@ -19,7 +19,6 @@ <_AndroidBuildRuntimeIdentifiersInParallel Condition=" ('$(_AndroidTrimmableTypemapTrimJavaCode)' == 'true' or '$(IlcGenerateDgmlFile)' == 'true') and '$(_AndroidBuildRuntimeIdentifiersInParallel)' == '' ">false <_TrimmableRuntimeProviderJavaName Condition=" '$(_TrimmableRuntimeProviderJavaName)' == '' ">net.dot.jni.nativeaot.NativeAotRuntimeProvider - r8 d8 True True diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index bf0154bafa8..cb6e15585f3 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -135,11 +135,13 @@ public R8JniPeer () { } }; var proj = new XamarinAndroidApplicationProject { IsRelease = true, - LinkTool = "r8", Sources = { peerSource, }, }; + if (runtime == AndroidRuntime.CoreCLR) { + proj.LinkTool = "r8"; + } proj.AndroidJavaSources.Add (new BuildItem (AndroidBuildActions.AndroidJavaSource, "UserJavaType.java") { TextContent = () => "package com.example; public class UserJavaType { }", Encoding = Encoding.ASCII, diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 824074116f0..0278edd0bfa 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -3102,8 +3102,8 @@ because xbuild doesn't support framework reference assemblies. - + r8 <_AndroidEnableR8JniNameRewriting Condition=" '$(_AndroidEnableR8JniNameRewriting)' == '' and '$(AndroidLinkTool)' == 'r8' and '$(AndroidTypeMapImplementation)' == 'trimmable' and ('$(_AndroidRuntime)' == 'NativeAOT' or ('$(_AndroidRuntime)' == 'CoreCLR' and '$(PublishTrimmed)' == 'true')) ">$(AndroidEnableR8JniNameObfuscation) <_AndroidEnableR8JniNameRewriting Condition=" '$(_AndroidEnableR8JniNameRewriting)' == '' ">false From ac2b30593ff5afa0659fe35d3de515239c7540fe Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 14:10:53 +0200 Subject: [PATCH 14/14] Avoid R8 reachability name collisions Restrict reverse mapping during post-link scanning to exact JNI entries recorded by the managed rewrite pass. This prevents ordinary short string literals from being mistaken for R8-obfuscated synthetic classes while preserving strict final reachability validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 3 +- .../Tasks/GenerateProguardConfiguration.cs | 10 ++++ .../GenerateProguardConfigurationTests.cs | 38 +++++++----- .../Utilities/JniRemapping/R8Mapping.cs | 59 +++++++++++++++---- 4 files changed, 81 insertions(+), 29 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index 5f1eacf3ae7..adf0089ffee 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets @@ -96,12 +96,13 @@ AfterTargets="_PrepareLinkedAssembliesForProguard" DependsOnTargets="_PrepareLinkedAssembliesForProguard" Condition=" '$(PublishTrimmed)' == 'true' and '$(_ProguardProjectConfiguration)' != '' " - Inputs="@(_LinkedAssemblyForProguard);$(_AndroidR8JniSeedMapping)" + Inputs="@(_LinkedAssemblyForProguard);$(_AndroidR8JniSeedMapping);$(_AndroidR8JniRewriteManifest)" Outputs="$(_ProguardProjectConfiguration);$(_AndroidR8JniReachabilityManifest)"> diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateProguardConfiguration.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateProguardConfiguration.cs index 936dde02b99..8a66d7aabf2 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateProguardConfiguration.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateProguardConfiguration.cs @@ -23,6 +23,8 @@ public class GenerateProguardConfiguration : AndroidTask public string? R8MappingFile { get; set; } + public string? R8RewriteManifestFile { get; set; } + public string? R8ReachabilityManifestFile { get; set; } R8Mapping? r8Mapping; @@ -31,6 +33,14 @@ public override bool RunTask () { if (!R8MappingFile.IsNullOrEmpty ()) { r8Mapping = R8Mapping.Load (R8MappingFile); + if (!R8RewriteManifestFile.IsNullOrEmpty ()) { + if (!File.Exists (R8RewriteManifestFile)) { + Log.LogCodedError ("XA4307", Properties.Resources.XA4307, + $"The R8 JNI rewrite manifest '{R8RewriteManifestFile}' was not found."); + return false; + } + r8Mapping.RestrictReverseLookupsTo (File.ReadLines (R8RewriteManifestFile)); + } } var dir = Path.GetDirectoryName (OutputFile); if (!dir.IsNullOrEmpty () && !Directory.Exists (dir)) { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs index 1c63c11118c..d006af74010 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs @@ -20,6 +20,7 @@ public void WritesOriginalKeepRulesAndReachabilityManifest () string assembly = Path.Combine (path, "Linked.dll"); string mapping = Path.Combine (path, "mapping.txt"); string proguard = Path.Combine (path, "proguard.cfg"); + string rewriteManifest = Path.Combine (path, "rewrite.txt"); string manifest = Path.Combine (path, "reachability.txt"); var fixture = new JniFixtureBuilder (); @@ -37,7 +38,8 @@ public void WritesOriginalKeepRulesAndReachabilityManifest () fixture.AddVoidMethod ("DirectLookup", fixture.EmitLoadStringBody ( fixture.String ("a/b/E"), fixture.String ("x"), - fixture.String ("()V"))); + fixture.String ("()V"), + fixture.String ("g"))); var type = fixture.AddType ("Managed", "MyView", fieldStart, methodStart); var property = fixture.Metadata.AddProperty ( @@ -90,14 +92,32 @@ java.lang.Object listener -> e void run() -> x acme.orig.ICallback -> a.b.F: void invoke() -> y + acme.orig.Synthetic -> g: """); + string expectedManifest = """ + C acme/orig/DirectTarget + C acme/orig/ICallback + C acme/orig/MyView + C android/view/View + F acme/orig/MyView count + F acme/orig/MyView enabled + F acme/orig/MyView listener + M acme/orig/DirectTarget run():void + M acme/orig/ICallback invoke():void + M acme/orig/MyView ():void + M acme/orig/MyView onClick(android.view.View):void + M acme/orig/MyView onClick(android.view.View,int):void + + """.ReplaceLineEndings (); + File.WriteAllText (rewriteManifest, expectedManifest); var task = new GenerateProguardConfiguration { BuildEngine = new MockBuildEngine (TestContext.Out), LinkedAssemblies = new [] { new TaskItem (assembly) }, OutputFile = proguard, R8MappingFile = mapping, + R8RewriteManifestFile = rewriteManifest, R8ReachabilityManifestFile = manifest, }; @@ -128,21 +148,7 @@ void invoke() -> y """.ReplaceLineEndings (), File.ReadAllText (proguard)); - Assert.AreEqual (""" - C acme/orig/DirectTarget - C acme/orig/ICallback - C acme/orig/MyView - C android/view/View - F acme/orig/MyView count - F acme/orig/MyView enabled - F acme/orig/MyView listener - M acme/orig/DirectTarget run():void - M acme/orig/ICallback invoke():void - M acme/orig/MyView ():void - M acme/orig/MyView onClick(android.view.View):void - M acme/orig/MyView onClick(android.view.View,int):void - - """.ReplaceLineEndings (), File.ReadAllText (manifest)); + Assert.AreEqual (expectedManifest, File.ReadAllText (manifest)); } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index 6ca0e0d6652..9607ea00122 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -37,6 +37,7 @@ sealed class R8Mapping : IJniNameMapping readonly Dictionary>> originalFields = new Dictionary>> (StringComparer.Ordinal); readonly Dictionary> originalMethods = new Dictionary> (StringComparer.Ordinal); readonly HashSet accessedEntries = new HashSet (StringComparer.Ordinal); + HashSet? allowedReverseEntries; public IEnumerable AccessedEntries => accessedEntries; @@ -54,6 +55,12 @@ bool IJniNameMapping.TryMapMethodByNameOnly (string owningClassName, string meth internal IJniNameMapping CreateReverseMapping () => new ReverseR8Mapping (this); + internal void RestrictReverseLookupsTo (IEnumerable manifestEntries) + => allowedReverseEntries = new HashSet (manifestEntries, StringComparer.Ordinal); + + bool IsReverseEntryAllowed (string entry) + => allowedReverseEntries == null || allowedReverseEntries.Contains (entry); + public static R8Mapping Load (string path) { using var reader = new StreamReader (path); @@ -435,7 +442,7 @@ public ReverseR8Mapping (R8Mapping mapping) public bool TryMapClass (string className, out string mappedClassName) { - if (!mapping.TryGetOriginalClass (className, out mappedClassName)) { + if (!TryGetAllowedOriginalClass (className, out mappedClassName)) { return false; } mapping.accessedEntries.Add (BuildClassEntry (mappedClassName)); @@ -445,24 +452,33 @@ public bool TryMapClass (string className, out string mappedClassName) public bool TryMapField (string owningClassName, string fieldName, out string mappedFieldName) { mappedFieldName = ""; - if (!mapping.TryGetOriginalClass (owningClassName, out string originalClassName) || + if (!TryGetAllowedOriginalClass (owningClassName, out string originalClassName) || !mapping.originalFields.TryGetValue (originalClassName, out var classFields) || !classFields.TryGetValue (fieldName, out var originalNames) || originalNames.Count == 0) { return false; } - mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); + string? firstOriginalName = null; foreach (string originalName in originalNames) { - mapping.accessedEntries.Add (BuildFieldEntry (originalClassName, originalName)); + string entry = BuildFieldEntry (originalClassName, originalName); + if (!mapping.IsReverseEntryAllowed (entry)) { + continue; + } + firstOriginalName ??= originalName; + mapping.accessedEntries.Add (entry); } - mappedFieldName = originalNames [0]; + if (firstOriginalName == null) { + return false; + } + mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); + mappedFieldName = firstOriginalName; return true; } public bool TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, string javaReturnType, out string mappedMethodName) { mappedMethodName = ""; - if (!mapping.TryGetOriginalClass (owningClassName, out string originalClassName)) { + if (!TryGetAllowedOriginalClass (owningClassName, out string originalClassName)) { return false; } @@ -474,17 +490,22 @@ public bool TryMapMethod (string owningClassName, string methodName, IReadOnlyLi if (!mapping.TryGetOriginalMethodName (originalClassName, methodName, originalParameterTypes, originalReturnType, out mappedMethodName)) { return false; } - mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); - mapping.accessedEntries.Add (BuildMethodEntry ( + string entry = BuildMethodEntry ( originalClassName, - BuildMethodKey (mappedMethodName, originalParameterTypes, originalReturnType))); + BuildMethodKey (mappedMethodName, originalParameterTypes, originalReturnType)); + if (!mapping.IsReverseEntryAllowed (entry)) { + mappedMethodName = ""; + return false; + } + mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); + mapping.accessedEntries.Add (entry); return true; } public bool TryMapMethodByNameOnly (string owningClassName, string methodName, out string mappedMethodName) { mappedMethodName = ""; - if (!mapping.TryGetOriginalClass (owningClassName, out string originalClassName) || + if (!TryGetAllowedOriginalClass (owningClassName, out string originalClassName) || !mapping.methods.TryGetValue (originalClassName, out var classMethods)) { return false; } @@ -494,9 +515,13 @@ public bool TryMapMethodByNameOnly (string owningClassName, string methodName, o if (!String.Equals (entry.Value, methodName, StringComparison.Ordinal)) { continue; } + string manifestEntry = BuildMethodEntry (originalClassName, entry.Key); + if (!mapping.IsReverseEntryAllowed (manifestEntry)) { + continue; + } int parameterStart = entry.Key.IndexOf ('('); firstOriginalName ??= parameterStart < 0 ? entry.Key : entry.Key.Substring (0, parameterStart); - mapping.accessedEntries.Add (BuildMethodEntry (originalClassName, entry.Key)); + mapping.accessedEntries.Add (manifestEntry); } if (firstOriginalName == null) { return false; @@ -512,10 +537,20 @@ string GetOriginalJavaType (string javaType) string suffix = suffixStart < 0 ? "" : javaType.Substring (suffixStart); string elementType = suffixStart < 0 ? javaType : javaType.Substring (0, suffixStart); string jniType = JavaNameToJni (elementType); - return mapping.TryGetOriginalClass (jniType, out string originalJniType) + return TryGetAllowedOriginalClass (jniType, out string originalJniType) ? originalJniType.Replace ('/', '.') + suffix : javaType; } + + bool TryGetAllowedOriginalClass (string obfuscatedJniClassName, out string originalJniClassName) + { + if (!mapping.TryGetOriginalClass (obfuscatedJniClassName, out originalJniClassName) || + !mapping.IsReverseEntryAllowed (BuildClassEntry (originalJniClassName))) { + originalJniClassName = ""; + return false; + } + return true; + } } static bool IsRemovedClassName (string className)