// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
#if NETCOREAPP
using System.Runtime.Loader;
#endif
using Microsoft.Testing.Platform.Helpers;
namespace Microsoft.Testing.Platform.DynamicExtensions;
///
/// Default .
///
///
///
/// On .NET, each distinct extension assembly is loaded into its own AssemblyLoadContext whose
/// dependencies are resolved from the extension's own .deps.json, so an extension cannot conflict with
/// the test application's dependency graph or with another extension's.
///
///
/// On .NET Framework (the netstandard2.0 asset) AssemblyLoadContext does not exist, so the assembly is
/// loaded with Assembly.LoadFrom and no isolation is available. reports this so
/// the caller can record it in the diagnostic log.
///
///
internal sealed class DynamicExtensionAssemblyLoader : IDynamicExtensionAssemblyLoader
{
private readonly Dictionary _loadedAssemblies = [with(DynamicExtensionConstants.PathComparer)];
#if NETCOREAPP
private readonly IFileSystem _fileSystem;
#endif
public DynamicExtensionAssemblyLoader(IFileSystem fileSystem)
{
#if NETCOREAPP
_fileSystem = fileSystem;
#else
// The netstandard2.0 asset cannot isolate, so it never probes for dependencies itself.
_ = fileSystem;
#endif
}
#if NETCOREAPP
public bool IsIsolated => true;
#else
public bool IsIsolated => false;
#endif
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' may break when trimming", Justification = "Loading an external assembly is the feature, and trimming cannot remove anything from that file since it is not part of the application. It can remove anything the *application* did not reference, including BCL members, so an extension calling one of those can load and then fail at run time -- a documented limitation of combining trimming with dynamic extensions (see docs/RFCs/023-Dynamic-Extension-Loading.md) rather than something this call site can fix. Runtimes that cannot load assemblies dynamically are rejected earlier by DynamicExtensionLoader.")]
[SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Loading an assembly from a file is unsupported on some platforms (for example browser). The resulting PlatformNotSupportedException is wrapped by DynamicExtensionLoader into an actionable error naming the manifest and the extension.")]
public Assembly LoadAssembly(string assemblyPath)
{
if (_loadedAssemblies.TryGetValue(assemblyPath, out Assembly? cached))
{
return cached;
}
#if NETCOREAPP
DynamicExtensionLoadContext context = new(assemblyPath, _fileSystem);
Assembly assembly = context.LoadFromAssemblyPath(assemblyPath);
#else
var assembly = Assembly.LoadFrom(assemblyPath);
#endif
_loadedAssemblies.Add(assemblyPath, assembly);
return assembly;
}
#if NETCOREAPP
private sealed class DynamicExtensionLoadContext : AssemblyLoadContext
{
private static readonly string[] AssemblyExtensions = [".dll", ".exe"];
private readonly AssemblyDependencyResolver? _resolver;
private readonly IFileSystem _fileSystem;
private readonly string _directory;
[SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "See DynamicExtensionAssemblyLoader.LoadAssembly. Dependency resolution failures degrade to probing the extension directory.")]
public DynamicExtensionLoadContext(string assemblyPath, IFileSystem fileSystem)
: base($"MicrosoftTestingPlatformDynamicExtension:{assemblyPath}", isCollectible: false)
{
_fileSystem = fileSystem;
_directory = Path.GetDirectoryName(assemblyPath) ?? string.Empty;
try
{
_resolver = new AssemblyDependencyResolver(assemblyPath);
}
catch (Exception)
{
// The extension was deployed without a usable .deps.json (for example xcopied). Fall back to
// probing the extension's own directory, which still keeps its dependencies out of the host's
// load context.
_resolver = null;
}
}
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' may break when trimming", Justification = "See DynamicExtensionAssemblyLoader.LoadAssembly.")]
[SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "See DynamicExtensionAssemblyLoader.LoadAssembly.")]
protected override Assembly? Load(AssemblyName assemblyName)
{
// The platform contract must have a single identity across the host and every extension, otherwise
// the ITestApplicationBuilder the extension sees is a different type from the one we pass to it —
// and the same holds for the abstractions extensions exchange with each other. A listed contract is
// therefore never loaded into this context, even when only the extension carries it.
if (IsSharedContractAssembly(assemblyName.Name))
{
return ResolveSharedContractAssembly(assemblyName);
}
string? resolvedPath = _resolver?.ResolveAssemblyToPath(assemblyName);
resolvedPath ??= ProbeDirectory(assemblyName);
// Delegating to the default context is required, not merely convenient: the resolver returns no
// path for framework assemblies, so an extension that could not fall back would fail on
// System.Runtime. The cost is that a *missing* private dependency also falls through and can bind
// to an application assembly of the same name. That is a deployment mistake rather than a trust
// problem — both directories are fully trusted application folders — and the two cases are
// indistinguishable here, since neither resolved. See the RFC's open questions.
return resolvedPath is null ? null : LoadFromAssemblyPath(resolvedPath);
}
///
/// Resolves a shared contract assembly to the one copy every extension and the host must agree on,
/// deliberately ignoring the version the extension was compiled against.
///
///
///
/// An already-loaded copy is preferred first. Passing the extension's full to
/// would apply version binding, so
/// an extension compiled against a newer platform than the host would fail to bind, silently fall
/// through to its own private copy, and end up with a different ITestApplicationBuilder identity
/// — surfacing as a baffling "the type does not expose AddExtensions" error rather than the version
/// mismatch it really is. If the versions are genuinely incompatible, the resulting
/// is the honest failure, and the same one a statically referenced
/// extension would produce.
///
///
/// Being loaded is not the same as being available. Dynamic hooks run before the statically registered
/// extensions (RFC section 4), so a contract such as
/// Microsoft.Testing.Extensions.TrxReport.Abstractions is frequently present in the application's
/// dependency graph but not yet touched. Settling for the extension's private copy in that window would
/// split the contract the moment the static extension loaded the real one, so when nothing is loaded yet
/// the default context is asked to load it — by simple name only, which cannot fail on version.
///
///
/// Finally, the application may not carry the contract at all, which is the ordinary manifest-only
/// deployment: the extension brings its own copy. Loading that copy into *this* context would give every
/// extension its own identity, so a capability implemented by one dynamic extension would be invisible to
/// another — exactly the split the shared list exists to prevent. The first copy found is therefore
/// promoted into the default context, making it canonical for every extension that follows.
///
///
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' may break when trimming", Justification = "See DynamicExtensionAssemblyLoader.LoadAssembly.")]
[SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "See DynamicExtensionAssemblyLoader.LoadAssembly.")]
private Assembly? ResolveSharedContractAssembly(AssemblyName assemblyName)
{
string? simpleName = assemblyName.Name;
if (RoslynString.IsNullOrEmpty(simpleName))
{
return null;
}
foreach (Assembly loaded in Default.Assemblies)
{
if (string.Equals(loaded.GetName().Name, simpleName, StringComparison.OrdinalIgnoreCase))
{
return loaded;
}
}
try
{
return Default.LoadFromAssemblyName(new AssemblyName(simpleName!));
}
catch (FileNotFoundException)
{
// Genuinely absent from the host. Only this case falls through: a FileLoadException or
// BadImageFormatException means the host does carry the contract but something is wrong with it,
// and silently substituting a private copy would hide that behind a type-identity mismatch.
}
string? resolvedPath = _resolver?.ResolveAssemblyToPath(assemblyName) ?? ProbeDirectory(assemblyName);
return resolvedPath is null ? null : Default.LoadFromAssemblyPath(resolvedPath);
}
private static bool IsSharedContractAssembly(string? simpleName)
{
foreach (string shared in DynamicExtensionConstants.SharedContractAssemblyNames)
{
if (string.Equals(simpleName, shared, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
[SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "See DynamicExtensionAssemblyLoader.LoadAssembly.")]
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
string? resolvedPath = _resolver?.ResolveUnmanagedDllToPath(unmanagedDllName);
return resolvedPath is null ? IntPtr.Zero : LoadUnmanagedDllFromPath(resolvedPath);
}
private string? ProbeDirectory(AssemblyName assemblyName)
{
string? simpleName = assemblyName.Name;
if (RoslynString.IsNullOrEmpty(simpleName) || _directory.Length == 0)
{
return null;
}
// Satellite assemblies live under a culture sub-directory by convention. Only reachable when the
// extension was deployed without a usable .deps.json; otherwise the resolver handles them.
string? culture = assemblyName.CultureName;
string directory = RoslynString.IsNullOrEmpty(culture)
? _directory
: Path.Combine(_directory, culture!);
foreach (string extension in AssemblyExtensions)
{
string candidate = Path.Combine(directory, simpleName + extension);
if (_fileSystem.ExistFile(candidate))
{
return candidate;
}
}
return null;
}
}
#endif
}