diff --git a/src/libraries/System.Runtime.Loader/tests/AssemblyResolutionDowngradeTest.cs b/src/libraries/System.Runtime.Loader/tests/AssemblyResolutionDowngradeTest.cs new file mode 100644 index 00000000000000..f0f7de9c208fa6 --- /dev/null +++ b/src/libraries/System.Runtime.Loader/tests/AssemblyResolutionDowngradeTest.cs @@ -0,0 +1,250 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Reflection; +using Microsoft.DotNet.RemoteExecutor; +using Xunit; + +namespace System.Runtime.Loader.Tests +{ + public class AssemblyResolutionDowngradeTest : FileCleanupTestBase + { + private const string TestAssemblyName = "System.Runtime.Loader.Test.VersionDowngrade"; + + /// + /// Test that AppDomain.AssemblyResolve can resolve a higher version request with a lower version assembly. + /// This tests the scenario where code requests assembly version 3.0.0 but the resolver provides 1.0.0. + /// + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void AppDomainAssemblyResolve_CanDowngradeVersion() + { + RemoteExecutor.Invoke(() => { + string assemblyV1Path = GetTestAssemblyPath("System.Runtime.Loader.Test.AssemblyVersion1"); + + bool resolverCalled = false; + + ResolveEventHandler handler = (sender, args) => + { + Assert.Same(AppDomain.CurrentDomain, sender); + Assert.NotNull(args); + Assert.NotNull(args.Name); + + var requestedName = new AssemblyName(args.Name); + if (requestedName.Name == TestAssemblyName) + { + resolverCalled = true; + // Request is for version 3.0, but we return version 1.0 (downgrade) + Assert.Equal(new Version(3, 0, 0, 0), requestedName.Version); + return Assembly.LoadFile(assemblyV1Path); + } + return null; + }; + + AppDomain.CurrentDomain.AssemblyResolve += handler; + + try + { + // Request version 3.0.0 but expect to get 1.0.0 via downgrade + var requestedAssemblyName = new AssemblyName($"{TestAssemblyName}, Version=3.0.0.0"); + Assembly resolvedAssembly = Assembly.Load(requestedAssemblyName); + + Assert.NotNull(resolvedAssembly); + Assert.True(resolverCalled, "Assembly resolver should have been called"); + + // Verify we got the 1.0.0 assembly (downgrade successful) + Assert.Equal(new Version(1, 0, 0, 0), resolvedAssembly.GetName().Version); + + // Verify the assembly works as expected + Type testType = resolvedAssembly.GetType("System.Runtime.Loader.Tests.VersionTestClass"); + Assert.NotNull(testType); + + string version = (string)testType.GetMethod("GetVersion").Invoke(null, null); + Assert.Equal("1.0.0", version); + } + finally + { + AppDomain.CurrentDomain.AssemblyResolve -= handler; + } + }).Dispose(); + } + + /// + /// Test that AssemblyLoadContext.Resolving event can resolve a higher version request with a lower version assembly. + /// + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void AssemblyLoadContextResolving_CanDowngradeVersion() + { + RemoteExecutor.Invoke(() => { + string assemblyV1Path = GetTestAssemblyPath("System.Runtime.Loader.Test.AssemblyVersion1"); + + bool resolverCalled = false; + + Func handler = (context, name) => + { + if (name.Name == TestAssemblyName) + { + resolverCalled = true; + // Request is for version 3.0, but we return version 1.0 (downgrade) + Assert.Equal(new Version(3, 0, 0, 0), name.Version); + return context.LoadFromAssemblyPath(assemblyV1Path); + } + return null; + }; + + AssemblyLoadContext.Default.Resolving += handler; + + try + { + // Request version 3.0.0 but expect to get 1.0.0 via downgrade + var requestedAssemblyName = new AssemblyName($"{TestAssemblyName}, Version=3.0.0.0"); + Assembly resolvedAssembly = AssemblyLoadContext.Default.LoadFromAssemblyName(requestedAssemblyName); + + Assert.NotNull(resolvedAssembly); + Assert.True(resolverCalled, "Assembly resolver should have been called"); + + // Verify we got the 1.0.0 assembly (downgrade successful) + Assert.Equal(new Version(1, 0, 0, 0), resolvedAssembly.GetName().Version); + + // Verify the assembly works as expected + Type testType = resolvedAssembly.GetType("System.Runtime.Loader.Tests.VersionTestClass"); + Assert.NotNull(testType); + + string version = (string)testType.GetMethod("GetVersion").Invoke(null, null); + Assert.Equal("1.0.0", version); + } + finally + { + AssemblyLoadContext.Default.Resolving -= handler; + } + }).Dispose(); + } + + /// + /// Test that a custom AssemblyLoadContext.Load override can resolve a higher version request with a lower version assembly. + /// + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void CustomAssemblyLoadContextLoad_CanDowngradeVersion() + { + RemoteExecutor.Invoke(() => { + string assemblyV1Path = GetTestAssemblyPath("System.Runtime.Loader.Test.AssemblyVersion1"); + + var customContext = new DowngradeAssemblyLoadContext(assemblyV1Path); + + // Request version 3.0.0 but expect to get 1.0.0 via downgrade + var requestedAssemblyName = new AssemblyName($"{TestAssemblyName}, Version=3.0.0.0"); + Assembly resolvedAssembly = customContext.LoadFromAssemblyName(requestedAssemblyName); + + Assert.NotNull(resolvedAssembly); + Assert.True(customContext.LoadCalled, "Custom Load method should have been called"); + + // Verify we got the 1.0.0 assembly (downgrade successful) + Assert.Equal(new Version(1, 0, 0, 0), resolvedAssembly.GetName().Version); + + // Verify the assembly works as expected + Type testType = resolvedAssembly.GetType("System.Runtime.Loader.Tests.VersionTestClass"); + Assert.NotNull(testType); + + string version = (string)testType.GetMethod("GetVersion").Invoke(null, null); + Assert.Equal("1.0.0", version); + + // Verify that the correct ALC loaded the assembly + Assert.Equal(customContext, AssemblyLoadContext.GetLoadContext(resolvedAssembly)); + }).Dispose(); + } + + /// + /// Test that normal runtime resolution (without extension mechanisms) will NOT allow downgrades. + /// This test verifies the baseline behavior that downgrades only work via extension mechanisms. + /// Note: On Mono, downgrades are allowed even in normal resolution, so this test behaves differently. + /// + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void NormalResolution_CannotDowngradeVersion() + { + RemoteExecutor.Invoke(() => { + string assemblyV1Path = GetTestAssemblyPath("System.Runtime.Loader.Test.AssemblyVersion1"); + + // First, load the version 1.0.0 assembly into the default context + Assembly loadedV1 = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyV1Path); + Assert.Equal(new Version(1, 0, 0, 0), loadedV1.GetName().Version); + + // Now try to load version 3.0.0 + var requestedAssemblyName = new AssemblyName($"{TestAssemblyName}, Version=3.0.0.0"); + + if (PlatformDetection.IsMonoRuntime) + { + // On Mono, normal resolution allows downgrades, so this should succeed + // and return the already-loaded 1.0.0 assembly + Assembly resolvedAssembly = AssemblyLoadContext.Default.LoadFromAssemblyName(requestedAssemblyName); + Assert.NotNull(resolvedAssembly); + Assert.Equal(new Version(1, 0, 0, 0), resolvedAssembly.GetName().Version); + Assert.Same(loadedV1, resolvedAssembly); + } + else + { + // On CoreCLR, normal resolution should NOT automatically + // downgrade to the already-loaded 1.0.0 version, it should fail + Assert.Throws(() => + AssemblyLoadContext.Default.LoadFromAssemblyName(requestedAssemblyName)); + } + }).Dispose(); + } + + private static string GetTestAssemblyPath(string assemblyProject) + { + // Map project names to actual embedded resource names + string resourceName = assemblyProject switch + { + "System.Runtime.Loader.Test.AssemblyVersion1" => "System.Runtime.Loader.Tests.AssemblyVersion1.dll", + _ => throw new ArgumentException($"Unknown test assembly project: {assemblyProject}") + }; + + // Extract the embedded assembly to a temporary file + string tempPath = Path.Combine(Path.GetTempPath(), $"{assemblyProject}_{Guid.NewGuid()}.dll"); + + using (Stream resourceStream = typeof(AssemblyResolutionDowngradeTest).Assembly.GetManifestResourceStream(resourceName)) + { + if (resourceStream is null) + { + throw new FileNotFoundException($"Could not find embedded resource: {resourceName}"); + } + + using (FileStream fileStream = File.Create(tempPath)) + { + resourceStream.CopyTo(fileStream); + } + } + + return tempPath; + } + + /// + /// Custom AssemblyLoadContext that can downgrade version requests. + /// + private class DowngradeAssemblyLoadContext : AssemblyLoadContext + { + private readonly string _downgradePath; + + public bool LoadCalled { get; private set; } + + public DowngradeAssemblyLoadContext(string downgradePath) : base("DowngradeContext") + { + _downgradePath = downgradePath; + } + + protected override Assembly Load(AssemblyName assemblyName) + { + LoadCalled = true; + + if (assemblyName.Name == TestAssemblyName) + { + // Request is for version 3.0, but we return version 1.0 (downgrade) + Assert.Equal(new Version(3, 0, 0, 0), assemblyName.Version); + return LoadFromAssemblyPath(_downgradePath); + } + + return null; + } + } + } +} \ No newline at end of file diff --git a/src/libraries/System.Runtime.Loader/tests/System.Runtime.Loader.Test.AssemblyVersion1/System.Runtime.Loader.Test.AssemblyVersion1.csproj b/src/libraries/System.Runtime.Loader/tests/System.Runtime.Loader.Test.AssemblyVersion1/System.Runtime.Loader.Test.AssemblyVersion1.csproj new file mode 100644 index 00000000000000..b50eb00e2e5a54 --- /dev/null +++ b/src/libraries/System.Runtime.Loader/tests/System.Runtime.Loader.Test.AssemblyVersion1/System.Runtime.Loader.Test.AssemblyVersion1.csproj @@ -0,0 +1,13 @@ + + + $(NetCoreAppCurrent);netstandard2.0 + 1.0.0.0 + 1.0.0.0 + 1.0.0 + System.Runtime.Loader.Test.VersionDowngrade + false + + + + + \ No newline at end of file diff --git a/src/libraries/System.Runtime.Loader/tests/System.Runtime.Loader.Test.AssemblyVersion1/VersionTestClass.cs b/src/libraries/System.Runtime.Loader/tests/System.Runtime.Loader.Test.AssemblyVersion1/VersionTestClass.cs new file mode 100644 index 00000000000000..5e6d347447ca4d --- /dev/null +++ b/src/libraries/System.Runtime.Loader/tests/System.Runtime.Loader.Test.AssemblyVersion1/VersionTestClass.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Runtime.Loader.Tests +{ + public class VersionTestClass + { + public static string GetVersion() + => typeof(VersionTestClass).Assembly.GetName().Version?.ToString(3) ?? "Unknown"; + } +} \ No newline at end of file diff --git a/src/libraries/System.Runtime.Loader/tests/System.Runtime.Loader.Tests.csproj b/src/libraries/System.Runtime.Loader/tests/System.Runtime.Loader.Tests.csproj index 921723bdc304ed..f9f54c521f0240 100644 --- a/src/libraries/System.Runtime.Loader/tests/System.Runtime.Loader.Tests.csproj +++ b/src/libraries/System.Runtime.Loader/tests/System.Runtime.Loader.Tests.csproj @@ -30,6 +30,7 @@ + @@ -50,6 +51,7 @@ +