Skip to content

Commit f488260

Browse files
daxian-dbwSIRMARGIN
authored andcommitted
Update PATH environment variable for package manager executable on Windows (PowerShell#25847)
Implementation for PowerShell/PowerShell-RFC#391. A few implementation decisions: 1. Check the package manager list only once per process (**same as CMD**). So, changing the package manager list in registry doesn't affect a PowerShell session that is already running. 2. When detecting user/system PATH value changes, only check new items PREPENDED or APPENDED to the original string (**same as CMD**). The detection is made simple, which is not intended to detect complex changes to the user/system PATH. 3. The PATH update feature is disabled when the `Environment` provider is not available, or when the current session is restricted. - Both `Environment` provider and `IsSessionRestricted` are checked only once per PowerShell session. - Use `ConditionalWeakTable` to cache whether the feature is enabled or not for a given session. - We cannot use `thread-static` variable for the caching because it's possible for Runspace to reuse threads.
1 parent 031e503 commit f488260

File tree

4 files changed

+681
-32
lines changed

4 files changed

+681
-32
lines changed

src/System.Management.Automation/engine/NativeCommandProcessor.cs

Lines changed: 227 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@
1111
using System.Diagnostics.CodeAnalysis;
1212
using System.Globalization;
1313
using System.IO;
14+
using System.Linq;
1415
using System.Management.Automation.Internal;
15-
using System.Management.Automation.Runspaces;
16-
using System.Runtime.InteropServices;
16+
using System.Runtime.CompilerServices;
1717
using System.Runtime.Serialization;
1818
using System.Text;
1919
using System.Threading;
2020
using System.Threading.Tasks;
2121
using System.Xml;
22+
using Microsoft.PowerShell.Commands;
2223
using Microsoft.PowerShell.Telemetry;
24+
using Microsoft.Win32;
2325
using Dbg = System.Management.Automation.Diagnostics;
2426

2527
namespace System.Management.Automation
@@ -194,27 +196,211 @@ internal NativeCommandExitException(string path, int exitCode, int processId, st
194196
/// </summary>
195197
internal class NativeCommandProcessor : CommandProcessorBase
196198
{
197-
// This is the list of files which will trigger Legacy behavior if
198-
// PSNativeCommandArgumentPassing is set to "Windows".
199-
private static readonly IReadOnlySet<string> s_legacyFileExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
199+
/// <summary>
200+
/// This is the list of files which will trigger Legacy behavior if 'PSNativeCommandArgumentPassing' is set to "Windows".
201+
/// </summary>
202+
private static readonly HashSet<string> s_legacyFileExtensions = new(StringComparer.OrdinalIgnoreCase)
203+
{
204+
".js",
205+
".wsf",
206+
".cmd",
207+
".bat",
208+
".vbs",
209+
};
210+
211+
/// <summary>
212+
/// This is the list of native commands that have non-standard behavior with regard to argument passing.
213+
/// We use Legacy argument parsing for them when 'PSNativeCommandArgumentPassing' is set to "Windows".
214+
/// </summary>
215+
private static readonly HashSet<string> s_legacyCommands = new(StringComparer.OrdinalIgnoreCase)
216+
{
217+
"cmd",
218+
"cscript",
219+
"find",
220+
"sqlcmd",
221+
"wscript",
222+
};
223+
224+
#if !UNIX
225+
/// <summary>
226+
/// List of known package managers pulled from the registry.
227+
/// </summary>
228+
private static readonly HashSet<string> s_knownPackageManagers = GetPackageManagerListFromRegistry();
229+
230+
/// <summary>
231+
/// Indicates whether the Path Update feature is enabled in a given session.
232+
/// PowerShell sessions could reuse the same thread, so we cannot cache the value with a thread static variable.
233+
/// </summary>
234+
private static readonly ConditionalWeakTable<ExecutionContext, string> s_pathUpdateFeatureEnabled = new();
235+
236+
private readonly bool _isPackageManager;
237+
private string _originalUserEnvPath;
238+
private string _originalSystemEnvPath;
239+
240+
/// <summary>
241+
/// Gets the known package managers from the registry.
242+
/// </summary>
243+
private static HashSet<string> GetPackageManagerListFromRegistry()
244+
{
245+
// We only account for the first 8 package managers. This is the same behavior as in CMD.
246+
const int MaxPackageManagerCount = 8;
247+
const string RegKeyPath = @"Software\Microsoft\Command Processor\KnownPackageManagers";
248+
249+
string[] subKeyNames = null;
250+
HashSet<string> retSet = null;
251+
252+
try
253+
{
254+
using RegistryKey key = Registry.LocalMachine.OpenSubKey(RegKeyPath);
255+
subKeyNames = key?.GetSubKeyNames();
256+
}
257+
catch
258+
{
259+
return null;
260+
}
261+
262+
if (subKeyNames is { Length: > 0 })
263+
{
264+
IEnumerable<string> names = subKeyNames.Length <= MaxPackageManagerCount
265+
? subKeyNames
266+
: subKeyNames.Take(MaxPackageManagerCount);
267+
268+
retSet = new(names, StringComparer.OrdinalIgnoreCase);
269+
}
270+
271+
return retSet;
272+
}
273+
274+
/// <summary>
275+
/// Check if the given name is a known package manager from the registry list.
276+
/// </summary>
277+
private static bool IsKnownPackageManager(string name)
200278
{
201-
".js",
202-
".wsf",
203-
".cmd",
204-
".bat",
205-
".vbs",
206-
};
207-
208-
// The following native commands have non-standard behavior with regard to argument passing,
209-
// so we use Legacy argument parsing for them when PSNativeCommandArgumentPassing is set to Windows.
210-
private static readonly IReadOnlySet<string> s_legacyCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
279+
if (s_knownPackageManagers is null)
280+
{
281+
return false;
282+
}
283+
284+
if (s_knownPackageManagers.Contains(name))
285+
{
286+
return true;
287+
}
288+
289+
int lastDotIndex = name.LastIndexOf('.');
290+
if (lastDotIndex > 0)
291+
{
292+
string nameWithoutExt = name[..lastDotIndex];
293+
if (s_knownPackageManagers.Contains(nameWithoutExt))
294+
{
295+
return true;
296+
}
297+
}
298+
299+
return false;
300+
}
301+
302+
/// <summary>
303+
/// Check if the Path Update feature is enabled for the given session.
304+
/// </summary>
305+
private static bool IsPathUpdateFeatureEnabled(ExecutionContext context)
211306
{
212-
"cmd",
213-
"cscript",
214-
"find",
215-
"sqlcmd",
216-
"wscript",
217-
};
307+
// We check only once per session.
308+
if (s_pathUpdateFeatureEnabled.TryGetValue(context, out string value))
309+
{
310+
// The feature is enabled if the value is not null.
311+
return value is { };
312+
}
313+
314+
// Disable Path Update if 'EnvironmentProvider' is disabled in the current session, or the current session is restricted.
315+
bool enabled = context.EngineSessionState.Providers.ContainsKey(EnvironmentProvider.ProviderName)
316+
&& !Utils.IsSessionRestricted(context);
317+
318+
// - Use the static empty string instance to indicate that the feature is enabled.
319+
// - Use the null value to indicate that the feature is disabled.
320+
s_pathUpdateFeatureEnabled.TryAdd(context, enabled ? string.Empty : null);
321+
return enabled;
322+
}
323+
324+
/// <summary>
325+
/// Gets the added part of the new string compared to the old string.
326+
/// </summary>
327+
private static ReadOnlySpan<char> GetAddedPartOfString(string oldString, string newString)
328+
{
329+
if (oldString.Length >= newString.Length)
330+
{
331+
// Nothing added or something removed.
332+
return ReadOnlySpan<char>.Empty;
333+
}
334+
335+
int index = newString.IndexOf(oldString);
336+
if (index is -1)
337+
{
338+
// The new and old strings are drastically different. Stop trying in this case.
339+
return ReadOnlySpan<char>.Empty;
340+
}
341+
342+
if (index > 0)
343+
{
344+
// Found the old string at non-zero offset, so something was prepended to the old string.
345+
return newString.AsSpan(0, index);
346+
}
347+
else
348+
{
349+
// Found the old string at the beginning of the new string, so something was appended to the old string.
350+
return newString.AsSpan(oldString.Length);
351+
}
352+
}
353+
354+
/// <summary>
355+
/// Update the process-scope environment variable Path based on the changes in the user-scope and system-scope Path.
356+
/// </summary>
357+
/// <param name="oldUserPath">The old value of the user-scope Path retrieved from registry.</param>
358+
/// <param name="oldSystemPath">The old value of the system-scope Path retrieved from registry.</param>
359+
private static void UpdateProcessEnvPath(string oldUserPath, string oldSystemPath)
360+
{
361+
string newUserEnvPath = Environment.GetEnvironmentVariable("Path", EnvironmentVariableTarget.User);
362+
string newSystemEnvPath = Environment.GetEnvironmentVariable("Path", EnvironmentVariableTarget.Machine);
363+
string procEnvPath = Environment.GetEnvironmentVariable("Path");
364+
365+
ReadOnlySpan<char> userPathChange = GetAddedPartOfString(oldUserPath, newUserEnvPath).Trim(';');
366+
ReadOnlySpan<char> systemPathChange = GetAddedPartOfString(oldSystemPath, newSystemEnvPath).Trim(';');
367+
368+
// Add 2 to account for the path separators we may need to add.
369+
int maxLength = procEnvPath.Length + userPathChange.Length + systemPathChange.Length + 2;
370+
StringBuilder newPath = null;
371+
372+
if (userPathChange.Length > 0)
373+
{
374+
CreateNewProcEnvPath(userPathChange);
375+
}
376+
377+
if (systemPathChange.Length > 0)
378+
{
379+
CreateNewProcEnvPath(systemPathChange);
380+
}
381+
382+
if (newPath is { Length: > 0 })
383+
{
384+
// Update the process env Path.
385+
Environment.SetEnvironmentVariable("Path", newPath.ToString());
386+
}
387+
388+
// Helper method to create a new env Path string.
389+
void CreateNewProcEnvPath(ReadOnlySpan<char> newChange)
390+
{
391+
newPath ??= new StringBuilder(procEnvPath, capacity: maxLength);
392+
393+
if (newPath.Length is 0 || newPath[^1] is ';')
394+
{
395+
newPath.Append(newChange);
396+
}
397+
else
398+
{
399+
newPath.Append(';').Append(newChange);
400+
}
401+
}
402+
}
403+
#endif
218404

219405
#region ctor/native command properties
220406

@@ -262,7 +448,11 @@ internal NativeCommandProcessor(ApplicationInfo applicationInfo, ExecutionContex
262448
// Create input writer for providing input to the process.
263449
_inputWriter = new ProcessInputWriter(Command);
264450

265-
_isTranscribing = this.Command.Context.EngineHostInterface.UI.IsTranscribing;
451+
_isTranscribing = context.EngineHostInterface.UI.IsTranscribing;
452+
453+
#if !UNIX
454+
_isPackageManager = IsKnownPackageManager(_applicationInfo.Name) && IsPathUpdateFeatureEnabled(context);
455+
#endif
266456
}
267457

268458
/// <summary>
@@ -418,7 +608,7 @@ internal override void ProcessRecord()
418608
/// <summary>
419609
/// Process object for the invoked application.
420610
/// </summary>
421-
private System.Diagnostics.Process _nativeProcess;
611+
private Process _nativeProcess;
422612

423613
/// <summary>
424614
/// This is used for writing input to the process.
@@ -560,6 +750,12 @@ private void InitNativeProcess()
560750
// must set UseShellExecute to false if we modify the environment block
561751
startInfo.UseShellExecute = false;
562752
}
753+
754+
if (_isPackageManager)
755+
{
756+
_originalUserEnvPath = Environment.GetEnvironmentVariable("Path", EnvironmentVariableTarget.User);
757+
_originalSystemEnvPath = Environment.GetEnvironmentVariable("Path", EnvironmentVariableTarget.Machine);
758+
}
563759
#endif
564760

565761
if (this.Command.Context.CurrentPipelineStopping)
@@ -898,6 +1094,13 @@ internal override void Complete()
8981094
ConsumeAvailableNativeProcessOutput(blocking: true);
8991095
_nativeProcess.WaitForExit();
9001096

1097+
#if !UNIX
1098+
if (_isPackageManager)
1099+
{
1100+
UpdateProcessEnvPath(_originalUserEnvPath, _originalSystemEnvPath);
1101+
}
1102+
#endif
1103+
9011104
// Capture screen output if we are transcribing and running stand alone
9021105
if (_isTranscribing && (s_supportScreenScrape == true) && _runStandAlone)
9031106
{
@@ -1717,6 +1920,7 @@ private bool IsExecutable(string path)
17171920
#region Minishell Interop
17181921

17191922
private bool _isMiniShell = false;
1923+
17201924
/// <summary>
17211925
/// Returns true if native command being invoked is mini-shell.
17221926
/// </summary>

src/System.Management.Automation/engine/Utils.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1539,14 +1539,14 @@ internal static string DisplayHumanReadableFileSize(long bytes)
15391539
/// <returns>True if the session is restricted.</returns>
15401540
internal static bool IsSessionRestricted(ExecutionContext context)
15411541
{
1542-
CmdletInfo cmdletInfo = context.SessionState.InvokeCommand.GetCmdlet("Microsoft.PowerShell.Core\\Import-Module");
1543-
// if import-module is visible, then the session is not restricted,
1544-
// because the user can load arbitrary code.
1545-
if (cmdletInfo != null && cmdletInfo.Visibility == SessionStateEntryVisibility.Public)
1546-
{
1547-
return false;
1548-
}
1549-
return true;
1542+
CmdletInfo cmdletInfo = context.SessionState.InvokeCommand.GetCmdlet("Microsoft.PowerShell.Core\\Import-Module");
1543+
// if import-module is visible, then the session is not restricted,
1544+
// because the user can load arbitrary code.
1545+
if (cmdletInfo != null && cmdletInfo.Visibility == SessionStateEntryVisibility.Public)
1546+
{
1547+
return false;
1548+
}
1549+
return true;
15501550
}
15511551
}
15521552
}

0 commit comments

Comments
 (0)