diff --git a/build.psm1 b/build.psm1 index 3e33ed87de..2dacee9744 100644 --- a/build.psm1 +++ b/build.psm1 @@ -1046,9 +1046,10 @@ function Publish-PSTestTools { Find-Dotnet $tools = @( - @{Path="${PSScriptRoot}/test/tools/TestExe";Output="testexe"} - @{Path="${PSScriptRoot}/test/tools/WebListener";Output="WebListener"} - @{Path="${PSScriptRoot}/test/tools/TestService";Output="TestService"} + @{ Path="${PSScriptRoot}/test/tools/TestAlc"; Output="library" } + @{ Path="${PSScriptRoot}/test/tools/TestExe"; Output="exe" } + @{ Path="${PSScriptRoot}/test/tools/WebListener"; Output="exe" } + @{ Path="${PSScriptRoot}/test/tools/TestService"; Output="exe" } ) $Options = Get-PSOptions -DefaultToNew @@ -1069,12 +1070,19 @@ function Publish-PSTestTools { Remove-Item -Path $objPath -Recurse -Force } - if (-not $runtime) { - dotnet publish --output bin --configuration $Options.Configuration --framework $Options.Framework --runtime $Options.Runtime - } else { - dotnet publish --output bin --configuration $Options.Configuration --framework $Options.Framework --runtime $runtime + if ($tool.Output -eq 'library') { + ## Handle building and publishing assemblies. + dotnet publish --configuration $Options.Configuration --framework $Options.Framework + continue } + ## Handle building and publishing executables. + if (-not $runtime) { + $runtime = $Options.Runtime + } + + dotnet publish --output bin --configuration $Options.Configuration --framework $Options.Framework --runtime $runtime --self-contained + if ( -not $env:PATH.Contains($toolPath) ) { $env:PATH = $toolPath+$TestModulePathSeparator+$($env:PATH) } diff --git a/src/System.Management.Automation/engine/ExecutionContext.cs b/src/System.Management.Automation/engine/ExecutionContext.cs index 18ba461a68..d73a1e97eb 100644 --- a/src/System.Management.Automation/engine/ExecutionContext.cs +++ b/src/System.Management.Automation/engine/ExecutionContext.cs @@ -1269,55 +1269,150 @@ namespace System.Management.Automation internal PSTransactionManager transactionManager; - internal Assembly AddAssembly(string name, string filename, out Exception error) + /// + /// This method is used for assembly loading requests stemmed from 'InitialSessionState' binding and module loading. + /// + /// Source of the assembly loading request, should be a module name when specified. + /// Name of the assembly to be loaded. + /// Path of the assembly to be loaded. + /// Exception that is caught when the loading fails. + internal Assembly AddAssembly(string source, string assemblyName, string filePath, out Exception error) { - Assembly loadedAssembly = LoadAssembly(name, filename, out error); - - if (loadedAssembly == null) - return null; - - if (AssemblyCache.ContainsKey(loadedAssembly.FullName)) + // Search the cache by the path, and return the assembly if we find it. + // It's common to have two loading requests for the same assembly when loading a module -- the first time for + // resolving a binary module path, and the second time for actually processing that module. + // + // That's not a problem when all the module assemblies are loaded into the default ALC. But in a scenario where + // a module tries to hide its nested/root binary modules in a custom ALC, that will become a problem. This is + // because: + // in that scenario, the module will usually setup a handler to load the specific assemblies to the custom ALC, + // and that will be how the first loading request gets served. However, after the module path is resolved with + // the first loading, the path will be used for the second loading upon real module processing. Since we prefer + // loading-by-path over loading-by-name in the 'LoadAssembly' call, we will end up loading the same assembly in + // the default ALC (because we use 'Assembly.LoadFrom' which always loads an assembly to the default ALC) if we + // do not search in the cache first. That will break the scenario, because the module means to isolate all its + // dependencies from the default ALC, and it failed to do so. + // + // Therefore, we need to search the cache first. The reason we use path as the key is to make sure the request + // is for exactly the same assembly. The same assembly file should not be loaded into different ALC's by module + // loading within the same PowerShell session (Runspace). + // + // An example module targeting the abovementioned scenario will likely have the following file structure: + // IsolatedModule + // │ IsolatedModule.psd1 (has 'NestedModules = @('Test.Isolated.Init.dll', 'Test.Isolated.Nested.dll')') + // │ Test.Isolated.Init.dll (contains the custom ALC and code to setup 'Resolving' handler) + // └───Dependencies (folder under module base) + // Newtonsoft.Json.dll (version 10.0.0.0 dependency) + // Test.Isolated.Nested.dll (nested binary module referencing the particular dependency) + // + // In this example, the following events will happen in sequence: + // 1. PowerShell is able to find 'Test.Isolated.Init.dll' under module base folder, so it will be loaded into + // the default ALC as expected and setup the 'Resolving' handler via the 'OnImport' call. + // 2. PowerShell cannot find 'Test.Isolated.Nested.dll' under the module base folder, so it will call the method + // 'FixFileName(.., bool canLoadAssembly)' to resolve the path of this binary module. + // This particular overload will attempt to load the assembly by name, which will be served by the 'Resolving' + // handler that was setup in the step 1. So, the assembly will be loaded into the custom ALC and insert to the + // assembly cache. + // 3. Path of the nested module 'Test.Isolated.Init.dll' now has been resolved by the step 2 (assembly.Location). + // Now it's time to actually load this binary module for processing in the method 'LoadBinaryModule', which + // will make a call to this method with the resolved assembly file path. + // At this poin, we will have to query the cache first, instead of calling 'LoadAssembly' directly, to make sure + // that the assembly instance loaded in the custom ALC in step 2 gets returned back. Otherwise, the same assembly + // file will be loaded in the default ALC because 'Assembly.LoadFrom' is used in 'LoadAssembly' and that API will + // always load an assembly file to the default ALC, and that will break this scenario. + if (TryGetFromAssemblyCache(source, filePath, out Assembly loadedAssembly)) { - // we should ignore this assembly. - return loadedAssembly; - } - // We will cache the assembly by both full name and - // file name - AssemblyCache.Add(loadedAssembly.FullName, loadedAssembly); - - if (AssemblyCache.ContainsKey(loadedAssembly.GetName().Name)) - { - // we should ignore this assembly. + error = null; return loadedAssembly; } - AssemblyCache.Add(loadedAssembly.GetName().Name, loadedAssembly); + // Attempt to load the requested assembly, first by path then by name. + loadedAssembly = LoadAssembly(assemblyName, filePath, out error); + if (loadedAssembly is not null) + { + AddToAssemblyCache(source, loadedAssembly); + } + return loadedAssembly; } - internal void RemoveAssembly(string name) + /// + /// Add a loaded assembly to the 'AssemblyCache'. + /// The is used as a prefix for the key to make it easy to remove all associated + /// assemblies from the cache when a module gets unloaded. + /// + /// The source where the assembly comes from, should be a module name when specified. + /// The assembly we try to cache. + internal void AddToAssemblyCache(string source, Assembly assembly) { - Assembly loadedAssembly; - if (AssemblyCache.TryGetValue(name, out loadedAssembly) && loadedAssembly != null) - { - AssemblyCache.Remove(name); + // Try caching the assembly by its location if possible. + // When it's a dynamic assembly, we use it's full name. This could happen with 'Import-Module -Assembly'. + string key = string.IsNullOrEmpty(assembly.Location) ? assembly.FullName : assembly.Location; - AssemblyCache.Remove(loadedAssembly.GetName().Name); + // When the assembly is from a module loading, we prefix the key with the source, + // so we can remove it from the cache when the module gets unloaded. + if (!string.IsNullOrEmpty(source)) + { + // Both 'source' and 'key' are of the string type, so no need to specify 'InvariantCulture'. + key = $"{source}@{key}"; + } + + AssemblyCache.TryAdd(key, assembly); + } + + /// + /// Remove all cache entries that are associated with the specified source. + /// + internal void RemoveFromAssemblyCache(string source) + { + if (string.IsNullOrEmpty(source)) + { + return; + } + + var keysToRemove = new List(); + string prefix = $"{source}@"; + + foreach (string key in AssemblyCache.Keys) + { + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + keysToRemove.Add(key); + } + } + + foreach (string key in keysToRemove) + { + AssemblyCache.Remove(key); } } - [SuppressMessage("Microsoft.Reliability", "CA2001:AvoidCallingProblematicMethods", MessageId = "System.Reflection.Assembly.LoadWithPartialName")] - [SuppressMessage("Microsoft.Reliability", "CA2001:AvoidCallingProblematicMethods", MessageId = "System.Reflection.Assembly.LoadFrom")] - internal static Assembly LoadAssembly(string name, string filename, out Exception error) + /// + /// Try to get an assembly from the cache. + /// + private bool TryGetFromAssemblyCache(string source, string filePath, out Assembly assembly) + { + if (string.IsNullOrEmpty(filePath)) + { + assembly = null; + return false; + } + + // Both 'source' and 'filePath' are of the string type, so no need to specify 'InvariantCulture'. + string key = string.IsNullOrEmpty(source) ? filePath : $"{source}@{filePath}"; + return AssemblyCache.TryGetValue(key, out assembly); + } + + private static Assembly LoadAssembly(string name, string filePath, out Exception error) { // First we try to load the assembly based on the filename Assembly loadedAssembly = null; error = null; - if (!string.IsNullOrEmpty(filename)) + if (!string.IsNullOrEmpty(filePath)) { try { - loadedAssembly = Assembly.LoadFrom(filename); + loadedAssembly = Assembly.LoadFrom(filePath); return loadedAssembly; } catch (FileNotFoundException fileNotFound) @@ -1562,7 +1657,7 @@ namespace System.Management.Automation EngineHostInterface = hostInterface as InternalHost ?? new InternalHost(hostInterface, this); // Hook up the assembly cache - AssemblyCache = new Dictionary(); + AssemblyCache = new Dictionary(StringComparer.OrdinalIgnoreCase); // Initialize the fixed toplevel session state and the current session state TopLevelSessionState = EngineSessionState = new SessionStateInternal(this); diff --git a/src/System.Management.Automation/engine/InitialSessionState.cs b/src/System.Management.Automation/engine/InitialSessionState.cs index bc40e903fa..c0d4c2cb7c 100644 --- a/src/System.Management.Automation/engine/InitialSessionState.cs +++ b/src/System.Management.Automation/engine/InitialSessionState.cs @@ -410,7 +410,7 @@ namespace System.Management.Automation.Runspaces /// The cloned object. public override InitialSessionStateEntry Clone() { - SessionStateAssemblyEntry entry = new SessionStateAssemblyEntry(Name, FileName); + var entry = new SessionStateAssemblyEntry(Name, FileName); entry.SetPSSnapIn(this.PSSnapIn); entry.SetModule(this.Module); return entry; @@ -2405,9 +2405,14 @@ namespace System.Management.Automation.Runspaces // Load the assemblies and initialize the assembly cache... foreach (SessionStateAssemblyEntry ssae in Assemblies) { - if (etwEnabled) RunspaceEventSource.Log.LoadAssemblyStart(ssae.Name, ssae.FileName); - Exception error = null; - Assembly asm = context.AddAssembly(ssae.Name, ssae.FileName, out error); + if (etwEnabled) + { + RunspaceEventSource.Log.LoadAssemblyStart(ssae.Name, ssae.FileName); + } + + // Specify the source only if this is for module loading. + // The source is used for porper cleaning of the assembly cache when a module is unloaded. + Assembly asm = context.AddAssembly(ssae.Module?.Name, ssae.Name, ssae.FileName, out Exception error); if (asm == null || error != null) { @@ -3374,105 +3379,6 @@ namespace System.Management.Automation.Runspaces context.EngineSessionState.SetVariableAtScope(qv, "global", true, CommandOrigin.Internal); } - /// - /// Remove anything that would have been bound by this ISS instance. - /// At this point, it removes assemblies and cmdlet entries at the top level. - /// It also removes types and formats. - /// The other entry types - functions, variables, aliases - /// are not removed by this function. - /// - /// - internal void Unbind(ExecutionContext context) - { - lock (_syncObject) - { - SessionStateInternal ss = context.EngineSessionState; - - // Remove the assemblies from the assembly cache... - foreach (SessionStateAssemblyEntry ssae in Assemblies) - { - context.RemoveAssembly(ssae.Name); - } - - // Remove all of the commands from the top-level session state. - foreach (SessionStateCommandEntry cmd in Commands) - { - SessionStateCmdletEntry ssce = cmd as SessionStateCmdletEntry; - if (ssce != null) - { - List matches; - if (context.TopLevelSessionState.GetCmdletTable().TryGetValue(ssce.Name, out matches)) - { - // Remove the name from the list... - for (int i = matches.Count - 1; i >= 0; i--) - { - if (matches[i].ModuleName.Equals(cmd.PSSnapIn.Name)) - { - string name = matches[i].Name; - matches.RemoveAt(i); - context.TopLevelSessionState.RemoveCmdlet(name, i, /*force*/ true); - } - } - // And remove the entry if the list is now empty... - if (matches.Count == 0) - { - context.TopLevelSessionState.RemoveCmdletEntry(ssce.Name, true); - } - } - - continue; - } - } - - // Remove all of the providers from the top-level provider table. - if (_providers != null && _providers.Count > 0) - { - Dictionary> providerTable = context.TopLevelSessionState.Providers; - - foreach (SessionStateProviderEntry sspe in _providers) - { - List pl; - if (providerTable.TryGetValue(sspe.Name, out pl)) - { - Diagnostics.Assert(pl != null, "There should never be a null list of entries in the provider table"); - // For each provider with the same name... - for (int i = pl.Count - 1; i >= 0; i--) - { - ProviderInfo pi = pl[i]; - - // If it was implemented by this entry, remove it - if (pi.ImplementingType == sspe.ImplementingType) - { - RemoveAllDrivesForProvider(pi, context.TopLevelSessionState); - pl.RemoveAt(i); - } - } - - // If there are no providers left with this name, remove the key. - if (pl.Count == 0) - { - providerTable.Remove(sspe.Name); - } - } - } - } - - List formatFilesToRemove = new List(); - if (this.Formats != null) - { - formatFilesToRemove.AddRange(this.Formats.Select(static f => f.FileName)); - } - - List typeFilesToRemove = new List(); - if (this.Types != null) - { - typeFilesToRemove.AddRange(this.Types.Select(static t => t.FileName)); - } - - RemoveTypesAndFormats(context, formatFilesToRemove, typeFilesToRemove); - } - } - internal static void RemoveTypesAndFormats(ExecutionContext context, IList formatFilesToRemove, IList typeFilesToRemove) { // The formats and types tables are implemented in such a way that @@ -3904,11 +3810,9 @@ namespace System.Management.Automation.Runspaces this.Formats.Add(formatEntry); } - SessionStateAssemblyEntry assemblyEntry = new SessionStateAssemblyEntry(psSnapInInfo.AssemblyName, psSnapInInfo.AbsoluteModulePath); - + var assemblyEntry = new SessionStateAssemblyEntry(psSnapInInfo.AssemblyName, psSnapInInfo.AbsoluteModulePath); assemblyEntry.SetPSSnapIn(psSnapInInfo); - - this.Assemblies.Add(assemblyEntry); + Assemblies.Add(assemblyEntry); // entry from types.ps1xml references a type (Microsoft.PowerShell.Commands.SecurityDescriptorCommandsBase) in this assembly if (psSnapInInfo.Name.Equals(CoreSnapin, StringComparison.OrdinalIgnoreCase)) @@ -4005,20 +3909,16 @@ namespace System.Management.Automation.Runspaces throw e; } - Dictionary cmdlets = null; - Dictionary> aliases = null; - Dictionary providers = null; - string assemblyPath = assembly.Location; - PSSnapInHelpers.AnalyzePSSnapInAssembly(assembly, assemblyPath, psSnapInInfo: null, module, out cmdlets, out aliases, out providers, helpFile: out _); - - // If this is an in-memory assembly, don't added it to the list of AssemblyEntries - // since it can't be loaded by path or name - if (!string.IsNullOrEmpty(assembly.Location)) - { - SessionStateAssemblyEntry assemblyEntry = new SessionStateAssemblyEntry(assembly.FullName, assemblyPath); - this.Assemblies.Add(assemblyEntry); - } + PSSnapInHelpers.AnalyzePSSnapInAssembly( + assembly, + assemblyPath, + psSnapInInfo: null, + module, + out Dictionary cmdlets, + out Dictionary> aliases, + out Dictionary providers, + helpFile: out _); if (cmdlets != null) { diff --git a/src/System.Management.Automation/engine/Modules/ImportModuleCommand.cs b/src/System.Management.Automation/engine/Modules/ImportModuleCommand.cs index 45011edb90..8f503cbe46 100644 --- a/src/System.Management.Automation/engine/Modules/ImportModuleCommand.cs +++ b/src/System.Management.Automation/engine/Modules/ImportModuleCommand.cs @@ -548,32 +548,15 @@ namespace Microsoft.PowerShell.Commands private void ImportModule_ViaAssembly(ImportModuleOptions importModuleOptions, Assembly suppliedAssembly) { bool moduleLoaded = false; + string moduleName = "dynamic_code_module_" + suppliedAssembly.FullName; + // Loop through Module Cache to ensure that the module is not already imported. - if (suppliedAssembly != null && Context.Modules.ModuleTable != null) + foreach (KeyValuePair pair in Context.Modules.ModuleTable) { - foreach (KeyValuePair pair in Context.Modules.ModuleTable) + if (pair.Value.Path == string.Empty) { - // if the module in the moduleTable is an assembly module without path, the moduleName is the key. - string moduleName = "dynamic_code_module_" + suppliedAssembly; - if (pair.Value.Path == string.Empty) - { - if (pair.Key.Equals(moduleName, StringComparison.OrdinalIgnoreCase)) - { - moduleLoaded = true; - if (BasePassThru) - { - WriteObject(pair.Value); - } - - break; - } - else - { - continue; - } - } - - if (pair.Value.Path.Equals(suppliedAssembly.Location, StringComparison.OrdinalIgnoreCase)) + // If the module in the moduleTable is an assembly module without path, the moduleName is the key. + if (pair.Key.Equals(moduleName, StringComparison.OrdinalIgnoreCase)) { moduleLoaded = true; if (BasePassThru) @@ -583,12 +566,26 @@ namespace Microsoft.PowerShell.Commands break; } + + continue; + } + + if (pair.Value.Path.Equals(suppliedAssembly.Location, StringComparison.OrdinalIgnoreCase)) + { + moduleLoaded = true; + if (BasePassThru) + { + WriteObject(pair.Value); + } + + break; } } if (!moduleLoaded) { PSModuleInfo module = LoadBinaryModule( + parentModule: null, moduleName: null, fileName: null, suppliedAssembly, @@ -596,15 +593,13 @@ namespace Microsoft.PowerShell.Commands ss: null, importModuleOptions, ManifestProcessingFlags.LoadElements | ManifestProcessingFlags.WriteErrors | ManifestProcessingFlags.NullOnFirstError, - this.BasePrefix, - loadTypes: false, - loadFormats: false, + BasePrefix, out bool found); - if (found && module != null) + if (found && module is not null) { // Add it to all module tables ... - AddModuleToModuleTables(this.Context, this.TargetSessionState.Internal, module); + AddModuleToModuleTables(Context, TargetSessionState.Internal, module); if (BasePassThru) { WriteObject(module); @@ -1380,14 +1375,25 @@ namespace Microsoft.PowerShell.Commands private bool IsPs1xmlFileHelper_IsPresentInEntries(RemoteDiscoveryHelper.CimModuleFile cimModuleFile, IEnumerable manifestEntries) { - if (manifestEntries.Any(s => s.EndsWith(cimModuleFile.FileName, StringComparison.OrdinalIgnoreCase))) + const string ps1xmlExt = ".ps1xml"; + string fileName = cimModuleFile.FileName; + + foreach (string entry in manifestEntries) { - return true; + if (entry.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } } - if (manifestEntries.Any(s => FixupFileName(string.Empty, s, ".ps1xml", isImportingModule: true).EndsWith(cimModuleFile.FileName, StringComparison.OrdinalIgnoreCase))) + foreach (string entry in manifestEntries) { - return true; + string tempName = entry.EndsWith(ps1xmlExt, StringComparison.OrdinalIgnoreCase) ? entry : entry + ps1xmlExt; + string resolvedPath = ResolveRootedFilePath(tempName, Context); + if (resolvedPath is not null && resolvedPath.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } } return false; @@ -1894,13 +1900,10 @@ namespace Microsoft.PowerShell.Commands else if (this.ParameterSetName.Equals(ParameterSet_Assembly, StringComparison.OrdinalIgnoreCase)) { // Now load all of the supplied assemblies... - if (Assembly != null) + foreach (Assembly suppliedAssembly in Assembly) { - foreach (Assembly suppliedAssembly in Assembly) - { - ApplicationInsightsTelemetry.SendTelemetryMetric(TelemetryType.ModuleLoad, suppliedAssembly.GetName().Name); - ImportModule_ViaAssembly(importModuleOptions, suppliedAssembly); - } + ApplicationInsightsTelemetry.SendTelemetryMetric(TelemetryType.ModuleLoad, suppliedAssembly.GetName().Name); + ImportModule_ViaAssembly(importModuleOptions, suppliedAssembly); } } else if (this.ParameterSetName.Equals(ParameterSet_Name, StringComparison.OrdinalIgnoreCase)) diff --git a/src/System.Management.Automation/engine/Modules/ModuleCmdletBase.cs b/src/System.Management.Automation/engine/Modules/ModuleCmdletBase.cs index 53dc8d058c..1c94d557a8 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleCmdletBase.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleCmdletBase.cs @@ -682,9 +682,19 @@ namespace Microsoft.PowerShell.Commands return result; } - private PSModuleInfo LoadModuleNamedInManifest(PSModuleInfo parentModule, ModuleSpecification moduleSpecification, string moduleBase, bool searchModulePath, - string prefix, SessionState ss, ImportModuleOptions options, ManifestProcessingFlags manifestProcessingFlags, bool loadTypes, - bool loadFormats, object privateData, out bool found, string shortModuleName, PSLanguageMode? manifestLanguageMode) + private PSModuleInfo LoadModuleNamedInManifest( + PSModuleInfo parentModule, + ModuleSpecification moduleSpecification, + string moduleBase, + bool searchModulePath, + string prefix, + SessionState ss, + ImportModuleOptions options, + ManifestProcessingFlags manifestProcessingFlags, + object privateData, + out bool found, + string shortModuleName, + PSLanguageMode? manifestLanguageMode) { PSModuleInfo module = null; PSModuleInfo tempModuleInfoFromVerification = null; @@ -698,11 +708,16 @@ namespace Microsoft.PowerShell.Commands var importingModule = manifestProcessingFlags.HasFlag(ManifestProcessingFlags.LoadElements); string extension = Path.GetExtension(moduleSpecification.Name); + // First check for fully-qualified paths - either absolute or relative string rootedPath = ResolveRootedFilePath(moduleSpecification.Name, this.Context); if (string.IsNullOrEmpty(rootedPath)) { - rootedPath = FixupFileName(moduleBase, moduleSpecification.Name, extension, importingModule); + // Use the name of the parent module if it's specified, otherwise, use the current module name. + // - If the current module is a nested module, then the parent module will be specifeid. + // - If the current module is a root module, then the parent module will not be specified. + string moduleName = parentModule?.Name ?? ModuleIntrinsics.GetModuleName(moduleSpecification.Name); + rootedPath = FixFileName(moduleName, moduleBase, moduleSpecification.Name, extension: null, canLoadAssembly: importingModule); } else { @@ -925,8 +940,6 @@ namespace Microsoft.PowerShell.Commands options, manifestProcessingFlags, prefix, - loadTypes, - loadFormats, out found, shortModuleName, disableFormatUpdates: false); @@ -1524,6 +1537,7 @@ namespace Microsoft.PowerShell.Commands Dbg.Assert(moduleManifestPath != null, "moduleManifestPath for module (.psd1) can't be null"); string moduleBase = Path.GetDirectoryName(moduleManifestPath); + string moduleName = ModuleIntrinsics.GetModuleName(moduleManifestPath); if ((manifestProcessingFlags & (ManifestProcessingFlags.LoadElements | ManifestProcessingFlags.WriteErrors | @@ -1624,24 +1638,32 @@ namespace Microsoft.PowerShell.Commands invalidOperation.SetErrorId("Modules_WildCardNotAllowedInModuleToProcessAndInNestedModules"); throw invalidOperation; } + // See if this module is already loaded. Since the manifest entry may not // have an extension and the module table is indexed by full names, we // may have search through all the extensions. PSModuleInfo loadedModule = null; - string rootedPath = this.FixupFileName(moduleBase, actualRootModule, extension: null, importingModule); - string mtpExtension = Path.GetExtension(rootedPath); - if (!string.IsNullOrEmpty(mtpExtension) && ModuleIntrinsics.IsPowerShellModuleExtension(mtpExtension)) + string rootedPath = null; + + // For a root module, we use its own module name instead of the manifest module name when calling 'FixFileName'. + // This is because when actually loading the root module later, it won't have access to the parent manifest module, + // and we will use its own name to query for already loaded assemblies from 'Context.AssemblyCache'. + string rootModuleName = ModuleIntrinsics.GetModuleName(actualRootModule); + string extension = Path.GetExtension(actualRootModule); + if (!string.IsNullOrEmpty(extension) && ModuleIntrinsics.IsPowerShellModuleExtension(extension)) { + rootedPath = FixFileName(rootModuleName, moduleBase, actualRootModule, extension: null, canLoadAssembly: importingModule); TryGetFromModuleTable(rootedPath, out loadedModule); } else { foreach (string extensionToTry in ModuleIntrinsics.PSModuleExtensions) { - rootedPath = this.FixupFileName(moduleBase, actualRootModule, extensionToTry, importingModule); - TryGetFromModuleTable(rootedPath, out loadedModule); - if (loadedModule != null) + rootedPath = FixFileName(rootModuleName, moduleBase, actualRootModule, extensionToTry, canLoadAssembly: importingModule); + if (TryGetFromModuleTable(rootedPath, out loadedModule)) + { break; + } } } @@ -2049,7 +2071,6 @@ namespace Microsoft.PowerShell.Commands { bool nameMissingOrEmpty = false; var invalidNames = new List(); - string moduleName = ModuleIntrinsics.GetModuleName(moduleManifestPath); expFeatureList = new List(features.Length); foreach (Hashtable feature in features) @@ -2162,61 +2183,72 @@ namespace Microsoft.PowerShell.Commands // Indicates the ISS.Bind() should be called... bool doBind = false; - // Set up to load any required assemblies that have been specified... - List tmpAssemblyList; - List assemblyList = new List(); - List fixedUpAssemblyPathList = new List(); - - if ( - !GetListOfStringsFromData(data, moduleManifestPath, "RequiredAssemblies", manifestProcessingFlags, - out tmpAssemblyList)) + if (!GetListOfStringsFromData( + data, + moduleManifestPath, + "RequiredAssemblies", + manifestProcessingFlags, + out List assemblyList)) { containedErrors = true; - if (bailOnFirstError) return null; - } - else - { - if (tmpAssemblyList != null && tmpAssemblyList.Count > 0) + if (bailOnFirstError) { - foreach (string assembly in tmpAssemblyList) - { - assemblyList.Add(assembly); - } + return null; } - - if ((assemblyList != null) && importingModule) + } + else if (assemblyList != null && importingModule) + { + foreach (string assembly in assemblyList) { - foreach (string assembly in assemblyList) + if (WildcardPattern.ContainsWildcardCharacters(assembly)) { - if (WildcardPattern.ContainsWildcardCharacters(assembly)) + PSInvalidOperationException invalidOperation = PSTraceSource.NewInvalidOperationException( + Modules.WildCardNotAllowedInRequiredAssemblies, + moduleManifestPath); + invalidOperation.SetErrorId("Modules_WildCardNotAllowedInRequiredAssemblies"); + throw invalidOperation; + } + else + { + string fileName = null; + string ext = Path.GetExtension(assembly); + + // Note that we don't need to load the required assemblies eagerly because they will be loaded before + // processing type and format data. So, when calling 'FixupFileName', we only attempt to resolve the + // path, and avoid triggering the loading of the assembly. + if (ModuleIntrinsics.ProcessableAssemblyExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase)) { - PSInvalidOperationException invalidOperation = PSTraceSource.NewInvalidOperationException( - Modules.WildCardNotAllowedInRequiredAssemblies, - moduleManifestPath); - invalidOperation.SetErrorId("Modules_WildCardNotAllowedInRequiredAssemblies"); - throw invalidOperation; + fileName = FixFileNameWithoutLoadingAssembly(moduleBase, assembly, extension: null); } else { - string fileName = FixupFileName(moduleBase, assembly, StringLiterals.PowerShellNgenAssemblyExtension, importingModule, out bool pathIsResolved); - if (!pathIsResolved) + bool isPathResolved = false; + foreach (string extToTry in ModuleIntrinsics.ProcessableAssemblyExtensions) { - fileName = FixupFileName(moduleBase, assembly, StringLiterals.PowerShellILAssemblyExtension, importingModule); + fileName = FixFileNameWithoutLoadingAssembly(moduleBase, assembly, extToTry, out isPathResolved); + if (isPathResolved) + { + break; + } } - string loadMessage = StringUtil.Format(Modules.LoadingFile, "Assembly", fileName); - WriteVerbose(loadMessage); - iss.Assemblies.Add(new SessionStateAssemblyEntry(assembly, fileName)); - fixedUpAssemblyPathList.Add(fileName); - - fileName = FixupFileName(moduleBase, assembly, StringLiterals.PowerShellILExecutableExtension, importingModule); - loadMessage = StringUtil.Format(Modules.LoadingFile, "Executable", fileName); - WriteVerbose(loadMessage); - iss.Assemblies.Add(new SessionStateAssemblyEntry(assembly, fileName)); - fixedUpAssemblyPathList.Add(fileName); - - doBind = true; + if (!isPathResolved) + { + // We didn't resolve the assembly path, so remove the '.exe' extension that was added in the + // last iteration of the above loop. + int index = fileName.LastIndexOf('.'); + fileName = fileName.Substring(0, index); + } } + + WriteVerbose(StringUtil.Format(Modules.LoadingFile, "Assembly", fileName)); + + // Set a fake PSModuleInfo object to indicate the module it comes from. + var assemblyEntry = new SessionStateAssemblyEntry(assembly, fileName); + assemblyEntry.SetModule(new PSModuleInfo(moduleName, path: null, context: null, sessionState: null)); + + iss.Assemblies.Add(assemblyEntry); + doBind = true; } } } @@ -2253,8 +2285,7 @@ namespace Microsoft.PowerShell.Commands continue; } - string resolvedEntryFileName = ResolveRootedFilePath(entry.FileName, Context) ?? - entry.FileName; + string resolvedEntryFileName = ResolveRootedFilePath(entry.FileName, Context) ?? entry.FileName; if (resolvedEntryFileName.Equals(resolvedFileName, StringComparison.OrdinalIgnoreCase)) { isAlreadyLoaded = true; @@ -2377,7 +2408,7 @@ namespace Microsoft.PowerShell.Commands key: "FileList", manifestProcessingFlags, moduleBase, - extension: string.Empty, + extension: null, // Don't check file existence - don't want to change current behavior without feature team discussion. verifyFilesExist: false, out List fileList)) @@ -2517,10 +2548,18 @@ namespace Microsoft.PowerShell.Commands // If there is a session state, set up to import/export commands and variables if (ss != null) { - ss.Internal.SetVariable(SpecialVariables.PSScriptRootVarPath, Path.GetDirectoryName(moduleManifestPath), - true, CommandOrigin.Internal); - ss.Internal.SetVariable(SpecialVariables.PSCommandPathVarPath, moduleManifestPath, true, + ss.Internal.SetVariable( + SpecialVariables.PSScriptRootVarPath, + moduleBase, + asValue: true, CommandOrigin.Internal); + + ss.Internal.SetVariable( + SpecialVariables.PSCommandPathVarPath, + moduleManifestPath, + asValue: true, + CommandOrigin.Internal); + ss.Internal.Module = manifestInfo; // without ModuleToProcess a manifest will export everything by default @@ -2974,8 +3013,6 @@ namespace Microsoft.PowerShell.Commands ss: null, options: nestedModuleOptions, manifestProcessingFlags: manifestProcessingFlags, - loadTypes: true, - loadFormats: true, privateData: privateData, found: out found, shortModuleName: null, @@ -3077,8 +3114,6 @@ namespace Microsoft.PowerShell.Commands ss: ss, options: options, manifestProcessingFlags: manifestProcessingFlags, - loadTypes: (exportedTypeFiles == null || exportedTypeFiles.Count == 0), // If types files already loaded, don't load snapin files - loadFormats: (exportedFormatFiles == null || exportedFormatFiles.Count == 0), // if format files already loaded, don't load snapin files privateData: privateData, found: out found, shortModuleName: null, @@ -4403,8 +4438,6 @@ namespace Microsoft.PowerShell.Commands out List list) { list = null; - - bool importingModule = manifestProcessingFlags.HasFlag(ManifestProcessingFlags.LoadElements); if (!GetListOfStringsFromData(data, moduleManifestPath, key, manifestProcessingFlags, out List listOfStrings)) { return false; @@ -4426,7 +4459,7 @@ namespace Microsoft.PowerShell.Commands { try { - string fixedFileName = FixupFileName(moduleBase, s, extension, importingModule, skipLoading: true); + string fixedFileName = FixFileNameWithoutLoadingAssembly(moduleBase, s, extension); var dir = Path.GetDirectoryName(fixedFileName); if (string.Equals(psHome, dir, StringComparison.OrdinalIgnoreCase) || @@ -4581,12 +4614,22 @@ namespace Microsoft.PowerShell.Commands } } + private string FixFileNameWithoutLoadingAssembly(string moduleBase, string fileName, string extension) + { + return FixFileName(moduleName: null, moduleBase, fileName, extension, canLoadAssembly: false, pathIsResolved: out _); + } + + private string FixFileNameWithoutLoadingAssembly(string moduleBase, string fileName, string extension, out bool pathIsResolved) + { + return FixFileName(moduleName: null, moduleBase, fileName, extension, canLoadAssembly: false, out pathIsResolved); + } + /// /// A utility routine to fix up a file name so it's rooted and has an extension. /// - internal string FixupFileName(string moduleBase, string name, string extension, bool isImportingModule, bool skipLoading = false) + private string FixFileName(string moduleName, string moduleBase, string fileName, string extension, bool canLoadAssembly) { - return FixupFileName(moduleBase, name, extension, isImportingModule, pathIsResolved: out _, skipLoading); + return FixFileName(moduleName, moduleBase, fileName, extension, canLoadAssembly, pathIsResolved: out _); } /// @@ -4596,24 +4639,33 @@ namespace Microsoft.PowerShell.Commands /// When fixing up an assembly file, this method loads the resovled assembly if it's in the process of actually loading a module. /// Read the comments in the method for the detailed information. /// + /// Name of the module that we are processing, used for caching purpose when we need to load an assembly. /// The base path to use if the file is not rooted. - /// The file name to resolve. - /// The extension to use in case the given name has no extension. - /// Indicate if we are loading a module. + /// The file name to resolve. + /// The extension to use for the look up. + /// Indicate if we can load assembly for the resolution. /// Indicate if the returned path is fully resolved. - /// Indicate if the resolved module should be loaded. /// - /// The resolved file path. Or, the combined path of and when the file path cannot be resolved. + /// The resolved file path. Or, the combined path of and when the file path cannot be resolved. /// - internal string FixupFileName(string moduleBase, string name, string extension, bool isImportingModule, out bool pathIsResolved, bool skipLoading = false) + private string FixFileName(string moduleName, string moduleBase, string fileName, string extension, bool canLoadAssembly, out bool pathIsResolved) { pathIsResolved = false; - string originalName = name; - string originalExt = Path.GetExtension(name); - if (string.IsNullOrEmpty(originalExt)) + string originalName = fileName; + string originalExt = Path.GetExtension(fileName); + + if (string.IsNullOrEmpty(extension)) { - name += extension; + // When 'extension' is not explicitly specified, we honor the original extension. + extension = originalExt; + } + else if (!extension.Equals(originalExt, StringComparison.OrdinalIgnoreCase)) + { + // When 'extension' is explicitly specified, append it if the original extension is different. + // Note: the original extension could actually be part of the file name. For example, the name + // is `Microsoft.PowerShell.Command.Utility`, in which case the extension is `.Utility`. + fileName += extension; } // Try to get the resolved fully qualified path to the file. @@ -4625,24 +4677,24 @@ namespace Microsoft.PowerShell.Commands // Check for combinedPath in this case will get us the normalized rooted path 'C:\Windows\System32\WindowsPowerShell\v1.0\WSMan.format.ps1xml'. // The 'Microsoft.WSMan.Management' module in PowerShell was updated to not use the relative path for 'FormatsToProcess' entry, // but it's safer to keep the original behavior to avoid unexpected breaking changes. - string combinedPath = Path.Combine(moduleBase, name); - string resolvedPath = IsRooted(name) - ? ResolveRootedFilePath(name, Context) ?? ResolveRootedFilePath(combinedPath, Context) + string combinedPath = Path.Combine(moduleBase, fileName); + string resolvedPath = IsRooted(fileName) + ? ResolveRootedFilePath(fileName, Context) ?? ResolveRootedFilePath(combinedPath, Context) : ResolveRootedFilePath(combinedPath, Context); // Return the path if successfully resolved. - if (resolvedPath != null) + if (resolvedPath is not null) { - if (isImportingModule && resolvedPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && !skipLoading) + if (canLoadAssembly && resolvedPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) { // If we are fixing up an assembly file path and we are actually loading the module, then we load the resolved assembly file here. - // This is because we process type/format ps1xml files before 'RootModule' and 'NestedModules' entries during the module loading. - // A types.ps1xml file could refer to a type defined in the assembly that is specified in the 'RootModule' or 'NestedModule', and - // in that case, processing the types.ps1xml file would fail because it happens before processing the 'RootModule', which loads - // the assembly. We cannot move the processing of types.ps1xml file after processing 'RootModule' either, because the 'RootModule' - // might refer to members defined in the types.ps1xml file. In order to make it work for this paradox, we have to load the resolved - // assembly when we are actually loading the module. However, when it's module analysis, there is no need to load the assembly. - ExecutionContext.LoadAssembly(name: null, filename: resolvedPath, error: out _); + // This is because we process type/format ps1xml files before 'RootModule' during the module loading. A types.ps1xml file could + // refer to a type defined in the assembly that is specified in the 'RootModule', and in that case, processing the types.ps1xml file + // would fail because it happens before processing the 'RootModule', which loads the assembly. + // We cannot move the processing of types.ps1xml file after processing 'RootModule' either, because the 'RootModule' might refer to + // members defined in the types.ps1xml file. In order to make it work for this paradox, we have to load the resolved assembly when + // we are actually loading the module. However, when it's module analysis, there is no need to load the assembly. + Context.AddAssembly(source: moduleName, assemblyName: null, filePath: resolvedPath, error: out _); } pathIsResolved = true; @@ -4655,12 +4707,12 @@ namespace Microsoft.PowerShell.Commands // For dlls, we cannot get the path from the provider. // We need to load the assembly and then get the path. // If the module is already loaded, this is not expensive since the assembly is already loaded in the AppDomain - if (!string.IsNullOrEmpty(extension) && + if (canLoadAssembly && !string.IsNullOrEmpty(extension) && (extension.Equals(StringLiterals.PowerShellILAssemblyExtension, StringComparison.OrdinalIgnoreCase) || - extension.Equals(StringLiterals.PowerShellILExecutableExtension, StringComparison.OrdinalIgnoreCase))) + extension.Equals(StringLiterals.PowerShellILExecutableExtension, StringComparison.OrdinalIgnoreCase))) { - Assembly assembly = ExecutionContext.LoadAssembly(name: originalName, filename: null, error: out _); - if (assembly != null) + Assembly assembly = Context.AddAssembly(source: moduleName, assemblyName: originalName, filePath: null, error: out _); + if (assembly is not null) { pathIsResolved = true; result = assembly.Location; @@ -4974,8 +5026,6 @@ namespace Microsoft.PowerShell.Commands /// Module name specified in the cmdlet. internal void RemoveModule(PSModuleInfo module, string moduleNameInRemoveModuleCmdlet) { - bool isTopLevelModule = false; - // if the module path is empty string, means it is a dynamically generated assembly. // We have set the module path to be module name as key to make it unique, we need update here as well in case the module can be removed. if (module.Path == string.Empty) @@ -4983,7 +5033,7 @@ namespace Microsoft.PowerShell.Commands module.Path = module.Name; } - bool shouldModuleBeRemoved = ShouldModuleBeRemoved(module, moduleNameInRemoveModuleCmdlet, out isTopLevelModule); + bool shouldModuleBeRemoved = ShouldModuleBeRemoved(module, moduleNameInRemoveModuleCmdlet, out bool isTopLevelModule); if (shouldModuleBeRemoved) { @@ -5214,6 +5264,15 @@ namespace Microsoft.PowerShell.Commands // And the appdomain level module path cache. PSModuleInfo.RemoveFromAppDomainLevelCache(module.Name); + + // And remove the module assembly entries that may have been added from the assembly cache. + Context.RemoveFromAssemblyCache(source: module.Name); + if (module.ModuleType == ModuleType.Binary && !string.IsNullOrEmpty(module.RootModule)) + { + // We also need to clean up the cache entries that are possibly referenced by the root module in this case. + string rootModuleName = ModuleIntrinsics.GetModuleName(module.RootModule); + Context.RemoveFromAssemblyCache(source: rootModuleName); + } } } } @@ -5903,6 +5962,7 @@ namespace Microsoft.PowerShell.Commands ext.Equals(StringLiterals.PowerShellILExecutableExtension, StringComparison.OrdinalIgnoreCase)) { module = LoadBinaryModule( + parentModule, ModuleIntrinsics.GetModuleName(fileName), fileName, assemblyToLoad: null, @@ -5911,8 +5971,6 @@ namespace Microsoft.PowerShell.Commands options, manifestProcessingFlags, prefix, - loadTypes: true, - loadFormats: true, out found); if (found && module != null) @@ -6384,6 +6442,7 @@ namespace Microsoft.PowerShell.Commands /// /// Load a binary module. A binary module is an assembly that should contain cmdlets. /// + /// The parent module for which this module is a nested module. /// The name of the snapin or assembly to load. /// The path to the assembly to load. /// The assembly to load so no lookup need be done. @@ -6395,12 +6454,11 @@ namespace Microsoft.PowerShell.Commands /// /// The set of options that are used while importing a module. /// The manifest processing flags to use when processing the module. - /// Load the types files mentioned in the snapin registration. - /// Load the formst files mentioned in the snapin registration. /// Command name prefix. /// Sets this to true if an assembly was found. /// THe module info object that was created... internal PSModuleInfo LoadBinaryModule( + PSModuleInfo parentModule, string moduleName, string fileName, Assembly assemblyToLoad, @@ -6409,12 +6467,10 @@ namespace Microsoft.PowerShell.Commands ImportModuleOptions options, ManifestProcessingFlags manifestProcessingFlags, string prefix, - bool loadTypes, - bool loadFormats, out bool found) { return LoadBinaryModule( - parentModule: null, + parentModule, moduleName, fileName, assemblyToLoad, @@ -6423,8 +6479,6 @@ namespace Microsoft.PowerShell.Commands options, manifestProcessingFlags, prefix, - loadTypes, - loadFormats, out found, shortModuleName: null, disableFormatUpdates: false); @@ -6446,8 +6500,6 @@ namespace Microsoft.PowerShell.Commands /// The set of options that are used while importing a module. /// The manifest processing flags to use when processing the module. /// Command name prefix. - /// Load the types files mentioned in the snapin registration. - /// Load the formst files mentioned in the snapin registration. /// Sets this to true if an assembly was found. /// Short name for module. /// @@ -6462,44 +6514,36 @@ namespace Microsoft.PowerShell.Commands ImportModuleOptions options, ManifestProcessingFlags manifestProcessingFlags, string prefix, - bool loadTypes, - bool loadFormats, out bool found, string shortModuleName, bool disableFormatUpdates) { - PSModuleInfo module = null; - if (string.IsNullOrEmpty(moduleName) && string.IsNullOrEmpty(fileName) && assemblyToLoad == null) + { throw PSTraceSource.NewArgumentNullException("moduleName,fileName,assemblyToLoad"); + } + + bool isParentEngineModule = parentModule != null && InitialSessionState.IsEngineModule(parentModule.Name); // Load the dll and process any cmdlets it might contain... InitialSessionState iss = InitialSessionState.Create(); List detectedCmdlets = null; List> detectedAliases = null; Assembly assembly = null; - Exception error = null; string modulePath = string.Empty; Version assemblyVersion = new Version(0, 0, 0, 0); - var importingModule = (manifestProcessingFlags & ManifestProcessingFlags.LoadElements) != 0; + bool importingModule = (manifestProcessingFlags & ManifestProcessingFlags.LoadElements) != 0; // See if we're loading a straight assembly... if (assemblyToLoad != null) { // Figure out what to use for a module path... - if (!string.IsNullOrEmpty(fileName)) - { - modulePath = fileName; - } - else - { - modulePath = assemblyToLoad.Location; - } + modulePath = string.IsNullOrEmpty(fileName) ? assemblyToLoad.Location : fileName; // And what to use for a module name... if (string.IsNullOrEmpty(moduleName)) { - moduleName = "dynamic_code_module_" + assemblyToLoad.GetName(); + moduleName = "dynamic_code_module_" + assemblyToLoad.FullName; } if (importingModule) @@ -6507,59 +6551,41 @@ namespace Microsoft.PowerShell.Commands // Passing module as a parameter here so that the providers can have the module property populated. // For engine providers, the module should point to top-level module name // For FileSystem, the module is Microsoft.PowerShell.Core and not System.Management.Automation - if (parentModule != null && InitialSessionState.IsEngineModule(parentModule.Name)) - { - iss.ImportCmdletsFromAssembly(assemblyToLoad, parentModule); - } - else - { - iss.ImportCmdletsFromAssembly(assemblyToLoad, null); - } + iss.ImportCmdletsFromAssembly(assemblyToLoad, isParentEngineModule ? parentModule : null); } assemblyVersion = GetAssemblyVersionNumber(assemblyToLoad); assembly = assemblyToLoad; - // If this is an in-memory only assembly, add it directly to the assembly cache if - // it isn't already there. - if (string.IsNullOrEmpty(assembly.Location)) - { - if (!Context.AssemblyCache.ContainsKey(assembly.FullName)) - { - Context.AssemblyCache.Add(assembly.FullName, assembly); - } - } + + // Use the parent module name for caching if there is one. + string source = parentModule?.Name ?? moduleName; + // Add it to the assembly cache if it isn't already there. + Context.AddToAssemblyCache(source, assembly); } else if (importingModule) { - assembly = Context.AddAssembly(moduleName, fileName, out error); + // Use the parent module name for caching if there is one. + string source = parentModule?.Name ?? moduleName; + assembly = Context.AddAssembly(source, moduleName, fileName, out Exception error); if (assembly == null) { if (error != null) + { throw error; + } found = false; return null; } assemblyVersion = GetAssemblyVersionNumber(assembly); - - if (string.IsNullOrEmpty(fileName)) - modulePath = assembly.Location; - else - modulePath = fileName; + modulePath = string.IsNullOrEmpty(fileName) ? assembly.Location : fileName; // Passing module as a parameter here so that the providers can have the module property populated. // For engine providers, the module should point to top-level module name // For FileSystem, the module is Microsoft.PowerShell.Core and not System.Management.Automation - if (parentModule != null && InitialSessionState.IsEngineModule(parentModule.Name)) - { - iss.ImportCmdletsFromAssembly(assembly, parentModule); - } - else - { - iss.ImportCmdletsFromAssembly(assembly, null); - } + iss.ImportCmdletsFromAssembly(assembly, isParentEngineModule ? parentModule : null); } else { @@ -6576,58 +6602,25 @@ namespace Microsoft.PowerShell.Commands } found = true; - if (string.IsNullOrEmpty(shortModuleName)) - module = new PSModuleInfo(moduleName, modulePath, Context, ss); - else - module = new PSModuleInfo(shortModuleName, modulePath, Context, ss); + string nameToUse = string.IsNullOrEmpty(shortModuleName) ? moduleName : shortModuleName; + PSModuleInfo module = new PSModuleInfo(nameToUse, modulePath, Context, ss); module.SetModuleType(ModuleType.Binary); module.SetModuleBase(moduleBase); module.SetVersion(assemblyVersion); - module.ImplementingAssembly = assemblyToLoad ?? assembly; + module.ImplementingAssembly = assembly; if (importingModule) { SetModuleLoggingInformation(module); } - // Add the types table entries - List typesFileNames = new List(); - foreach (SessionStateTypeEntry sste in iss.Types) - { - typesFileNames.Add(sste.FileName); - } - - if (typesFileNames.Count > 0) - { - module.SetExportedTypeFiles(new ReadOnlyCollection(typesFileNames)); - } - - // Add the format file entries - List formatsFileNames = new List(); - foreach (SessionStateFormatEntry ssfe in iss.Formats) - { - formatsFileNames.Add(ssfe.FileName); - } - - if (formatsFileNames.Count > 0) - { - module.SetExportedFormatFiles(new ReadOnlyCollection(formatsFileNames)); - } - // Add the module info the providers... foreach (SessionStateProviderEntry sspe in iss.Providers) { // For engine providers, the module should point to top-level module name // For FileSystem, the module is Microsoft.PowerShell.Core and not System.Management.Automation - if (parentModule != null && InitialSessionState.IsEngineModule(parentModule.Name)) - { - sspe.SetModule(parentModule); - } - else - { - sspe.SetModule(module); - } + sspe.SetModule(isParentEngineModule ? parentModule : module); } // Add all of the exported cmdlets to the module object... @@ -6741,16 +6734,7 @@ namespace Microsoft.PowerShell.Commands iss.Bind(Context, updateOnly: true, module, options.NoClobber, options.Local, setLocation: false); // Scan all of the types in the assembly to register JobSourceAdapters. - IEnumerable allTypes = Array.Empty(); - if (assembly != null) - { - allTypes = assembly.ExportedTypes; - } - else if (assemblyToLoad != null) - { - allTypes = assemblyToLoad.ExportedTypes; - } - + IEnumerable allTypes = assembly?.ExportedTypes ?? Array.Empty(); foreach (Type type in allTypes) { // If it derives from JobSourceAdapter and it's not already registered, register it... diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index ada03fb491..d845a1d2fb 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -885,25 +885,35 @@ namespace System.Management.Automation } // The extensions of all of the files that can be processed with Import-Module, put the ni.dll in front of .dll to have higher priority to be loaded. - internal static readonly string[] PSModuleProcessableExtensions = new string[] { - StringLiterals.PowerShellDataFileExtension, - StringLiterals.PowerShellScriptFileExtension, - StringLiterals.PowerShellModuleFileExtension, - StringLiterals.PowerShellCmdletizationFileExtension, - StringLiterals.PowerShellNgenAssemblyExtension, - StringLiterals.PowerShellILAssemblyExtension, - StringLiterals.PowerShellILExecutableExtension, - }; + internal static readonly string[] PSModuleProcessableExtensions = new string[] + { + StringLiterals.PowerShellDataFileExtension, + StringLiterals.PowerShellScriptFileExtension, + StringLiterals.PowerShellModuleFileExtension, + StringLiterals.PowerShellCmdletizationFileExtension, + StringLiterals.PowerShellNgenAssemblyExtension, + StringLiterals.PowerShellILAssemblyExtension, + StringLiterals.PowerShellILExecutableExtension, + }; // A list of the extensions to check for implicit module loading and discovery, put the ni.dll in front of .dll to have higher priority to be loaded. - internal static readonly string[] PSModuleExtensions = new string[] { - StringLiterals.PowerShellDataFileExtension, - StringLiterals.PowerShellModuleFileExtension, - StringLiterals.PowerShellCmdletizationFileExtension, - StringLiterals.PowerShellNgenAssemblyExtension, - StringLiterals.PowerShellILAssemblyExtension, - StringLiterals.PowerShellILExecutableExtension, - }; + internal static readonly string[] PSModuleExtensions = new string[] + { + StringLiterals.PowerShellDataFileExtension, + StringLiterals.PowerShellModuleFileExtension, + StringLiterals.PowerShellCmdletizationFileExtension, + StringLiterals.PowerShellNgenAssemblyExtension, + StringLiterals.PowerShellILAssemblyExtension, + StringLiterals.PowerShellILExecutableExtension, + }; + + // A list of the extensions to check for required assemblies. + internal static readonly string[] ProcessableAssemblyExtensions = new string[] + { + StringLiterals.PowerShellNgenAssemblyExtension, + StringLiterals.PowerShellILAssemblyExtension, + StringLiterals.PowerShellILExecutableExtension + }; /// /// Returns true if the extension is one of the module extensions... @@ -915,7 +925,9 @@ namespace System.Management.Automation foreach (string ext in PSModuleProcessableExtensions) { if (extension.Equals(ext, StringComparison.OrdinalIgnoreCase)) + { return true; + } } return false; diff --git a/test/powershell/engine/Module/IsolatedModule.Tests.ps1 b/test/powershell/engine/Module/IsolatedModule.Tests.ps1 new file mode 100644 index 0000000000..114b1d1610 --- /dev/null +++ b/test/powershell/engine/Module/IsolatedModule.Tests.ps1 @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe "Isolated module scenario - load the whole module in custom ALC" { + It "Loading 'IsolatedModule' should work as expected" { + ## The 'IsolatedModule' module can be found at '\test\tools\Modules'. + ## The module assemblies are created and deployed by '\test\tools\TestAlc'. + ## The module defines its own custom ALC and has its module structure organized in a special way that allows the module to be loaded in that custom ALC. + ## The file structure of this module is as follows: + ## │ IsolatedModule.psd1 + ## │ Test.Isolated.Init.dll (contains the custom ALC and code to setup 'Resolving' handler) + ## │ + ## └───Dependencies + ## Newtonsoft.Json.dll (version 10.0.0.0 dependency) + ## Test.Isolated.Nested.dll (nested binary module) + ## Test.Isolated.Root.dll (root binary module) + $module = Import-Module IsolatedModule -PassThru + $nestedCmd = Get-Command Test-NestedCommand + $rootCmd = Get-Command Test-RootCommand + + $module.ModuleType | Should -Be "Binary" + $module.RootModule | Should -Not -BeNullOrEmpty + + ## The type 'Test.Isolated.Nested.Foo' from the nested module can be resolved and should be from the same load context. + $context1 = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($nestedCmd.ImplementingType.Assembly) + $context2 = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext([Test.Isolated.Nested.Foo].Assembly) + $context1.Name | Should -BeExactly "MyCustomALC" + $context1 | Should -Be $context2 + + ## Test-NestedCommand depends on NewtonSoft.Json 10.0.0.0 while PowerShell depends on 13.0.0.0 or higher. + ## The exact version of NewtonSoft.Json should be loaded to the custom ALC. + $foo = [Test.Isolated.Nested.Foo]::new("Hello", "World") + Test-NestedCommand -Param $foo | Should -BeExactly "Hello-World-Newtonsoft.Json, Version=10.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed" + + ## The type 'Test.Isolated.Root.Red' from the root module can be resolved and should be from the same load context. + $context3 = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($rootCmd.ImplementingType.Assembly) + $context4 = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext([Test.Isolated.Root.Red].Assembly) + $context3.Name | Should -BeExactly "MyCustomALC" + $context3 | Should -Be $context4 + $context3 | Should -Be $context1 + + ## No type identity issue in parameter binding because they are from the same assembly instance. + $red = [Test.Isolated.Root.Red]::new("RED!") + Test-RootCommand -Param $red | Should -BeExactly "RED!" + + ## Removing the module should have its assemblies removed from 'Context.AssemblyCache' and results in the 'TypeNotFound' + ## error when trying to resolve the following 2 types: + ## - 'Test.Isolated.Nested.Bar', from 'Test.Isolated.Nested.dll' + ## - 'Test.Isolated.Root.Yellow', from 'Test.Isolated.Root.dll' + ## The types cannot be found because: + ## 1. they are from a load context that is not visible to PowerShell, + ## 2. those two assemblies have been removed from the cache when unloading the module. + ## [Test.Isolated.Nested.Foo] and [Test.Isolated.Root.Red] can still be found because they were added to type cache when + ## successfully resolved above. + Remove-Module IsolatedModule + { [Test.Isolated.Nested.Bar] } | Should -Throw -ErrorId "TypeNotFound" + { [Test.Isolated.Root.Yellow] } | Should -Throw -ErrorId "TypeNotFound" + } + + It "WSMan and Certificate providers should reference the manifest module instead of the nested module" -Skip:(!$IsWindows) { + $wsManModule = Import-Module Microsoft.WSMan.Management -PassThru + $securityModule = Import-Module Microsoft.PowerShell.Security -PassThru + + $wsManModule.ModuleType | Should -Be "Manifest" + $securityModule.ModuleType | Should -Be "Manifest" + + ## For engine providers, the 'Module' property should point to top-level module, instead of the nested module. + $wsManProvider = Get-PSProvider WSMan + $certificateProvider = Get-PSProvider Certificate + + $wsManProvider.Module | Should -Be $wsManModule + $certificateProvider.Module | Should -Be $securityModule + } +} diff --git a/test/powershell/engine/Module/TestModuleManifest.Tests.ps1 b/test/powershell/engine/Module/TestModuleManifest.Tests.ps1 index 2b84e75f90..091b968c27 100644 --- a/test/powershell/engine/Module/TestModuleManifest.Tests.ps1 +++ b/test/powershell/engine/Module/TestModuleManifest.Tests.ps1 @@ -18,12 +18,21 @@ Describe "Test-ModuleManifest tests" -tags "CI" { New-Item -ItemType Directory -Path testdrive:/module/foo > $null New-Item -ItemType Directory -Path testdrive:/module/bar > $null + New-Item -ItemType File -Path testdrive:/module/foo/bar.psm1 > $null + New-Item -ItemType File -Path testdrive:/module/foo/bar.ps1 > $null + New-Item -ItemType File -Path testdrive:/module/foo/bar.ps1xml > $null + New-Item -ItemType File -Path testdrive:/module/bar/foo.psm1 > $null + New-Item -ItemType File -Path testdrive:/module/bar/foo.ps1 > $null + New-Item -ItemType File -Path testdrive:/module/bar/foo.ps1xml > $null + $testModulePath = "testdrive:/module/test.psd1" $fileList = "foo\bar.psm1","bar/foo.psm1" + $scripts = "foo\/bar.ps1","bar/\foo.ps1" + $ps1xml = "foo//bar.ps1xml","bar\\foo.ps1xml" - New-ModuleManifest -NestedModules $fileList -RootModule foo\bar.psm1 -RequiredAssemblies $fileList -Path $testModulePath -TypesToProcess $fileList -FormatsToProcess $fileList -ScriptsToProcess $fileList -FileList $fileList -ModuleList $fileList + New-ModuleManifest -NestedModules $fileList -RootModule foo\bar.psm1 -RequiredAssemblies $fileList -Path $testModulePath -TypesToProcess $ps1xml -FormatsToProcess $ps1xml -ScriptsToProcess $scripts -FileList $fileList -ModuleList $fileList Test-Path $testModulePath | Should -BeTrue diff --git a/test/tools/Modules/IsolatedModule/IsolatedModule.psd1 b/test/tools/Modules/IsolatedModule/IsolatedModule.psd1 new file mode 100644 index 0000000000..2bbe183c24 --- /dev/null +++ b/test/tools/Modules/IsolatedModule/IsolatedModule.psd1 @@ -0,0 +1,16 @@ +# +# Module manifest for module 'IsolatedModule' +# + +@{ + ModuleVersion = '0.0.1' + GUID = '20d4742b-b17d-4ce8-b8da-29b25433cd18' + Author = 'Microsoft Corporation' + + RootModule = 'Test.Isolated.Root.dll' + NestedModules = @('Test.Isolated.Init.dll', 'Test.Isolated.Nested.dll') + FunctionsToExport = @() + CmdletsToExport = @('Test-NestedCommand', 'Test-RootCommand') + VariablesToExport = '*' + AliasesToExport = @() +} diff --git a/test/tools/TestAlc/TestAlc.sln b/test/tools/TestAlc/TestAlc.sln new file mode 100644 index 0000000000..c97dde9eb9 --- /dev/null +++ b/test/tools/TestAlc/TestAlc.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30114.105 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.Isolated.Init", "init\Test.Isolated.Init.csproj", "{87AFE044-D21A-4ECE-A8F7-9A31678811D9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.Isolated.Nested", "nested\Test.Isolated.Nested.csproj", "{702DAF12-2A79-4756-8E2A-E18B46639AD3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.Isolated.Root", "root\Test.Isolated.Root.csproj", "{9D0B1FAD-52F7-436A-AB9D-5A53E370C72B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {87AFE044-D21A-4ECE-A8F7-9A31678811D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87AFE044-D21A-4ECE-A8F7-9A31678811D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87AFE044-D21A-4ECE-A8F7-9A31678811D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87AFE044-D21A-4ECE-A8F7-9A31678811D9}.Release|Any CPU.Build.0 = Release|Any CPU + {702DAF12-2A79-4756-8E2A-E18B46639AD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {702DAF12-2A79-4756-8E2A-E18B46639AD3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {702DAF12-2A79-4756-8E2A-E18B46639AD3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {702DAF12-2A79-4756-8E2A-E18B46639AD3}.Release|Any CPU.Build.0 = Release|Any CPU + {9D0B1FAD-52F7-436A-AB9D-5A53E370C72B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D0B1FAD-52F7-436A-AB9D-5A53E370C72B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D0B1FAD-52F7-436A-AB9D-5A53E370C72B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D0B1FAD-52F7-436A-AB9D-5A53E370C72B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/test/tools/TestAlc/init/Init.cs b/test/tools/TestAlc/init/Init.cs new file mode 100644 index 0000000000..4241e56fa4 --- /dev/null +++ b/test/tools/TestAlc/init/Init.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Management.Automation; +using System.Reflection; +using System.Runtime.Loader; + +namespace Test.Isolated.Init +{ + internal class CustomLoadContext : AssemblyLoadContext + { + private readonly string _dependencyDirPath; + + public CustomLoadContext(string dependencyDirPath) + : base("MyCustomALC", isCollectible: false) + { + _dependencyDirPath = dependencyDirPath; + } + + protected override Assembly Load(AssemblyName assemblyName) + { + // We do the simple logic here of looking for an assembly of the given name + // in the configured dependency directory. + string assemblyPath = Path.Combine(_dependencyDirPath, $"{assemblyName.Name}.dll"); + + if (File.Exists(assemblyPath)) + { + // The ALC must use inherited methods to load assemblies. + // Assembly.Load*() won't work here. + return LoadFromAssemblyPath(assemblyPath); + } + + // For other assemblies, return null to allow other resolutions to continue. + return null; + } + } + + public class Init : IModuleAssemblyInitializer, IModuleAssemblyCleanup + { + private static readonly CustomLoadContext s_context; + private static readonly HashSet s_moduleAssemblies; + + static Init() + { + string dependencyDirPath = Path.Combine(Path.GetDirectoryName(typeof(Init).Assembly.Location), "Dependencies"); + s_context = new CustomLoadContext(dependencyDirPath); + s_moduleAssemblies = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Test.Isolated.Nested", + "Test.Isolated.Root" + }; + } + + public void OnImport() + { + // Add the Resolving event handler here. + AssemblyLoadContext.Default.Resolving += ResolveAlcEngine; + } + + public void OnRemove(PSModuleInfo psModuleInfo) + { + // Remove the Resolving event handler here. + AssemblyLoadContext.Default.Resolving -= ResolveAlcEngine; + } + + private static Assembly ResolveAlcEngine(AssemblyLoadContext defaultAlc, AssemblyName assemblyToResolve) + { + // We only want to resolve our module assemblies here. + if (s_moduleAssemblies.Contains(assemblyToResolve.Name)) + { + // This is where the nested module 'Test.Isolated.Nested.dll' and the root module 'Test.Isolated.Root.dll' + // gets loaded into our custom ALC and then passed through into PowerShell's ALC. + return s_context.LoadFromAssemblyName(assemblyToResolve); + } + + // Let the resolution chain continue for other assemblies. + return null; + } + } +} diff --git a/test/tools/TestAlc/init/Test.Isolated.Init.csproj b/test/tools/TestAlc/init/Test.Isolated.Init.csproj new file mode 100644 index 0000000000..c1a291fa55 --- /dev/null +++ b/test/tools/TestAlc/init/Test.Isolated.Init.csproj @@ -0,0 +1,21 @@ + + + + + + + false + None + + + false + + + ..\..\Modules\IsolatedModule + + + + + + + diff --git a/test/tools/TestAlc/nested/NestedCommand.cs b/test/tools/TestAlc/nested/NestedCommand.cs new file mode 100644 index 0000000000..8882292013 --- /dev/null +++ b/test/tools/TestAlc/nested/NestedCommand.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; +using Newtonsoft.Json; + +namespace Test.Isolated.Nested +{ + [Cmdlet("Test", "NestedCommand")] + public class TestNestedCommand : PSCmdlet + { + [Parameter(Mandatory = true)] + public Foo Param { get; set; } + + protected override void ProcessRecord() + { + WriteObject($"{Param.Name}-{Param.Path}-{typeof(StringEscapeHandling).Assembly.FullName}"); + } + } + + public class Foo + { + public string Name { get; } + + public string Path { get; } + + public Foo(string name, string path) + { + Name = name; + Path = path; + } + } + + public class Bar + { + public string Id { get; } + + public Bar(string id) + { + Id = id; + } + } +} diff --git a/test/tools/TestAlc/nested/Test.Isolated.Nested.csproj b/test/tools/TestAlc/nested/Test.Isolated.Nested.csproj new file mode 100644 index 0000000000..2f4a64332b --- /dev/null +++ b/test/tools/TestAlc/nested/Test.Isolated.Nested.csproj @@ -0,0 +1,22 @@ + + + + + + + false + None + + + false + + + ..\..\Modules\IsolatedModule\Dependencies + + + + + + + + diff --git a/test/tools/TestAlc/root/RootCommand.cs b/test/tools/TestAlc/root/RootCommand.cs new file mode 100644 index 0000000000..74aa40006f --- /dev/null +++ b/test/tools/TestAlc/root/RootCommand.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; + +namespace Test.Isolated.Root +{ + [Cmdlet("Test", "RootCommand")] + public class TestRootCommand : PSCmdlet + { + [Parameter(Mandatory = true)] + public Red Param { get; set; } + + protected override void ProcessRecord() + { + WriteObject(Param.Name); + } + } + + public class Red + { + public string Name { get; } + + public Red(string name) + { + Name = name; + } + } + + public class Yellow + { + public string Id { get; } + + public Yellow(string id) + { + Id = id; + } + } +} diff --git a/test/tools/TestAlc/root/Test.Isolated.Root.csproj b/test/tools/TestAlc/root/Test.Isolated.Root.csproj new file mode 100644 index 0000000000..ab333e0668 --- /dev/null +++ b/test/tools/TestAlc/root/Test.Isolated.Root.csproj @@ -0,0 +1,21 @@ + + + + + + + false + None + + + false + + + ..\..\Modules\IsolatedModule\Dependencies + + + + + + +