#nullable enable // Copyright (c) Nate McMaster. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Runtime.Loader; using BTCPayServer.Plugins.Dotnet.LibraryModel; namespace BTCPayServer.Plugins.Dotnet.Loader { /// /// A builder for creating an instance of . /// public class AssemblyLoadContextBuilder { private readonly List _additionalProbingPaths = new(); private readonly List _resourceProbingPaths = new(); private readonly List _resourceProbingSubpaths = new(); private readonly Dictionary _managedLibraries = new(StringComparer.Ordinal); private readonly Dictionary _nativeLibraries = new(StringComparer.Ordinal); private readonly HashSet _privateAssemblies = new(StringComparer.Ordinal); private readonly HashSet _defaultAssemblies = new(StringComparer.Ordinal); private AssemblyLoadContext _defaultLoadContext = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()) ?? AssemblyLoadContext.Default; private string? _mainAssemblyPath; private bool _preferDefaultLoadContext; private bool _lazyLoadReferences; private bool _isCollectible; private bool _loadInMemory; private bool _shadowCopyNativeLibraries; /// /// Creates an assembly load context using settings specified on the builder. /// /// A new ManagedLoadContext. public AssemblyLoadContext Build() { var resourceProbingPaths = new List(_resourceProbingPaths); foreach (var additionalPath in _additionalProbingPaths) { foreach (var subPath in _resourceProbingSubpaths) { resourceProbingPaths.Add(Path.Combine(additionalPath, subPath)); } } if (_mainAssemblyPath == null) { throw new InvalidOperationException($"Missing required property. You must call '{nameof(SetMainAssemblyPath)}' to configure the default assembly."); } return new ManagedLoadContext( _mainAssemblyPath, _managedLibraries, _nativeLibraries, _privateAssemblies, _defaultAssemblies, _additionalProbingPaths, resourceProbingPaths, _defaultLoadContext, _preferDefaultLoadContext, _lazyLoadReferences, _isCollectible, _loadInMemory, _shadowCopyNativeLibraries); } /// /// Set the file path to the main assembly for the context. This is used as the starting point for loading /// other assemblies. The directory that contains it is also known as the 'app local' directory. /// /// The file path. Must not be null or empty. Must be an absolute path. /// The builder. public AssemblyLoadContextBuilder SetMainAssemblyPath(string path) { if (string.IsNullOrEmpty(path)) { throw new ArgumentException("Argument must not be null or empty.", nameof(path)); } if (!Path.IsPathRooted(path)) { throw new ArgumentException("Argument must be a full path.", nameof(path)); } _mainAssemblyPath = path; return this; } /// /// Replaces the default used by the . /// Use this feature if the of the is not the Runtime's default load context. /// i.e. (AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly) != /// /// The context to set. /// The builder. public AssemblyLoadContextBuilder SetDefaultContext(AssemblyLoadContext context) { _defaultLoadContext = context ?? throw new ArgumentException($"Bad Argument: AssemblyLoadContext in {nameof(AssemblyLoadContextBuilder)}.{nameof(SetDefaultContext)} is null."); return this; } /// /// Instructs the load context to prefer a private version of this assembly, even if that version is /// different from the version used by the host application. /// Use this when you do not need to exchange types created from within the load context with other contexts /// or the default app context. /// /// This may mean the types loaded from /// this assembly will not match the types from an assembly with the same name, but different version, /// in the host application. /// /// /// For example, if the host application has a type named Foo from assembly Banana, Version=1.0.0.0 /// and the load context prefers a private version of Banan, Version=2.0.0.0, when comparing two objects, /// one created by the host (Foo1) and one created from within the load context (Foo2), they will not have the same /// type. Foo1.GetType() != Foo2.GetType() /// /// /// The name of the assembly. /// The builder. public AssemblyLoadContextBuilder PreferLoadContextAssembly(AssemblyName assemblyName) { if (assemblyName.Name != null) { _privateAssemblies.Add(assemblyName.Name); } return this; } /// /// Instructs the load context to first attempt to load assemblies by this name from the default app context, even /// if other assemblies in this load context express a dependency on a higher or lower version. /// Use this when you need to exchange types created from within the load context with other contexts /// or the default app context. /// /// The name of the assembly. /// The builder. public AssemblyLoadContextBuilder PreferDefaultLoadContextAssembly(AssemblyName assemblyName) { // Lazy loaded references have dependencies resolved as they are loaded inside the actual Load Context. if (_lazyLoadReferences) { if (assemblyName.Name != null && !_defaultAssemblies.Contains(assemblyName.Name)) { _defaultAssemblies.Add(assemblyName.Name); var assembly = _defaultLoadContext.LoadFromAssemblyName(assemblyName); foreach (var reference in assembly.GetReferencedAssemblies()) { if (reference.Name != null) { _defaultAssemblies.Add(reference.Name); } } } return this; } var names = new Queue(); names.Enqueue(assemblyName); while (names.TryDequeue(out var name)) { if (name.Name == null || _defaultAssemblies.Contains(name.Name)) { // base cases continue; } _defaultAssemblies.Add(name.Name); // Load and find all dependencies of default assemblies. // This sacrifices some performance for determinism in how transitive // dependencies will be shared between host and plugin. var assembly = _defaultLoadContext.LoadFromAssemblyName(name); foreach (var reference in assembly.GetReferencedAssemblies()) { names.Enqueue(reference); } } return this; } /// /// Instructs the load context to first search for binaries from the default app context, even /// if other assemblies in this load context express a dependency on a higher or lower version. /// Use this when you need to exchange types created from within the load context with other contexts /// or the default app context. /// /// This may mean the types loaded from within the context are force-downgraded to the version provided /// by the host. can be used to selectively identify binaries /// which should not be loaded from the default load context. /// /// /// When true, first attemp to load binaries from the default load context. /// The builder. public AssemblyLoadContextBuilder PreferDefaultLoadContext(bool preferDefaultLoadContext) { _preferDefaultLoadContext = preferDefaultLoadContext; return this; } /// /// Instructs the load context to lazy load dependencies of all shared assemblies. /// Reduces plugin load time at the expense of non-determinism in how transitive dependencies are loaded /// between the plugin and the host. /// /// Please be aware of the danger of using this option: /// /// https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873 /// /// /// True to lazy load, else false. /// The builder. public AssemblyLoadContextBuilder IsLazyLoaded(bool isLazyLoaded) { _lazyLoadReferences = isLazyLoaded; return this; } /// /// Add a managed library to the load context. /// /// The managed library. /// The builder. public AssemblyLoadContextBuilder AddManagedLibrary(ManagedLibrary library) { ValidateRelativePath(library.AdditionalProbingPath); if (library.Name.Name != null) { _managedLibraries.Add(library.Name.Name, library); } return this; } /// /// Add a native library to the load context. /// /// /// public AssemblyLoadContextBuilder AddNativeLibrary(NativeLibrary library) { ValidateRelativePath(library.AppLocalPath); ValidateRelativePath(library.AdditionalProbingPath); _nativeLibraries.Add(library.Name, library); return this; } /// /// Add a that should be used to search for native and managed libraries. /// /// The file path. Must be a full file path. /// The builder public AssemblyLoadContextBuilder AddProbingPath(string path) { if (string.IsNullOrEmpty(path)) { throw new ArgumentException("Value must not be null or empty.", nameof(path)); } if (!Path.IsPathRooted(path)) { throw new ArgumentException("Argument must be a full path.", nameof(path)); } _additionalProbingPaths.Add(path); return this; } /// /// Add a that should be use to search for resource assemblies (aka satellite assemblies). /// /// The file path. Must be a full file path. /// The builder public AssemblyLoadContextBuilder AddResourceProbingPath(string path) { if (string.IsNullOrEmpty(path)) { throw new ArgumentException("Value must not be null or empty.", nameof(path)); } if (!Path.IsPathRooted(path)) { throw new ArgumentException("Argument must be a full path.", nameof(path)); } _resourceProbingPaths.Add(path); return this; } /// /// Enable unloading the assembly load context. /// /// The builder public AssemblyLoadContextBuilder EnableUnloading() { _isCollectible = true; return this; } /// /// Read .dll files into memory to avoid locking the files. /// This is not as efficient, so is not enabled by default, but is required for scenarios /// like hot reloading. /// /// The builder public AssemblyLoadContextBuilder PreloadAssembliesIntoMemory() { _loadInMemory = true; // required to prevent dotnet from locking loaded files return this; } /// /// Shadow copy native libraries (unmanaged DLLs) to avoid locking of these files. /// This is not as efficient, so is not enabled by default, but is required for scenarios /// like hot reloading of plugins dependent on native libraries. /// /// The builder public AssemblyLoadContextBuilder ShadowCopyNativeLibraries() { _shadowCopyNativeLibraries = true; return this; } /// /// Add a that should be use to search for resource assemblies (aka satellite assemblies) /// relative to any paths specified as /// /// The file path. Must not be a full file path since it will be appended to additional probing path roots. /// The builder internal AssemblyLoadContextBuilder AddResourceProbingSubpath(string path) { if (string.IsNullOrEmpty(path)) { throw new ArgumentException("Value must not be null or empty.", nameof(path)); } if (Path.IsPathRooted(path)) { throw new ArgumentException("Argument must be not a full path.", nameof(path)); } _resourceProbingSubpaths.Add(path); return this; } private static void ValidateRelativePath(string probingPath) { if (string.IsNullOrEmpty(probingPath)) { throw new ArgumentException("Value must not be null or empty.", nameof(probingPath)); } if (Path.IsPathRooted(probingPath)) { throw new ArgumentException("Argument must be a relative path.", nameof(probingPath)); } } } }