diff --git a/resources/RegisterPnPAssemblyResolver.ps1 b/resources/RegisterPnPAssemblyResolver.ps1 index f07ed8b45..c5047d975 100644 --- a/resources/RegisterPnPAssemblyResolver.ps1 +++ b/resources/RegisterPnPAssemblyResolver.ps1 @@ -11,19 +11,24 @@ # We load the module assembly by explicit path (so no resolver is needed to find it) and invoke its public # registration entry point, which wires up the resolver on the default AssemblyLoadContext. Registration is # idempotent, so running this on every import is harmless. +# +# ScriptsToProcess runs in the caller's session state, so the body runs in a child scope to keep its variables +# out of the user's session. -$moduleRoot = $PSScriptRoot -$coreAssemblyPath = Join-Path -Path $moduleRoot -ChildPath 'Core/PnP.PowerShell.dll' +& { + $moduleRoot = $PSScriptRoot + $coreAssemblyPath = Join-Path -Path $moduleRoot -ChildPath 'Core/PnP.PowerShell.dll' -if (Test-Path -LiteralPath $coreAssemblyPath) { - $assembly = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath($coreAssemblyPath) - $initializerType = $assembly.GetType('PnP.PowerShell.Commands.Base.PnPPowerShellModuleInitializer') - if ($null -ne $initializerType) { - $registerMethod = $initializerType.GetMethod( - 'EnsureDependencyResolverRegistered', - [System.Reflection.BindingFlags]'Public, Static') - if ($null -ne $registerMethod) { - [void]$registerMethod.Invoke($null, $null) + if (Test-Path -LiteralPath $coreAssemblyPath) { + $assembly = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath($coreAssemblyPath) + $initializerType = $assembly.GetType('PnP.PowerShell.Commands.Base.PnPPowerShellModuleInitializer') + if ($null -ne $initializerType) { + $registerMethod = $initializerType.GetMethod( + 'EnsureDependencyResolverRegistered', + [System.Reflection.BindingFlags]'Public, Static') + if ($null -ne $registerMethod) { + [void]$registerMethod.Invoke($null, $null) + } } } } diff --git a/src/Commands/Base/PnPPowerShellModuleInitializer.cs b/src/Commands/Base/PnPPowerShellModuleInitializer.cs index d4ea67d24..2b5bbea7f 100644 --- a/src/Commands/Base/PnPPowerShellModuleInitializer.cs +++ b/src/Commands/Base/PnPPowerShellModuleInitializer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Management.Automation; @@ -16,9 +17,9 @@ namespace PnP.PowerShell.Commands.Base /// default so its cmdlet types remain discoverable. Every other /// assembly we ship lives in the sibling "Common" folder and is loaded into a dedicated, fully /// isolated . A single - /// handler on the default context routes any assembly we ship into that private context; from there the - /// private context resolves the entire transitive graph internally, so our Microsoft.Extensions.* (and - /// friends) can never bind against a version the host process already loaded. + /// handler on the default context routes the private dependencies this assembly references into that + /// private context; from there the private context resolves the entire transitive graph internally, so our + /// Microsoft.Extensions.* (and friends) can never bind against a version the host process already loaded. /// /// The handler MUST be registered before PowerShell reflects over this assembly to discover cmdlets, /// because the cmdlet base types statically reference PnP.Framework/PnP.Core and reflection therefore @@ -42,6 +43,17 @@ public sealed class PnPPowerShellModuleInitializer : IModuleAssemblyInitializer /// private static readonly string s_dependencyPath; + /// + /// Versions of the assemblies this assembly references directly, keyed by simple name. These references are the + /// only requests from the default context that are ours to answer: everything they depend on is resolved inside + /// the private context by . The resolver is process-wide and its event does + /// not say which assembly made the request, so a request is only answered when both its name and its version + /// match one of our references. This narrows the interception rather than isolating it: another module asking + /// for one of these assemblies (MSAL above all) at another version falls through to its own resolution, but one + /// asking for the very same version cannot be told apart from us and still receives our private copy. + /// + private static readonly Dictionary s_referencedAssemblyVersions; + /// /// Guards against registering the resolver more than once (module initializer + OnImport + re-import). /// @@ -56,8 +68,14 @@ static PnPPowerShellModuleInitializer() { // This assembly (PnP.PowerShell.dll) ships in "/Core"; the private dependency graph ships // in the sibling "/Common" folder. - string executingDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + Assembly executingAssembly = Assembly.GetExecutingAssembly(); + string executingDirectory = Path.GetDirectoryName(executingAssembly.Location); s_dependencyPath = Path.GetFullPath(Path.Combine(executingDirectory, "..", "Common")); + s_referencedAssemblyVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (AssemblyName reference in executingAssembly.GetReferencedAssemblies()) + { + s_referencedAssemblyVersions.TryAdd(reference.Name, reference.Version); + } // In-IDE (Visual Studio F5) debugging imports the raw build output, where every dependency sits in // the same folder as this assembly. PowerShell's own directory probing already resolves that whole @@ -96,14 +114,14 @@ public void OnImport() } /// - /// Default-context resolver. When the default context cannot satisfy an assembly, we check whether we - /// ship it. If so, we hand it to the private context or we return a copy that is already loaded when the private - /// context defers it to the default context. Otherwise we return null and let the runtime continue its - /// normal resolution (shared framework, PowerShell, host). + /// Default-context resolver. When the default context cannot satisfy an assembly this assembly references, + /// we check whether we ship it. If so, we hand it to the private context or we return a copy that is already + /// loaded when the private context defers it to the default context. Otherwise we return null and let + /// the runtime continue its normal resolution (shared framework, PowerShell, host). /// private static Assembly ResolveDependency(AssemblyLoadContext defaultContext, AssemblyName assemblyName) { - if (string.IsNullOrEmpty(assemblyName?.Name)) + if (!IsReferencedByThisAssembly(assemblyName)) { return null; } @@ -129,5 +147,16 @@ private static Assembly ResolveDependency(AssemblyLoadContext defaultContext, As // copies, isolated from whatever the host already loaded into the default context. return s_dependencyContext.LoadFromAssemblyName(assemblyName); } + + /// + /// True when the requested assembly is one this assembly references, at the version it references. A request + /// for the same name at another version is not ours and is left to the requester's own resolution. + /// + private static bool IsReferencedByThisAssembly(AssemblyName assemblyName) + { + return !string.IsNullOrEmpty(assemblyName?.Name) + && s_referencedAssemblyVersions.TryGetValue(assemblyName.Name, out Version referencedVersion) + && assemblyName.Version == referencedVersion; + } } }