diff --git a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs index 3dfc4a81..0d8afed6 100644 --- a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs +++ b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs @@ -121,6 +121,57 @@ public void DerivedTypeScanDoesNotFollowSameNameAssembliesWithDifferentIdentitie AppDomain.CurrentDomain.GetAssemblies())); } + [Theory] + [InlineData("S1API, Version=3.0.1.0, Culture=neutral, PublicKeyToken=null")] + [InlineData("S1API, Version=2.9.2.0, Culture=neutral, PublicKeyToken=null")] + [InlineData("S1API, Version=3.2.1.0, Culture=neutral, PublicKeyToken=null")] + [InlineData("s1api, Version=4.0.0.0, Culture=neutral, PublicKeyToken=null")] + public void AReferenceToAnyVersionOfTheBaseAssemblyBindsToIt(string reference) + { + var loaded = new AssemblyName("S1API, Version=3.2.1.0, Culture=neutral, PublicKeyToken=null"); + + Assert.True(ReflectionUtils.ReferenceBindsToDefinition(new AssemblyName(reference), loaded)); + } + + [Fact] + public void AReferenceToADifferentAssemblyNameDoesNotBind() + { + var loaded = new AssemblyName("S1API, Version=3.2.1.0, Culture=neutral, PublicKeyToken=null"); + + Assert.False(ReflectionUtils.ReferenceBindsToDefinition( + new AssemblyName("S1APILoader, Version=3.2.1.0, Culture=neutral, PublicKeyToken=null"), + loaded)); + } + + [Fact] + public void AReferenceWithADifferentPublicKeyTokenDoesNotBind() + { + var signed = new AssemblyName("Example, Version=1.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"); + var unsigned = new AssemblyName("Example, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"); + + Assert.False(ReflectionUtils.ReferenceBindsToDefinition(signed, unsigned)); + Assert.False(ReflectionUtils.ReferenceBindsToDefinition(unsigned, signed)); + Assert.True(ReflectionUtils.ReferenceBindsToDefinition(signed, signed)); + } + + [Fact] + public void ALoadedAssemblyOfADifferentVersionIsFollowedOnlyWhenItIsTheOnlyOneOfThatName() + { + var reference = new AssemblyName("Library, Version=1.5.0.0, Culture=neutral, PublicKeyToken=null"); + var newer = new AssemblyName("Library, Version=1.6.0.0, Culture=neutral, PublicKeyToken=null"); + + Assert.True(ReflectionUtils.ShouldFollowLoadedReference(newer, reference, loadedAssembliesWithThatName: 1)); + Assert.False(ReflectionUtils.ShouldFollowLoadedReference(newer, reference, loadedAssembliesWithThatName: 2)); + } + + [Fact] + public void ALoadedAssemblyWithTheExactIdentityIsAlwaysFollowed() + { + var reference = new AssemblyName("Library, Version=1.5.0.0, Culture=neutral, PublicKeyToken=null"); + + Assert.True(ReflectionUtils.ShouldFollowLoadedReference(reference, reference, loadedAssembliesWithThatName: 2)); + } + private static AssemblyBuilder CreateDynamicAssembly(string name, Version version) { var assemblyName = new AssemblyName(name) diff --git a/S1API/Internal/Utils/ReflectionUtils.cs b/S1API/Internal/Utils/ReflectionUtils.cs index 853db58b..a2ec62db 100644 --- a/S1API/Internal/Utils/ReflectionUtils.cs +++ b/S1API/Internal/Utils/ReflectionUtils.cs @@ -137,7 +137,7 @@ private static bool ReferencesAssemblyTransitively( foreach (AssemblyName referencedAssembly in referencedAssemblies) { - if (AssemblyIdentityMatches(referencedAssembly, baseAssemblyName)) + if (ReferenceBindsToDefinition(referencedAssembly, baseAssemblyName)) return true; string referencedName = referencedAssembly.Name ?? string.Empty; @@ -147,7 +147,10 @@ private static bool ReferencesAssemblyTransitively( foreach (Assembly loadedReference in loadedReferences) { - if (!AssemblyIdentityMatches(loadedReference.GetName(), referencedAssembly)) + if (!ShouldFollowLoadedReference( + loadedReference.GetName(), + referencedAssembly, + loadedReferences.Length)) continue; if (ReferencesAssemblyTransitively( @@ -164,6 +167,45 @@ private static bool ReferencesAssemblyTransitively( return false; } + /// + /// INTERNAL: Does this reference bind to that assembly? The simple name and public key token must match; the + /// version need not, because the runtime binds a reference to whichever assembly of that name is loaded. A mod + /// built against an older S1API still runs against the loaded one, so it must still be scanned for the types + /// that derive from it. + /// + internal static bool ReferenceBindsToDefinition( + AssemblyName referenceAssemblyName, + AssemblyName definitionAssemblyName) + { + if (!string.Equals( + referenceAssemblyName.Name, + definitionAssemblyName.Name, + StringComparison.OrdinalIgnoreCase)) + return false; + + byte[] referenceToken = referenceAssemblyName.GetPublicKeyToken() ?? Array.Empty(); + byte[] definitionToken = definitionAssemblyName.GetPublicKeyToken() ?? Array.Empty(); + return referenceToken.SequenceEqual(definitionToken); + } + + /// + /// INTERNAL: Should the scan follow a reference into this loaded assembly? An exact identity always; a different + /// version only when it is the one assembly of that name loaded, since then the reference can only bind to it. + /// With several same-named assemblies loaded it is unknown which one the reference means, so only an exact + /// match is followed. + /// + internal static bool ShouldFollowLoadedReference( + AssemblyName loadedAssemblyName, + AssemblyName referenceAssemblyName, + int loadedAssembliesWithThatName) + { + if (AssemblyIdentityMatches(loadedAssemblyName, referenceAssemblyName)) + return true; + + return loadedAssembliesWithThatName == 1 + && ReferenceBindsToDefinition(referenceAssemblyName, loadedAssemblyName); + } + private static bool AssemblyIdentityMatches( AssemblyName referenceAssemblyName, AssemblyName definitionAssemblyName) =>