Skip to content

Commit 592668b

Browse files
authored
Refactor the module path construction code to make it more robust and easier to maintain (#26565)
1 parent 2e7765e commit 592668b

3 files changed

Lines changed: 123 additions & 102 deletions

File tree

src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleHost.cs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2201,7 +2201,6 @@ private void ReportException(Exception e, Executor exec)
22012201
if (e1 != null)
22022202
{
22032203
// that didn't work. Write out the error ourselves as a last resort.
2204-
22052204
ReportExceptionFallback(e, null);
22062205
}
22072206
}
@@ -2222,15 +2221,17 @@ private void ReportExceptionFallback(Exception e, string header)
22222221
Console.Error.WriteLine(header);
22232222
}
22242223

2225-
if (e == null)
2224+
if (e is null)
22262225
{
22272226
return;
22282227
}
22292228

22302229
// See if the exception has an error record attached to it...
22312230
ErrorRecord er = null;
22322231
if (e is IContainsErrorRecord icer)
2232+
{
22332233
er = icer.ErrorRecord;
2234+
}
22342235

22352236
if (e is PSRemotingTransportException)
22362237
{
@@ -2247,8 +2248,22 @@ private void ReportExceptionFallback(Exception e, string header)
22472248
}
22482249

22492250
// Add the position message for the error if it's available.
2250-
if (er != null && er.InvocationInfo != null)
2251+
if (er?.InvocationInfo is { })
2252+
{
22512253
Console.Error.WriteLine(er.InvocationInfo.PositionMessage);
2254+
}
2255+
2256+
// Print the stack trace.
2257+
Console.Error.WriteLine($"\n--- {e.GetType().FullName} ---");
2258+
Console.Error.WriteLine(e.StackTrace);
2259+
2260+
Exception inner = e.InnerException;
2261+
while (inner is { })
2262+
{
2263+
Console.Error.WriteLine($"--- inner {inner.GetType().FullName} ---");
2264+
Console.Error.WriteLine(inner.StackTrace);
2265+
inner = inner.InnerException;
2266+
}
22522267
}
22532268

22542269
/// <summary>

src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs

Lines changed: 78 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@
1010
using System.Management.Automation.Language;
1111
using System.Text;
1212
using System.Threading;
13-
1413
using Microsoft.PowerShell.Commands;
15-
using Microsoft.Win32;
1614

1715
using Dbg = System.Management.Automation.Diagnostics;
1816

@@ -39,18 +37,22 @@ public class ModuleIntrinsics
3937
private static readonly string s_windowsPowerShellPSHomeModulePath =
4038
Path.Combine(System.Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "Modules");
4139

40+
static ModuleIntrinsics()
41+
{
42+
// Initialize the module path.
43+
SetModulePath();
44+
}
45+
4246
internal ModuleIntrinsics(ExecutionContext context)
4347
{
4448
_context = context;
45-
46-
// And initialize the module path...
47-
SetModulePath();
49+
ModuleTable = new Dictionary<string, PSModuleInfo>(StringComparer.OrdinalIgnoreCase);
4850
}
4951

5052
private readonly ExecutionContext _context;
5153

5254
// Holds the module collection...
53-
internal Dictionary<string, PSModuleInfo> ModuleTable { get; } = new Dictionary<string, PSModuleInfo>(StringComparer.OrdinalIgnoreCase);
55+
internal Dictionary<string, PSModuleInfo> ModuleTable { get; }
5456

5557
private const int MaxModuleNestingDepth = 10;
5658

@@ -176,11 +178,9 @@ private PSModuleInfo CreateModuleImplementation(string name, string path, object
176178

177179
sb.SessionState = ss;
178180
}
179-
else
181+
else if (moduleCode is string sbText)
180182
{
181-
var sbText = moduleCode as string;
182-
if (sbText != null)
183-
sb = ScriptBlock.Create(_context, sbText);
183+
sb = ScriptBlock.Create(_context, sbText);
184184
}
185185
}
186186

@@ -1082,91 +1082,80 @@ internal static string GetExpandedEnvironmentVariable(string name, EnvironmentVa
10821082
return result;
10831083
}
10841084

1085-
/// <summary>
1086-
/// Checks if a particular string (path) is a member of 'combined path' string (like %Path% or %PSModulePath%)
1087-
/// </summary>
1088-
/// <param name="pathToScan">'Combined path' string to analyze; can not be null.</param>
1089-
/// <param name="pathToLookFor">Path to search for; can not be another 'combined path' (semicolon-separated); can not be null.</param>
1090-
/// <returns>Index of pathToLookFor in pathToScan; -1 if not found.</returns>
1091-
private static int PathContainsSubstring(string pathToScan, string pathToLookFor)
1092-
{
1093-
// we don't support if any of the args are null - parent function should ensure this; empty values are ok
1094-
Diagnostics.Assert(pathToScan != null, "pathToScan should not be null according to contract of the function");
1095-
Diagnostics.Assert(pathToLookFor != null, "pathToLookFor should not be null according to contract of the function");
1096-
1097-
int pos = 0; // position of the current substring in pathToScan
1098-
string[] substrings = pathToScan.Split(Path.PathSeparator, StringSplitOptions.None); // we want to process empty entries
1099-
string goodPathToLookFor = pathToLookFor.Trim().TrimEnd(Path.DirectorySeparatorChar); // trailing backslashes and white-spaces will mess up equality comparison
1100-
foreach (string substring in substrings)
1101-
{
1102-
string goodSubstring = substring.Trim().TrimEnd(Path.DirectorySeparatorChar); // trailing backslashes and white-spaces will mess up equality comparison
1103-
1104-
// We have to use equality comparison on individual substrings (as opposed to simple 'string.IndexOf' or 'string.Contains')
1105-
// because of cases like { pathToScan="C:\Temp\MyDir\MyModuleDir", pathToLookFor="C:\Temp" }
1106-
1107-
if (string.Equals(goodSubstring, goodPathToLookFor, StringComparison.OrdinalIgnoreCase))
1108-
{
1109-
return pos; // match found - return index of it in the 'pathToScan' string
1110-
}
1111-
else
1112-
{
1113-
pos += substring.Length + 1; // '1' is for trailing semicolon
1114-
}
1115-
}
1116-
// if we are here, that means a match was not found
1117-
return -1;
1118-
}
1119-
11201085
/// <summary>
11211086
/// Adds paths to a 'combined path' string (like %Path% or %PSModulePath%) if they are not already there.
11221087
/// </summary>
11231088
/// <param name="basePath">Path string (like %Path% or %PSModulePath%).</param>
1124-
/// <param name="pathToAdd">Collection of individual paths to add.</param>
1089+
/// <param name="pathToAdd">An individual path to add, or multiple paths separated by the path separator character.</param>
11251090
/// <param name="insertPosition">-1 to append to the end; 0 to insert in the beginning of the string; etc...</param>
11261091
/// <returns>Result string.</returns>
1127-
private static string AddToPath(string basePath, string pathToAdd, int insertPosition)
1092+
private static string UpdatePath(string basePath, string pathToAdd, ref int insertPosition)
11281093
{
11291094
// we don't support if any of the args are null - parent function should ensure this; empty values are ok
1130-
Diagnostics.Assert(basePath != null, "basePath should not be null according to contract of the function");
1131-
Diagnostics.Assert(pathToAdd != null, "pathToAdd should not be null according to contract of the function");
1095+
Dbg.Assert(basePath != null, "basePath should not be null according to contract of the function");
1096+
Dbg.Assert(pathToAdd != null, "pathToAdd should not be null according to contract of the function");
1097+
1098+
// The 'pathToAdd' could be a 'combined path' (path-separator-separated).
1099+
string[] newPaths = pathToAdd.Split(
1100+
Path.PathSeparator,
1101+
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
11321102

1133-
StringBuilder result = new StringBuilder(basePath);
1103+
if (newPaths.Length is 0)
1104+
{
1105+
// The 'pathToAdd' doesn't really contain any paths to add.
1106+
return basePath;
1107+
}
1108+
1109+
var result = new StringBuilder(basePath, capacity: basePath.Length + pathToAdd.Length + newPaths.Length);
1110+
var addedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
1111+
string[] initialPaths = basePath.Split(
1112+
Path.PathSeparator,
1113+
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
1114+
1115+
foreach (string p in initialPaths)
1116+
{
1117+
// Remove the trailing directory separators.
1118+
// Trailing white spaces were already removed by 'StringSplitOptions.TrimEntries'.
1119+
addedPaths.Add(Path.TrimEndingDirectorySeparator(p));
1120+
}
11341121

1135-
if (!string.IsNullOrEmpty(pathToAdd)) // we don't want to append empty paths
1122+
foreach (string subPathToAdd in newPaths)
11361123
{
1137-
foreach (string subPathToAdd in pathToAdd.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) // in case pathToAdd is a 'combined path' (semicolon-separated)
1124+
// Remove the trailing directory separators.
1125+
// Trailing white spaces were already removed by 'StringSplitOptions.TrimEntries'.
1126+
string normalizedPath = Path.TrimEndingDirectorySeparator(subPathToAdd);
1127+
if (addedPaths.Contains(normalizedPath))
11381128
{
1139-
int position = PathContainsSubstring(result.ToString(), subPathToAdd); // searching in effective 'result' value ensures that possible duplicates in pathsToAdd are handled correctly
1140-
if (position == -1) // subPathToAdd not found - add it
1141-
{
1142-
if (insertPosition == -1 || insertPosition > basePath.Length) // append subPathToAdd to the end
1143-
{
1144-
bool endsWithPathSeparator = false;
1145-
if (result.Length > 0)
1146-
{
1147-
endsWithPathSeparator = (result[result.Length - 1] == Path.PathSeparator);
1148-
}
1129+
// The normalized sub path was already added - skip it.
1130+
continue;
1131+
}
11491132

1150-
if (endsWithPathSeparator)
1151-
{
1152-
result.Append(subPathToAdd);
1153-
}
1154-
else
1155-
{
1156-
result.Append(Path.PathSeparator + subPathToAdd);
1157-
}
1158-
}
1159-
else if (insertPosition > result.Length)
1160-
{
1161-
// handle case where path is a singleton with no path separator already
1162-
result.Append(Path.PathSeparator).Append(subPathToAdd);
1163-
}
1164-
else // insert at the requested location (this is used by DSC (<Program Files> location) and by 'user-specific location' (SpecialFolder.MyDocuments or EVT.User))
1165-
{
1166-
result.Insert(insertPosition, subPathToAdd + Path.PathSeparator);
1167-
}
1133+
// The normalized sub path was not found - add it.
1134+
if (insertPosition is -1 || insertPosition >= result.Length)
1135+
{
1136+
// Append the normalized sub path to the end.
1137+
if (result.Length > 0 && result[^1] != Path.PathSeparator)
1138+
{
1139+
result.Append(Path.PathSeparator);
11681140
}
1141+
1142+
result.Append(normalizedPath);
1143+
// Next insertion should happen at the end.
1144+
insertPosition = result.Length;
1145+
}
1146+
else
1147+
{
1148+
// Insert at the requested location.
1149+
// This is used by the user-specific module path, the shared module path (<Program Files> location), and the PSHome module path.
1150+
string strToInsert = normalizedPath + Path.PathSeparator;
1151+
result.Insert(insertPosition, strToInsert);
1152+
1153+
// Next insertion should happen after the just inserted string.
1154+
insertPosition += strToInsert.Length;
11691155
}
1156+
1157+
// Add it to the set.
1158+
addedPaths.Add(normalizedPath);
11701159
}
11711160

11721161
return result.ToString();
@@ -1218,7 +1207,7 @@ public static string GetModulePath(string currentProcessModulePath, string hklmM
12181207
string psHomeModulePath = GetPSHomeModulePath(); // $PSHome\Modules location
12191208

12201209
// If the variable isn't set, then set it to the default value
1221-
if (currentProcessModulePath == null) // EVT.Process does Not exist - really corner case
1210+
if (string.IsNullOrEmpty(currentProcessModulePath)) // EVT.Process does Not exist - really corner case
12221211
{
12231212
// Handle the default case...
12241213
if (string.IsNullOrEmpty(hkcuUserModulePath)) // EVT.User does Not exist -> set to <SpecialFolder.MyDocuments> location
@@ -1270,21 +1259,6 @@ public static string GetModulePath(string currentProcessModulePath, string hklmM
12701259
return currentProcessModulePath;
12711260
}
12721261

1273-
private static string UpdatePath(string path, string pathToAdd, ref int insertIndex)
1274-
{
1275-
if (!string.IsNullOrEmpty(pathToAdd))
1276-
{
1277-
path = AddToPath(path, pathToAdd, insertIndex);
1278-
insertIndex = path.IndexOf(Path.PathSeparator, PathContainsSubstring(path, pathToAdd));
1279-
if (insertIndex != -1)
1280-
{
1281-
// advance past the path separator
1282-
insertIndex++;
1283-
}
1284-
}
1285-
return path;
1286-
}
1287-
12881262
/// <summary>
12891263
/// Checks if $env:PSModulePath is not set and sets it as appropriate. Note - because these
12901264
/// strings go through the provider, we need to escape any wildcards before passing them
@@ -1358,11 +1332,16 @@ private static string SetModulePath()
13581332
{
13591333
string currentModulePath = GetExpandedEnvironmentVariable(Constants.PSModulePathEnvVar, EnvironmentVariableTarget.Process);
13601334
#if !UNIX
1361-
// if the current process and user env vars are the same, it means we need to append the machine one as it's incomplete
1362-
// otherwise, the user modified it and we should use the process one
1335+
// if the current process and user env vars are the same, it means we need to append the machine one as it's incomplete.
1336+
// Otherwise, the user modified it and we should use the process one.
13631337
if (string.CompareOrdinal(GetExpandedEnvironmentVariable(Constants.PSModulePathEnvVar, EnvironmentVariableTarget.User), currentModulePath) == 0)
13641338
{
1365-
currentModulePath = currentModulePath + Path.PathSeparator + GetExpandedEnvironmentVariable(Constants.PSModulePathEnvVar, EnvironmentVariableTarget.Machine);
1339+
string machineScopeValue = GetExpandedEnvironmentVariable(Constants.PSModulePathEnvVar, EnvironmentVariableTarget.Machine);
1340+
currentModulePath = string.IsNullOrEmpty(currentModulePath)
1341+
? machineScopeValue
1342+
: string.IsNullOrEmpty(machineScopeValue)
1343+
? currentModulePath
1344+
: string.Concat(currentModulePath, Path.PathSeparator, machineScopeValue);
13661345
}
13671346
#endif
13681347
string allUsersModulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.AllUsers);

test/powershell/Host/ConsoleHost.Tests.ps1

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,33 @@ export $envVarName='$guid'
386386
}
387387
}
388388

389+
Context "-SettingsFile Commandline switch set 'PSModulePath'" {
390+
391+
BeforeAll {
392+
$CustomSettingsFile = Join-Path -Path $TestDrive -ChildPath 'powershell.test.json'
393+
$mPath1 = Join-Path $PSHOME 'Modules'
394+
$mPath2 = Join-Path $TestDrive 'NonExist'
395+
$pathSep = [System.IO.Path]::PathSeparator
396+
397+
## Use multiple paths in the setting.
398+
$ModulePath = "${mPath1}${pathSep}${mPath2}".Replace('\', "\\")
399+
Set-Content -Path $CustomSettingsfile -Value "{`"Microsoft.PowerShell:ExecutionPolicy`":`"Unrestricted`", `"PSModulePath`": `"$ModulePath`" }" -ErrorAction Stop
400+
}
401+
402+
It "Verify PowerShell PSModulePath should contain paths from config file" {
403+
$psModulePath = & $powershell -NoProfile -SettingsFile $CustomSettingsFile -Command '$env:PSModulePath'
404+
405+
## $mPath1 already exists in the value of env PSModulePath, so it won't be added again.
406+
$index = $psModulePath.IndexOf("${mPath1}${pathSep}", [System.StringComparison]::OrdinalIgnoreCase)
407+
$index | Should -BeGreaterThan 0
408+
$index += $mPath1.Length
409+
$psModulePath.IndexOf($mPath1, $index, [System.StringComparison]::OrdinalIgnoreCase) | Should -BeExactly -1
410+
411+
## $mPath2 should be added at the index position 0.
412+
$psModulePath.StartsWith("${mPath2}${pathSep}", [System.StringComparison]::OrdinalIgnoreCase) | Should -BeTrue
413+
}
414+
}
415+
389416
Context "Pipe to/from powershell" {
390417
BeforeAll {
391418
if ($null -ne $PSStyle) {

0 commit comments

Comments
 (0)