// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Collections; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Management.Automation.Internal; using System.Text; using Microsoft.PowerShell.Commands; namespace System.Management.Automation { using Language; /// /// The parameter binder for native commands. /// internal class NativeCommandParameterBinder : ParameterBinderBase { #region ctor /// /// Constructs a NativeCommandParameterBinder. /// /// /// The NativeCommand to bind to. /// /// /// .Context is null /// internal NativeCommandParameterBinder( NativeCommand command) : base(command.MyInvocation, command.Context, command) { _nativeCommand = command; } #endregion ctor #region internal members #region Parameter binding /// /// Binds a parameter for a native command (application). /// /// /// The name of the parameter to bind the value to. For applications /// this just becomes another parameter... /// /// /// The value to bind to the parameter. It should be assumed by /// derived classes that the proper type coercion has already taken /// place and that any prerequisite metadata has been satisfied. /// /// internal override void BindParameter(string name, object value, CompiledCommandParameter parameterMetadata) { Diagnostics.Assert(false, "Unreachable code"); throw new NotSupportedException(); } internal override object GetDefaultParameterValue(string name) { return null; } internal void BindParameters(Collection parameters) { bool sawVerbatimArgumentMarker = false; bool first = true; foreach (CommandParameterInternal parameter in parameters) { if (!first) { _arguments.Append(' '); } first = false; if (parameter.ParameterNameSpecified) { Diagnostics.Assert(!parameter.ParameterText.Contains(' '), "Parameters cannot have whitespace"); PossiblyGlobArg(parameter.ParameterText, StringConstantType.BareWord); if (parameter.SpaceAfterParameter) { _arguments.Append(' '); } } if (parameter.ArgumentSpecified) { // If this is the verbatim argument marker, we don't pass it on to the native command. // We do need to remember it though - we'll expand environment variables in subsequent args. object argValue = parameter.ArgumentValue; if (string.Equals("--%", argValue as string, StringComparison.OrdinalIgnoreCase)) { sawVerbatimArgumentMarker = true; continue; } if (argValue != AutomationNull.Value && argValue != UnboundParameter.Value) { // ArrayLiteralAst is used to reconstruct the correct argument, e.g. // windbg -k com:port=\\devbox\pipe\debug,pipe,resets=0,reconnect // The parser produced an array of strings but marked the parameter so we // can properly reconstruct the correct command line. StringConstantType stringConstantType = StringConstantType.BareWord; ArrayLiteralAst arrayLiteralAst = null; switch (parameter?.ArgumentAst) { case StringConstantExpressionAst sce: stringConstantType = sce.StringConstantType; break; case ExpandableStringExpressionAst ese: stringConstantType = ese.StringConstantType; break; case ArrayLiteralAst ala: arrayLiteralAst = ala; break; } // Prior to PSNativePSPathResolution experimental feature, a single quote worked the same as a double quote // so if the feature is not enabled, we treat any quotes as double quotes. When this feature is no longer // experimental, this code here needs to be removed. if (!ExperimentalFeature.IsEnabled("PSNativePSPathResolution") && stringConstantType == StringConstantType.SingleQuoted) { stringConstantType = StringConstantType.DoubleQuoted; } AppendOneNativeArgument(Context, argValue, arrayLiteralAst, sawVerbatimArgumentMarker, stringConstantType); } } } } #endregion Parameter binding /// /// Gets the command arguments in string form. /// internal string Arguments { get { return _arguments.ToString(); } } private readonly StringBuilder _arguments = new StringBuilder(); #endregion internal members #region private members /// /// Stringize a non-IEnum argument to a native command, adding quotes /// and trailing spaces as appropriate. An array gets added as multiple arguments /// each of which will be stringized. /// /// Execution context instance. /// The object to append. /// If the argument was an array literal, the Ast, otherwise null. /// True if the argument occurs after --%. /// Bare, SingleQuoted, or DoubleQuoted. private void AppendOneNativeArgument(ExecutionContext context, object obj, ArrayLiteralAst argArrayAst, bool sawVerbatimArgumentMarker, StringConstantType stringConstantType) { IEnumerator list = LanguagePrimitives.GetEnumerator(obj); Diagnostics.Assert((argArrayAst == null) || obj is object[] && ((object[])obj).Length == argArrayAst.Elements.Count, "array argument and ArrayLiteralAst differ in number of elements"); int currentElement = -1; string separator = string.Empty; do { string arg; if (list == null) { arg = PSObject.ToStringParser(context, obj); } else { if (!ParserOps.MoveNext(context, null, list)) { break; } arg = PSObject.ToStringParser(context, ParserOps.Current(null, list)); currentElement += 1; if (currentElement != 0) { separator = GetEnumerableArgSeparator(argArrayAst, currentElement); } } if (!string.IsNullOrEmpty(arg)) { _arguments.Append(separator); if (sawVerbatimArgumentMarker) { arg = Environment.ExpandEnvironmentVariables(arg); _arguments.Append(arg); } else { // We need to add quotes if the argument has unquoted spaces. The // quotes could appear anywhere inside the string, not just at the start, // e.g. // $a = 'a"b c"d' // echoargs $a 'a"b c"d' a"b c"d // // The above should see 3 identical arguments in argv (the command line will // actually have quotes in different places, but the Win32 command line=>argv parser // erases those differences. // // We need to check quotes that the win32 argument parser checks which is currently // just the normal double quotes, no other special quotes. Also note that mismatched // quotes are supported if (NeedQuotes(arg)) { _arguments.Append('"'); if (stringConstantType == StringConstantType.DoubleQuoted) { _arguments.Append(ResolvePath(arg, Context)); } else { _arguments.Append(arg); } // need to escape all trailing backslashes so the native command receives it correctly // according to http://www.daviddeley.com/autohotkey/parameters/parameters.htm#WINCRULESDOC for (int i = arg.Length - 1; i >= 0 && arg[i] == '\\'; i--) { _arguments.Append('\\'); } _arguments.Append('"'); } else { PossiblyGlobArg(arg, stringConstantType); } } } } while (list != null); } /// /// On Windows, just append . /// On Unix, do globbing as appropriate, otherwise just append . /// /// The argument that possibly needs expansion. /// Bare, SingleQuoted, or DoubleQuoted. private void PossiblyGlobArg(string arg, StringConstantType stringConstantType) { var argExpanded = false; #if UNIX // On UNIX systems, we expand arguments containing wildcard expressions against // the file system just like bash, etc. if (stringConstantType == StringConstantType.BareWord) { if (WildcardPattern.ContainsWildcardCharacters(arg)) { // See if the current working directory is a filesystem provider location // We won't do the expansion if it isn't since native commands can only access the file system. var cwdinfo = Context.EngineSessionState.CurrentLocation; // If it's a filesystem location then expand the wildcards if (cwdinfo.Provider.Name.Equals(FileSystemProvider.ProviderName, StringComparison.OrdinalIgnoreCase)) { // On UNIX, paths starting with ~ or absolute paths are not normalized bool normalizePath = arg.Length == 0 || !(arg[0] == '~' || arg[0] == '/'); // See if there are any matching paths otherwise just add the pattern as the argument Collection paths = null; try { paths = Context.EngineSessionState.InvokeProvider.ChildItem.Get(arg, false); } catch { // Fallthrough will append the pattern unchanged. } // Expand paths, but only from the file system. if (paths?.Count > 0 && paths.All(p => p.BaseObject is FileSystemInfo)) { var sep = string.Empty; foreach (var path in paths) { _arguments.Append(sep); sep = " "; var expandedPath = (path.BaseObject as FileSystemInfo).FullName; if (normalizePath) { expandedPath = Context.SessionState.Path.NormalizeRelativePath(expandedPath, cwdinfo.ProviderPath); } // If the path contains spaces, then add quotes around it. if (NeedQuotes(expandedPath)) { _arguments.Append('"'); _arguments.Append(expandedPath); _arguments.Append('"'); } else { _arguments.Append(expandedPath); } argExpanded = true; } } } } else { // Even if there are no wildcards, we still need to possibly // expand ~ into the filesystem provider home directory path ProviderInfo fileSystemProvider = Context.EngineSessionState.GetSingleProvider(FileSystemProvider.ProviderName); string home = fileSystemProvider.Home; if (string.Equals(arg, "~")) { _arguments.Append(home); argExpanded = true; } else if (arg.StartsWith("~/", StringComparison.OrdinalIgnoreCase)) { var replacementString = home + arg.Substring(1); _arguments.Append(replacementString); argExpanded = true; } } } #endif // UNIX if (stringConstantType != StringConstantType.SingleQuoted) { arg = ResolvePath(arg, Context); } if (!argExpanded) { _arguments.Append(arg); } } /// /// Check if string is prefixed by psdrive, if so, expand it if filesystem path. /// /// The potential PSPath to resolve. /// The current ExecutionContext. /// Resolved PSPath if applicable otherwise the original path internal static string ResolvePath(string path, ExecutionContext context) { if (ExperimentalFeature.IsEnabled("PSNativePSPathResolution")) { #if !UNIX // on Windows, we need to expand ~ to point to user's home path if (string.Equals(path, "~", StringComparison.Ordinal) || path.StartsWith(TildeDirectorySeparator, StringComparison.Ordinal) || path.StartsWith(TildeAltDirectorySeparator, StringComparison.Ordinal)) { try { ProviderInfo fileSystemProvider = context.EngineSessionState.GetSingleProvider(FileSystemProvider.ProviderName); return new StringBuilder(fileSystemProvider.Home) .Append(path.Substring(1)) .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar) .ToString(); } catch { return path; } } // check if the driveName is an actual disk drive on Windows, if so, no expansion if (path.Length >= 2 && path[1] == ':') { foreach (var drive in DriveInfo.GetDrives()) { if (drive.Name.StartsWith(new string(path[0], 1), StringComparison.OrdinalIgnoreCase)) { return path; } } } #endif if (path.Contains(':')) { LocationGlobber globber = new LocationGlobber(context.SessionState); try { ProviderInfo providerInfo; // replace the argument with resolved path if it's a filesystem path string pspath = globber.GetProviderPath(path, out providerInfo); if (string.Equals(providerInfo.Name, FileSystemProvider.ProviderName, StringComparison.OrdinalIgnoreCase)) { path = pspath; } } catch { // if it's not a provider path, do nothing } } } return path; } /// /// Check to see if the string contains spaces and therefore must be quoted. /// /// The string to check for spaces. internal static bool NeedQuotes(string stringToCheck) { bool needQuotes = false, followingBackslash = false; int quoteCount = 0; for (int i = 0; i < stringToCheck.Length; i++) { if (stringToCheck[i] == '"' && !followingBackslash) { quoteCount += 1; } else if (char.IsWhiteSpace(stringToCheck[i]) && (quoteCount % 2 == 0)) { needQuotes = true; } followingBackslash = stringToCheck[i] == '\\'; } return needQuotes; } private static string GetEnumerableArgSeparator(ArrayLiteralAst arrayLiteralAst, int index) { if (arrayLiteralAst == null) return " "; // index points to the *next* element, so we're looking for space between // it and the previous element. var next = arrayLiteralAst.Elements[index]; var prev = arrayLiteralAst.Elements[index - 1]; var arrayExtent = arrayLiteralAst.Extent; var afterPrev = prev.Extent.EndOffset; var beforeNext = next.Extent.StartOffset - 1; if (afterPrev == beforeNext) return ","; var arrayText = arrayExtent.Text; afterPrev -= arrayExtent.StartOffset; beforeNext -= arrayExtent.StartOffset; if (arrayText[afterPrev] == ',') return ", "; if (arrayText[beforeNext] == ',') return " ,"; return " , "; } /// /// The native command to bind to. /// private readonly NativeCommand _nativeCommand; private static readonly string TildeDirectorySeparator = $"~{Path.DirectorySeparatorChar}"; private static readonly string TildeAltDirectorySeparator = $"~{Path.AltDirectorySeparatorChar}"; #endregion private members } }