|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Build.Framework;
using Microsoft.Build.Shared;
#nullable disable
namespace Microsoft.Build.Utilities
{
/// <summary>
/// (1) Make sure values containing hyphens are quoted (RC at least requires this)
/// (2) Escape any embedded quotes.
/// -- Literal double quotes should be written in the form \" not ""
/// -- Backslashes falling just before doublequotes must be doubled.
/// -- Literal double quotes can only occur in pairs (you cannot pass a single literal double quote)
/// -- Functional double quotes (for example to handle spaces) are best put around both name and value
/// in switches like /Dname=value.
/// </summary>
/// <remarks>
///
/// Below are some quoting experiments, using the /D switch with the CL and RC preprocessor.
/// The /D switch is a little more tricky than most switches, because it has a name=value pair.
/// The table below contains what the preprocessor actually embeds when passed the switch in the
/// first column:
///
/// CL via cmd line CL via response file RC
/// /DFOO="A" A A
/// /D"FOO="A"" A A A
/// /DFOO=A A A
/// /D"FOO=A" A A
/// /DFOO=""A"" A A A
///
/// /DFOO=\"A\" "A" "A"
/// /DFOO="""A""" "A" broken "A"
/// /D"FOO=\"A\"" "A" "A"
/// /D"FOO=""A""" "A" "A"
///
/// /DFOO="A B" A B A B
/// /D"FOO=A B" A B A B
///
/// /D"FOO="A B"" broken
/// /DFOO=\"A B\" broken
/// /D"FOO=\"A B\"" "A B" "A B" "A B"
/// /D"FOO=""A B""" "A B" broken broken
///
/// From my experiments (with CL and RC only) it seems that
/// -- Literal double quotes are most reliably written in the form \" not ""
/// -- Backslashes falling just before doublequotes must be doubled.
/// -- Values containing literal double quotes must be quoted.
/// -- Literal double quotes can only occur in pairs (you cannot pass a single literal double quote)
/// -- For /Dname=value style switches, functional double quotes (for example to handle spaces) are best put around both
/// name and value (in other words, these kinds of switches don't need special treatment for their '=' signs).
/// -- Values containing hyphens should be quoted; RC requires this, and CL does not mind.
/// </remarks>
public class CommandLineBuilder
{
#region Constructors
/// <summary>
/// Default constructor
/// </summary>
public CommandLineBuilder()
{
}
/// <summary>
/// Default constructor
/// </summary>
public CommandLineBuilder(bool quoteHyphensOnCommandLine)
{
_quoteHyphens = quoteHyphensOnCommandLine;
}
/// <summary>
/// Default constructor
/// </summary>
public CommandLineBuilder(bool quoteHyphensOnCommandLine, bool useNewLineSeparator)
: this(quoteHyphensOnCommandLine)
{
_useNewLineSeparator = useNewLineSeparator;
}
#endregion
#region Properties
/// <summary>
/// Returns the length of the current command
/// </summary>
public int Length => CommandLine.Length;
/// <summary>
/// Retrieves the private StringBuilder instance for inheriting classes
/// </summary>
protected StringBuilder CommandLine { get; } = new StringBuilder();
#endregion
#region Basic methods
/// <summary>
/// Return the command-line as a string.
/// </summary>
/// <returns></returns>
public override string ToString() => CommandLine.ToString();
// Use if escaping of hyphens is supposed to take place
private const string s_allowedUnquotedRegexNoHyphen =
"^" // Beginning of line
+ @"[a-z\\/:0-9\._+=]*"
+ "$";
private const string s_definitelyNeedQuotesRegexWithHyphen = @"[|><\s,;\-""]+";
// Use if escaping of hyphens is not to take place
private const string s_allowedUnquotedRegexWithHyphen =
"^" // Beginning of line
+ @"[a-z\\/:0-9\._\-+=]*" // Allow hyphen to be unquoted
+ "$";
private const string s_definitelyNeedQuotesRegexNoHyphen = @"[|><\s,;""]+";
/// <summary>
/// Should hyphens be quoted or not
/// </summary>
private readonly bool _quoteHyphens;
/// <summary>
/// Should use new line separators instead of spaces to separate arguments.
/// </summary>
private readonly bool _useNewLineSeparator;
/// <summary>
/// Instead of defining which characters must be quoted, define
/// which characters we know its safe to not quote. This way leads
/// to more false-positives (which still work, but don't look as
/// nice coming out of the logger), but is less likely to leave a
/// security hole.
/// </summary>
private Regex _allowedUnquoted;
/// <summary>
/// Also, define the characters that we know for certain need quotes.
/// This is partly to document which characters we know can cause trouble
/// and partly as a sanity check against a bug creeping in.
/// </summary>
private Regex _definitelyNeedQuotes;
/// <summary>
/// Use a private property so that we can lazy initialize the regex
/// </summary>
private Regex DefinitelyNeedQuotes => _definitelyNeedQuotes
?? (_definitelyNeedQuotes = new Regex(_quoteHyphens ? s_definitelyNeedQuotesRegexWithHyphen : s_definitelyNeedQuotesRegexNoHyphen, RegexOptions.CultureInvariant));
/// <summary>
/// Use a private getter property to we can lazy initialize the regex
/// </summary>
private Regex AllowedUnquoted => _allowedUnquoted
?? (_allowedUnquoted = new Regex(_quoteHyphens ? s_allowedUnquotedRegexNoHyphen : s_allowedUnquotedRegexWithHyphen, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant));
/// <summary>
/// Checks the given switch parameter to see if it must/can be quoted.
/// </summary>
/// <param name="parameter">the string to examine for characters that require quoting</param>
/// <returns>true, if parameter should be quoted</returns>
protected virtual bool IsQuotingRequired(string parameter)
{
bool isQuotingRequired = false;
if (parameter != null)
{
#region Security Note: About cross-parameter injection
/*
If string parameters have whitespace in them, then a possible attack would
be like the following:
<Win32Icon>MyFile.ico /out:c:\windows\system32\notepad.exe</Win32Icon>
<Csc
Win32Icon="$(Win32Icon)"
...
/>
Since we just build up a command-line to pass into CSC.EXE, without quoting,
the project might overwrite notepad.exe.
If there are spaces in the parameter, then we must quote that parameter.
*/
#endregion
bool hasAllUnquotedCharacters = AllowedUnquoted.IsMatch(parameter);
bool hasSomeQuotedCharacters = DefinitelyNeedQuotes.IsMatch(parameter);
isQuotingRequired = !hasAllUnquotedCharacters;
isQuotingRequired = isQuotingRequired || hasSomeQuotedCharacters;
Debug.Assert(!hasAllUnquotedCharacters || !hasSomeQuotedCharacters,
"At least one of allowedUnquoted or definitelyNeedQuotes is wrong.");
}
return isQuotingRequired;
}
/// <summary>
/// Add a space or newline to the specified string if and only if it's not empty.
/// </summary>
/// <remarks>
/// This is a pretty obscure method and so it's only available to inherited classes.
/// </remarks>
protected void AppendSpaceIfNotEmpty()
{
if (CommandLine.Length != 0)
{
if (_useNewLineSeparator)
{
CommandLine.Append(Environment.NewLine);
}
else if (CommandLine[CommandLine.Length - 1] != ' ')
{
CommandLine.Append(' ');
}
}
}
#endregion
#region Methods for use in inherited classes, do not prepend a space before doing their thing
/// <summary>
/// Appends a string. Quotes are added if they are needed.
/// This method does not append a space to the command line before executing.
/// </summary>
/// <remarks>
/// Escapes any double quotes in the string.
/// </remarks>
/// <param name="textToAppend">The string to append</param>
protected void AppendTextWithQuoting(string textToAppend) => AppendQuotedTextToBuffer(CommandLine, textToAppend);
/// <summary>
/// Appends given text to the buffer after first quoting the text if necessary.
/// </summary>
/// <param name="buffer"></param>
/// <param name="unquotedTextToAppend"></param>
protected void AppendQuotedTextToBuffer(StringBuilder buffer, string unquotedTextToAppend)
{
ErrorUtilities.VerifyThrowArgumentNull(buffer);
if (unquotedTextToAppend != null)
{
bool addQuotes = IsQuotingRequired(unquotedTextToAppend);
if (addQuotes)
{
buffer.Append('"');
}
// Count the number of quotes
int literalQuotes = 0;
for (int i = 0; i < unquotedTextToAppend.Length; i++)
{
if (unquotedTextToAppend[i] == '"')
{
literalQuotes++;
}
}
if (literalQuotes > 0)
{
// Replace any \" sequences with \\"
unquotedTextToAppend = unquotedTextToAppend.Replace("\\\"", "\\\\\"");
// Now replace any " with \"
unquotedTextToAppend = unquotedTextToAppend.Replace("\"", "\\\"");
}
buffer.Append(unquotedTextToAppend);
// Be careful any trailing slash doesn't escape the quote we're about to add
if (addQuotes && unquotedTextToAppend.EndsWith("\\", StringComparison.Ordinal))
{
buffer.Append('\\');
}
if (addQuotes)
{
buffer.Append('"');
}
}
}
/// <summary>
/// Appends a string. No quotes are added.
/// This method does not append a space to the command line before executing.
/// </summary>
/// <example>
/// AppendTextUnquoted(@"Folder name\filename.cs") => "Folder name\\filename.cs"
/// </example>
/// <remarks>
/// In the future, this function may fixup 'textToAppend' to handle
/// literal embedded quotes.
/// </remarks>
/// <param name="textToAppend">The string to append</param>
public void AppendTextUnquoted(string textToAppend)
{
if (textToAppend != null)
{
CommandLine.Append(textToAppend);
}
}
/// <summary>
/// Appends a file name. Quotes are added if they are needed.
/// If the first character of the file name is a dash, ".\" is prepended to avoid confusing the file name with a switch
/// This method does not append a space to the command line before executing.
/// </summary>
/// <example>
/// AppendFileNameWithQuoting("-StrangeFileName.cs") => ".\-StrangeFileName.cs"
/// </example>
/// <remarks>
/// In the future, this function may fixup 'text' to handle
/// literal embedded quotes.
/// </remarks>
/// <param name="fileName">The file name to append</param>
protected void AppendFileNameWithQuoting(string fileName)
{
if (fileName != null)
{
// Don't let injection attackers escape from our quotes by sticking in
// their own quotes. Quotes are illegal.
VerifyThrowNoEmbeddedDoubleQuotes(string.Empty, fileName);
fileName = FileUtilities.FixFilePath(fileName);
if (fileName.Length != 0 && fileName[0] == '-')
{
AppendTextWithQuoting("." + Path.DirectorySeparatorChar + fileName);
}
else
{
AppendTextWithQuoting(fileName);
}
}
}
#endregion
#region Appending file names
/// <summary>
/// Appends a file name quoting it if necessary.
/// This method appends a space to the command line (if it's not currently empty) before the file name.
/// </summary>
/// <example>
/// AppendFileNameIfNotNull("-StrangeFileName.cs") => ".\-StrangeFileName.cs"
/// </example>
/// <param name="fileName">File name to append, if it's null this method has no effect</param>
public void AppendFileNameIfNotNull(string fileName)
{
if (fileName != null)
{
// Don't let injection attackers escape from our quotes by sticking in
// their own quotes. Quotes are illegal.
VerifyThrowNoEmbeddedDoubleQuotes(string.Empty, fileName);
AppendSpaceIfNotEmpty();
AppendFileNameWithQuoting(fileName);
}
}
/// <summary>
/// Appends a file name quoting it if necessary.
/// This method appends a space to the command line (if it's not currently empty) before the file name.
/// </summary>
/// <example>
/// See the string overload version
/// </example>
/// <param name="fileItem">File name to append, if it's null this method has no effect</param>
public void AppendFileNameIfNotNull(ITaskItem fileItem)
{
if (fileItem != null)
{
// Don't let injection attackers escape from our quotes by sticking in
// their own quotes. Quotes are illegal.
VerifyThrowNoEmbeddedDoubleQuotes(string.Empty, fileItem.ItemSpec);
AppendFileNameIfNotNull(fileItem.ItemSpec);
}
}
/// <summary>
/// Appends array of file name strings, quoting them if necessary, delimited by a delimiter.
/// This method appends a space to the command line (if it's not currently empty) before the file names.
/// </summary>
/// <example>
/// AppendFileNamesIfNotNull(new string[] {"Alpha.cs", "Beta.cs"}, ",") => "Alpha.cs,Beta.cs"
/// </example>
/// <param name="fileNames">File names to append, if it's null this method has no effect</param>
/// <param name="delimiter">The delimiter between file names</param>
public void AppendFileNamesIfNotNull(string[] fileNames, string delimiter)
{
ErrorUtilities.VerifyThrowArgumentNull(delimiter);
if (fileNames?.Length > 0)
{
// Don't let injection attackers escape from our quotes by sticking in
// their own quotes. Quotes are illegal.
for (int i = 0; i < fileNames.Length; ++i)
{
VerifyThrowNoEmbeddedDoubleQuotes(string.Empty, fileNames[i]);
}
AppendSpaceIfNotEmpty();
for (int i = 0; i < fileNames.Length; ++i)
{
if (i != 0)
{
AppendTextUnquoted(delimiter);
}
AppendFileNameWithQuoting(fileNames[i]);
}
}
}
/// <summary>
/// Appends array of ITaskItem specs as file names, quoting them if necessary, delimited by a delimiter.
/// This method appends a space to the command line (if it's not currently empty) before the file names.
/// </summary>
/// <example>
/// See the string[] overload version
/// </example>
/// <param name="fileItems">Task items to append, if null this method has no effect</param>
/// <param name="delimiter">Delimiter to put between items in the command line</param>
public void AppendFileNamesIfNotNull(ITaskItem[] fileItems, string delimiter)
{
ErrorUtilities.VerifyThrowArgumentNull(delimiter);
if (fileItems?.Length > 0)
{
// Don't let injection attackers escape from our quotes by sticking in
// their own quotes. Quotes are illegal.
for (int i = 0; i < fileItems.Length; ++i)
{
if (fileItems[i] != null)
{
VerifyThrowNoEmbeddedDoubleQuotes(string.Empty, fileItems[i].ItemSpec);
}
}
AppendSpaceIfNotEmpty();
for (int i = 0; i < fileItems.Length; ++i)
{
if (i != 0)
{
AppendTextUnquoted(delimiter);
}
if (fileItems[i] != null)
{
AppendFileNameWithQuoting(fileItems[i].ItemSpec);
}
}
}
}
#endregion
#region Appending switches with quoted parameters
/// <summary>
/// Appends a command-line switch that has no separate value, without any quoting.
/// This method appends a space to the command line (if it's not currently empty) before the switch.
/// </summary>
/// <example>
/// AppendSwitch("/utf8output") => "/utf8output"
/// </example>
/// <param name="switchName">The switch to append to the command line, may not be null</param>
public void AppendSwitch(string switchName)
{
ErrorUtilities.VerifyThrowArgumentNull(switchName);
AppendSpaceIfNotEmpty();
AppendTextUnquoted(switchName);
}
/// <summary>
/// Appends a command-line switch that takes a single string parameter, quoting the parameter if necessary.
/// This method appends a space to the command line (if it's not currently empty) before the switch.
/// </summary>
/// <example>
/// AppendSwitchIfNotNull("/source:", "File Name.cs") => "/source:\"File Name.cs\""
/// </example>
/// <param name="switchName">The switch to append to the command line, may not be null</param>
/// <param name="parameter">Switch parameter to append, quoted if necessary. If null, this method has no effect.</param>
public void AppendSwitchIfNotNull(string switchName, string parameter)
{
ErrorUtilities.VerifyThrowArgumentNull(switchName);
if (parameter != null)
{
// Now, stick the parameter in.
AppendSwitch(switchName);
AppendTextWithQuoting(parameter);
}
}
/// <summary>
/// Throws if the parameter has a double-quote in it. This is used to prevent parameter
/// injection. It's virtual so that tools can override this method if they want to have quotes escaped in filenames
/// </summary>
/// <param name="switchName">Switch name for error message</param>
/// <param name="parameter">Switch parameter to scan</param>
protected virtual void VerifyThrowNoEmbeddedDoubleQuotes(string switchName, string parameter)
{
if (parameter != null)
{
if (string.IsNullOrEmpty(switchName))
{
ErrorUtilities.VerifyThrowArgument(
-1 == parameter.IndexOf('"'),
"General.QuotesNotAllowedInThisKindOfTaskParameterNoSwitchName",
parameter);
}
else
{
ErrorUtilities.VerifyThrowArgument(
-1 == parameter.IndexOf('"'),
"General.QuotesNotAllowedInThisKindOfTaskParameter",
switchName,
parameter);
}
}
}
/// <summary>
/// Append a switch [overload]
/// This method appends a space to the command line (if it's not currently empty) before the switch.
/// </summary>
/// <example>
/// See the string overload version
/// </example>
/// <param name="switchName">The switch to append to the command line, may not be null</param>
/// <param name="parameter">Switch parameter to append, quoted if necessary. If null, this method has no effect.</param>
public void AppendSwitchIfNotNull(string switchName, ITaskItem parameter)
{
ErrorUtilities.VerifyThrowArgumentNull(switchName);
if (parameter != null)
{
AppendSwitchIfNotNull(switchName, parameter.ItemSpec);
}
}
/// <summary>
/// Appends a command-line switch that takes a string[] parameter,
/// and add double-quotes around the individual filenames if necessary.
/// This method appends a space to the command line (if it's not currently empty) before the switch.
/// </summary>
/// <example>
/// AppendSwitchIfNotNull("/sources:", new string[] {"Alpha.cs", "Be ta.cs"}, ";") => "/sources:Alpha.cs;\"Be ta.cs\""
/// </example>
/// <param name="switchName">The switch to append to the command line, may not be null</param>
/// <param name="parameters">Switch parameters to append, quoted if necessary. If null, this method has no effect.</param>
/// <param name="delimiter">Delimiter to put between individual parameters, may not be null (may be empty)</param>
public void AppendSwitchIfNotNull(string switchName, string[] parameters, string delimiter)
{
ErrorUtilities.VerifyThrowArgumentNull(switchName);
ErrorUtilities.VerifyThrowArgumentNull(delimiter);
if (parameters?.Length > 0)
{
AppendSwitch(switchName);
bool first = true;
foreach (string parameter in parameters)
{
if (!first)
{
AppendTextUnquoted(delimiter);
}
first = false;
AppendTextWithQuoting(parameter);
}
}
}
/// <summary>
/// Appends a command-line switch that takes a ITaskItem[] parameter,
/// and add double-quotes around the individual filenames if necessary.
/// This method appends a space to the command line (if it's not currently empty) before the switch.
/// </summary>
/// <example>
/// See the string[] overload version
/// </example>
/// <param name="switchName">The switch to append to the command line, may not be null</param>
/// <param name="parameters">Switch parameters to append, quoted if necessary. If null, this method has no effect.</param>
/// <param name="delimiter">Delimiter to put between individual parameters, may not be null (may be empty)</param>
public void AppendSwitchIfNotNull(string switchName, ITaskItem[] parameters, string delimiter)
{
ErrorUtilities.VerifyThrowArgumentNull(switchName);
ErrorUtilities.VerifyThrowArgumentNull(delimiter);
if (parameters?.Length > 0)
{
AppendSwitch(switchName);
bool first = true;
foreach (ITaskItem parameter in parameters)
{
if (!first)
{
AppendTextUnquoted(delimiter);
}
first = false;
if (parameter != null)
{
AppendTextWithQuoting(parameter.ItemSpec);
}
}
}
}
#endregion
#region Append switches with unquoted parameters
/// <summary>
/// Appends the literal parameter without trying to quote.
/// This method appends a space to the command line (if it's not currently empty) before the switch.
/// </summary>
/// <example>
/// AppendSwitchUnquotedIfNotNull("/source:", "File Name.cs") => "/source:File Name.cs"
/// </example>
/// <param name="switchName">The switch to append to the command line, may not be null</param>
/// <param name="parameter">Switch parameter to append, not quoted. If null, this method has no effect.</param>
public void AppendSwitchUnquotedIfNotNull(string switchName, string parameter)
{
ErrorUtilities.VerifyThrowArgumentNull(switchName);
if (parameter != null)
{
// Now, stick the parameter in.
AppendSwitch(switchName);
AppendTextUnquoted(parameter);
}
}
/// <summary>
/// Appends the literal parameter without trying to quote.
/// This method appends a space to the command line (if it's not currently empty) before the switch.
/// </summary>
/// <example>
/// See the string overload version
/// </example>
/// <param name="switchName">The switch to append to the command line, may not be null</param>
/// <param name="parameter">Switch parameter to append, not quoted. If null, this method has no effect.</param>
public void AppendSwitchUnquotedIfNotNull(string switchName, ITaskItem parameter)
{
ErrorUtilities.VerifyThrowArgumentNull(switchName);
if (parameter != null)
{
AppendSwitchUnquotedIfNotNull(switchName, parameter.ItemSpec);
}
}
/// <summary>
/// Appends a command-line switch that takes a string[] parameter, not quoting the individual parameters
/// This method appends a space to the command line (if it's not currently empty) before the switch.
/// </summary>
/// <example>
/// AppendSwitchUnquotedIfNotNull("/sources:", new string[] {"Alpha.cs", "Be ta.cs"}, ";") => "/sources:Alpha.cs;Be ta.cs"
/// </example>
/// <param name="switchName">The switch to append to the command line, may not be null</param>
/// <param name="parameters">Switch parameters to append, not quoted. If null, this method has no effect.</param>
/// <param name="delimiter">Delimiter to put between individual parameters, may not be null (may be empty)</param>
public void AppendSwitchUnquotedIfNotNull(string switchName, string[] parameters, string delimiter)
{
ErrorUtilities.VerifyThrowArgumentNull(switchName);
ErrorUtilities.VerifyThrowArgumentNull(delimiter);
if (parameters?.Length > 0)
{
AppendSwitch(switchName);
bool first = true;
foreach (string parameter in parameters)
{
if (!first)
{
AppendTextUnquoted(delimiter);
}
first = false;
AppendTextUnquoted(parameter);
}
}
}
/// <summary>
/// Appends a command-line switch that takes a ITaskItem[] parameter, not quoting the individual parameters
/// This method appends a space to the command line (if it's not currently empty) before the switch.
/// </summary>
/// <example>
/// See the string[] overload version
/// </example>
/// <param name="switchName">The switch to append to the command line, may not be null</param>
/// <param name="parameters">Switch parameters to append, not quoted. If null, this method has no effect.</param>
/// <param name="delimiter">Delimiter to put between individual parameters, may not be null (may be empty)</param>
public void AppendSwitchUnquotedIfNotNull(string switchName, ITaskItem[] parameters, string delimiter)
{
ErrorUtilities.VerifyThrowArgumentNull(switchName);
ErrorUtilities.VerifyThrowArgumentNull(delimiter);
if (parameters?.Length > 0)
{
AppendSwitch(switchName);
bool first = true;
foreach (ITaskItem parameter in parameters)
{
if (!first)
{
AppendTextUnquoted(delimiter);
}
first = false;
if (parameter != null)
{
AppendTextUnquoted(parameter.ItemSpec);
}
}
}
}
#endregion
}
}
|