Skip to content

AdditionalRuntimesStrategy loads a reference assembly from a runtime pack, throwing BadImageFormatException (x86) #1835

Description

@paulirwin

Summary

When test code triggers an AssemblyLoadContext.Resolving event for an assembly that exists as a reference assembly in an additional runtime pack (e.g. System.Security.Permissions.dll in Microsoft.WindowsDesktop.App), AdditionalRuntimesStrategy.TryToResolve calls LoadFromAssemblyPath() on it unconditionally. Reference assemblies (Version=0.0.0.0) cannot be loaded for execution, so the load throws BadImageFormatException. Because the resolver does not catch it, the exception propagates out of whatever call raised the Resolving event (here a Type.GetType(...) call), often turning a benign "type not found → null" into a hard failure.

I observe this on x86 test runs (x64 resolves a real assembly and does not hit the reference copy).

Environment / versions

  • NUnit Framework: 4.6.1 (the only framework version in use)
  • NUnit Engine: nunit.engine.core, Version=3.22.0.0 (bundled inside NUnit3TestAdapter 6.2.0)
  • NUnit3TestAdapter: 6.2.0
  • Microsoft.NET.Test.Sdk: 17.11.1
  • Install method: NuGet (PackageReference)
  • Runner: dotnet test (VSTest + NUnit3TestAdapter). Not the standalone nunit-console.
  • OS / arch: Windows; reproduces with dotnet test --arch x86, does not reproduce with --arch x64
  • Target framework: net8.0
  • Required precondition: a Microsoft.WindowsDesktop.App shared runtime is installed (reproduced with 3.1.32)

Full command line

dotnet test -c Release --arch x86      # fails
dotnet test -c Release --arch x64      # passes

Component

The failing types are NUnit.Engine.Internal.TestAssemblyResolver and AdditionalRuntimesStrategy, which live in nunit.engine.core (consumed here via NUnit3TestAdapter 6.2.0, which bundles nunit.engine.core, Version=3.22.0.0). The current main of this repo still contains the unguarded load in src/NUnitCommon/nunit.agent.core/TestAssemblyResolver.cs:

public override bool TryToResolve(AssemblyLoadContext loadContext, AssemblyName assemblyName, [NotNullWhen(true)] out Assembly? loadedAssembly)
{
    loadedAssembly = null;
    if (assemblyName.Version is null)
        return false;

    if (!DotNet.FindBestRuntime(assemblyName.Version, _runtimeName, _x86, out DotNet.RuntimeInfo? runtime))
        return false;

    string candidate = Path.Combine(runtime.Path, runtime.Version.ToString(), assemblyName.Name + ".dll");
    if (!File.Exists(candidate))
        return false;

    loadedAssembly = loadContext.LoadFromAssemblyPath(candidate);   // <-- no check that `candidate` is runnable; no catch
    return true;
}

There is no check that candidate is a runnable (non-reference) assembly, and the LoadFromAssemblyPath call is not guarded, so a reference assembly in the runtime pack directory takes the whole resolution down.

Minimal repro

Two files. Run with dotnet test --arch x86 (fails) and --arch x64 (passes).

Repro.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
    <PackageReference Include="NUnit" Version="4.6.1" />
    <PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
  </ItemGroup>
</Project>

ReproTests.cs

using System;
using NUnit.Framework;

namespace Repro
{
    public class Tests
    {
        // System.Security.Policy.PolicyException only exists on .NET Framework, so on modern
        // .NET, Type.GetType(..., throwOnError: false) is documented to return null (never throw).
        // Under the NUnit host on x86 it instead throws BadImageFormatException.
        // Passes on x64; fails on x86.
        [Test]
        public void ProbeMustNotThrow()
        {
            Assert.DoesNotThrow(
                (TestDelegate)(() => Type.GetType("System.Security.Policy.PolicyException, mscorlib", throwOnError: false)));
        }
    }
}

Actual result (x86)

System.BadImageFormatException : Could not load file or assembly 'System.Security.Permissions, Version=0.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'. Format of the executable (.exe) or library (.dll) is invalid.
  ----> System.BadImageFormatException : Could not load file or assembly 'C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App\3.1.32\System.Security.Permissions.dll'. Format of the executable (.exe) or library (.dll) is invalid.
   at System.Runtime.Loader.AssemblyLoadContext.LoadFromAssemblyPath(String assemblyPath)
   at NUnit.Engine.Internal.AdditionalRuntimesStrategy.TryToResolve(AssemblyLoadContext loadContext, AssemblyName assemblyName, Assembly& loadedAssembly)
   at NUnit.Engine.Internal.TestAssemblyResolver.OnResolving(AssemblyLoadContext loadContext, AssemblyName assemblyName)
   at System.Runtime.Loader.AssemblyLoadContext.ResolveUsingResolvingEvent(...)
   at System.Reflection.RuntimeAssembly.GetTypeCore(...)
   at System.Type.GetType(String typeName, Boolean throwOnError)
   at Repro.Tests.ProbeMustNotThrow()

Failed: 1 on x86; Passed: 1 on x64.

Expected result

Type.GetType(..., throwOnError: false) for a type that does not exist on the current runtime should return null. The resolver should not turn a benign resolution miss into a BadImageFormatException by attempting to execute a reference assembly.

Suggested fix

In AdditionalRuntimesStrategy.TryToResolve, either skip reference assemblies or guard the load and treat a failure as "not resolved":

try
{
    loadedAssembly = loadContext.LoadFromAssemblyPath(candidate);
    return true;
}
catch (BadImageFormatException)
{
    // candidate is a reference assembly (or otherwise not runnable); treat as unresolved
    loadedAssembly = null;
    return false;
}

Notes / related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions