|
// 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.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Build.Framework;
using Microsoft.Build.Shared;
using Microsoft.Build.Shared.FileSystem;
using Microsoft.Build.Utilities;
#nullable disable
namespace Microsoft.Build.Tasks
{
/// <summary>
/// This class defines an "Exec" MSBuild task, which simply invokes the specified process with the specified arguments, waits
/// for it to complete, and then returns True if the process completed successfully, and False if an error occurred.
/// </summary>
// UNDONE: ToolTask has a "UseCommandProcessor" flag that duplicates much of the code in this class. Remove the duplication.
public class Exec : ToolTaskExtension
{
#region Constructors
/// <summary>
/// Default constructor.
/// </summary>
public Exec()
{
Command = string.Empty;
// Console-based output uses the current system OEM code page by default. Note that we should not use Console.OutputEncoding
// here since processes we run don't really have much to do with our console window (and also Console.OutputEncoding
// doesn't return the OEM code page if the running application that hosts MSBuild is not a console application).
// If the cmd file contains non-ANSI characters encoding may change.
_standardOutputEncoding = EncodingUtilities.CurrentSystemOemEncoding;
_standardErrorEncoding = EncodingUtilities.CurrentSystemOemEncoding;
}
#endregion
#region Fields
// Are the encodings for StdErr and StdOut streams valid
private bool _encodingParametersValid = true;
private string _workingDirectory;
private ITaskItem[] _outputs;
internal bool workingDirectoryIsUNC; // internal for unit testing
private string _batchFile;
private string _customErrorRegex;
private string _customWarningRegex;
private readonly List<ITaskItem> _nonEmptyOutput = new List<ITaskItem>();
private Encoding _standardErrorEncoding;
private Encoding _standardOutputEncoding;
private string _command;
// '^' before _any_ character escapes that character, don't escape it.
private static readonly char[] _charactersToEscape = { '(', ')', '=', ';', '!', ',', '&', ' ' };
#endregion
#region Properties
[Required]
public string Command
{
get => _command;
set
{
_command = value;
if (NativeMethodsShared.IsUnixLike)
{
_command = _command.Replace("\r\n", "\n");
}
}
}
public string WorkingDirectory { get; set; }
public bool IgnoreExitCode { get; set; }
/// <summary>
/// Enable the pipe of the standard out to an item (StandardOutput).
/// </summary>
/// <Remarks>
/// Even thought this is called a pipe, it is in fact a Tee. Use StandardOutputImportance to adjust the visibility of the stdout.
/// </Remarks>
public bool ConsoleToMSBuild { get; set; }
/// <summary>
/// Users can supply a regular expression that we should
/// use to spot error lines in the tool output. This is
/// useful for tools that produce unusually formatted output
/// </summary>
[StringSyntax(StringSyntaxAttribute.Regex)]
public string CustomErrorRegularExpression
{
get => _customErrorRegex;
set => _customErrorRegex = value;
}
/// <summary>
/// Users can supply a regular expression that we should
/// use to spot warning lines in the tool output. This is
/// useful for tools that produce unusually formatted output
/// </summary>
[StringSyntax(StringSyntaxAttribute.Regex)]
public string CustomWarningRegularExpression
{
get => _customWarningRegex;
set => _customWarningRegex = value;
}
/// <summary>
/// Whether to use pick out lines in the output that match
/// the standard error/warning format, and log them as errors/warnings.
/// Defaults to false.
/// </summary>
public bool IgnoreStandardErrorWarningFormat { get; set; }
/// <summary>
/// Property specifying the encoding of the captured task standard output stream
/// </summary>
protected override Encoding StandardOutputEncoding => _standardOutputEncoding;
/// <summary>
/// Property specifying the encoding of the captured task standard error stream
/// </summary>
protected override Encoding StandardErrorEncoding => _standardErrorEncoding;
/// <summary>
/// Project visible property specifying the encoding of the captured task standard output stream
/// </summary>
[Output]
public string StdOutEncoding
{
get => StandardOutputEncoding.EncodingName;
set
{
try
{
_standardOutputEncoding = Encoding.GetEncoding(value);
}
catch (ArgumentException)
{
Log.LogErrorWithCodeFromResources("General.InvalidValue", "StdOutEncoding", "Exec");
_encodingParametersValid = false;
}
}
}
/// <summary>
/// Project visible property specifying the encoding of the captured task standard error stream
/// </summary>
[Output]
public string StdErrEncoding
{
get => StandardErrorEncoding.EncodingName;
set
{
try
{
_standardErrorEncoding = Encoding.GetEncoding(value);
}
catch (ArgumentException)
{
Log.LogErrorWithCodeFromResources("General.InvalidValue", "StdErrEncoding", "Exec");
_encodingParametersValid = false;
}
}
}
[Output]
public ITaskItem[] Outputs
{
get => _outputs ?? Array.Empty<ITaskItem>();
set => _outputs = value;
}
/// <summary>
/// Returns the output as an Item. Whitespace are trimmed.
/// ConsoleOutput is enabled when ConsoleToMSBuild is true. This avoids holding lines in memory
/// if they aren't used. ConsoleOutput is a combination of stdout and stderr.
/// </summary>
[Output]
public ITaskItem[] ConsoleOutput => !ConsoleToMSBuild ? Array.Empty<ITaskItem>() : _nonEmptyOutput.ToArray();
#endregion
#region Methods
/// <summary>
/// Write out a temporary batch file with the user-specified command in it.
/// </summary>
private void CreateTemporaryBatchFile()
{
var encoding = EncodingUtilities.BatchFileEncoding(Command + WorkingDirectory, UseUtf8Encoding);
// Temporary file with the extension .Exec.bat
_batchFile = FileUtilities.GetTemporaryFileName(".exec.cmd");
// UNICODE Batch files are not allowed as of WinXP. We can't use normal ANSI code pages either,
// since console-related apps use OEM code pages "for historical reasons". Sigh.
// We need to get the current OEM code page which will be the same language as the current ANSI code page,
// just the OEM version.
// See http://www.microsoft.com/globaldev/getWR/steps/wrg_codepage.mspx for a discussion of ANSI vs OEM
// Note: 8/12/15 - Switched to use UTF8 on OS newer than 6.1 (Windows 7)
// Note: 1/12/16 - Only use UTF8 when we detect we need to or the user specifies 'Always'
using (StreamWriter sw = FileUtilities.OpenWrite(_batchFile, false, encoding))
{
if (!NativeMethodsShared.IsUnixLike)
{
// In some wierd setups, users may have set an env var actually called "errorlevel"
// this would cause our "exit %errorlevel%" to return false.
// This is because the actual errorlevel value is not an environment variable, but some commands,
// such as "exit %errorlevel%" will use the environment variable with that name if it exists, instead
// of the actual errorlevel value. So we must temporarily reset errorlevel locally first.
sw.WriteLine("setlocal");
// One more wrinkle.
// "set foo=" has odd behavior: it sets errorlevel to 1 if there was no environment variable named
// "foo" defined.
// This has the effect of making "set errorlevel=" set an errorlevel of 1 if an environment
// variable named "errorlevel" didn't already exist!
// To avoid this problem, set errorlevel locally to a dummy value first.
sw.WriteLine("set errorlevel=dummy");
sw.WriteLine("set errorlevel=");
// We may need to change the code page and console encoding.
if (encoding.CodePage != EncodingUtilities.CurrentSystemOemEncoding.CodePage)
{
// Output to nul so we don't change output and logs.
sw.WriteLine($@"%SystemRoot%\System32\chcp.com {encoding.CodePage}>nul");
// Ensure that the console encoding is correct.
_standardOutputEncoding = encoding;
_standardErrorEncoding = encoding;
}
// if the working directory is a UNC path, bracket the exec command with pushd and popd, because pushd
// automatically maps the network path to a drive letter, and then popd disconnects it.
// This is required because Cmd.exe does not support UNC names as the current directory:
// https://support.microsoft.com/en-us/kb/156276
if (workingDirectoryIsUNC)
{
sw.WriteLine("pushd " + _workingDirectory);
}
}
else
{
// Use sh rather than bash, as not all 'nix systems necessarily have Bash installed
sw.WriteLine("#!/bin/sh");
}
sw.WriteLine(Command);
if (!NativeMethodsShared.IsUnixLike)
{
if (workingDirectoryIsUNC)
{
sw.WriteLine("popd");
}
// NOTES:
// 1) there's a bug in the Process class where the exit code is not returned properly i.e. if the command
// fails with exit code 9009, Process.ExitCode returns 1 -- the statement below forces it to return the
// correct exit code
// 2) also because of another (or perhaps the same) bug in the Process class, when we use pushd/popd for a
// UNC path, even if the command fails, the exit code comes back as 0 (seemingly reflecting the success
// of popd) -- the statement below fixes that too
// 3) the above described behaviour is most likely bugs in the Process class because batch files in a
// console window do not hide or change the exit code a.k.a. errorlevel, esp. since the popd command is
// a no-fail command, and it never changes the previous errorlevel
sw.WriteLine("exit %errorlevel%");
}
}
}
#endregion
#region Overridden methods
/// <summary>
/// Executes cmd.exe and waits for it to complete
/// </summary>
/// <remarks>
/// Overridden to clean up the batch file afterwards.
/// </remarks>
/// <returns>Upon completion of the process, returns True if successful, False if not.</returns>
protected override int ExecuteTool(string pathToTool, string responseFileCommands, string commandLineCommands)
{
try
{
return base.ExecuteTool(pathToTool, responseFileCommands, commandLineCommands);
}
finally
{
DeleteTempFile(_batchFile);
}
}
/// <summary>
/// Allows tool to handle the return code.
/// This method will only be called with non-zero exitCode set to true.
/// </summary>
/// <remarks>
/// Overridden to make sure we display the command we put in the batch file, not the cmd.exe command
/// used to run the batch file.
/// </remarks>
protected override bool HandleTaskExecutionErrors()
{
if (IgnoreExitCode)
{
// Don't log when EchoOff and IgnoreExitCode.
if (!EchoOff)
{
Log.LogMessageFromResources(MessageImportance.Normal, "Exec.CommandFailedNoErrorCode", Command, ExitCode);
}
return true;
}
// Don't emit expanded form of Command when EchoOff is set.
string commandForLog = EchoOff ? "..." : Command;
if (ExitCode == NativeMethods.SE_ERR_ACCESSDENIED)
{
Log.LogErrorWithCodeFromResources("Exec.CommandFailedAccessDenied", commandForLog, ExitCode);
}
else
{
Log.LogErrorWithCodeFromResources("Exec.CommandFailed", commandForLog, ExitCode);
}
return false;
}
/// <summary>
/// Logs the tool name and the path from where it is being run.
/// </summary>
/// <remarks>
/// Overridden to avoid logging the path to "cmd.exe", which is not interesting.
/// </remarks>
protected override void LogPathToTool(string toolName, string pathToTool)
{
// Do nothing
}
/// <summary>
/// Logs the command to be executed.
/// </summary>
/// <remarks>
/// Overridden to log the batch file command instead of the cmd.exe command.
/// </remarks>
/// <param name="message"></param>
protected override void LogToolCommand(string message)
{
// Dont print the command line if Echo is Off.
if (!EchoOff)
{
base.LogToolCommand(Command);
}
}
/// <summary>
/// Calls a method on the TaskLoggingHelper to parse a single line of text to
/// see if there are any errors or warnings in canonical format.
/// </summary>
/// <remarks>
/// Overridden to handle any custom regular expressions supplied.
/// </remarks>
protected override void LogEventsFromTextOutput(string singleLine, MessageImportance messageImportance)
{
if (OutputMatchesRegex(singleLine, ref _customErrorRegex))
{
Log.LogError(singleLine);
}
else if (OutputMatchesRegex(singleLine, ref _customWarningRegex))
{
Log.LogWarning(singleLine);
}
else if (IgnoreStandardErrorWarningFormat)
{
// Not detecting regular format errors and warnings, and it didn't
// match any regexes either -- log as a regular message
Log.LogMessage(messageImportance, singleLine, null);
}
else
{
// This is the normal code path: match standard format errors and warnings
Log.LogMessageFromText(singleLine, messageImportance);
}
if (ConsoleToMSBuild)
{
string trimmedTextLine = ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave17_10) ?
singleLine.TrimEnd() :
singleLine.Trim();
if (trimmedTextLine.Length > 0)
{
// The lines read may be unescaped, so we need to escape them
// before passing them to the TaskItem.
_nonEmptyOutput.Add(new TaskItem(EscapingUtilities.Escape(trimmedTextLine)));
}
}
}
/// <summary>
/// Returns true if the string is matched by the regular expression.
/// If the regular expression is invalid, logs an error, then clears it out to
/// prevent more errors.
/// </summary>
private bool OutputMatchesRegex(string singleLine, ref string regularExpression)
{
if (regularExpression == null)
{
return false;
}
bool match = false;
try
{
match = Regex.IsMatch(singleLine, regularExpression);
}
catch (ArgumentException ex)
{
Log.LogErrorWithCodeFromResources("Exec.InvalidRegex", regularExpression, ex.Message);
// Clear out the regex so there won't be any more errors; let the tool continue,
// then it will fail because of the error we just logged
regularExpression = null;
}
return match;
}
/// <summary>
/// Validate the task arguments, log any warnings/errors
/// </summary>
/// <returns>true if arguments are corrent enough to continue processing, false otherwise</returns>
protected override bool ValidateParameters()
{
// If either of the encoding parameters passed to the task were
// invalid, then we should report that fact back to tooltask
if (!_encodingParametersValid)
{
return false;
}
// Make sure that at least the Command property was set
if (Command.Trim().Length == 0)
{
Log.LogErrorWithCodeFromResources("Exec.MissingCommandError");
return false;
}
// determine what the working directory for the exec command is going to be -- if the user specified a working
// directory use that, otherwise it's the current directory
_workingDirectory = !string.IsNullOrEmpty(WorkingDirectory)
? WorkingDirectory
: Directory.GetCurrentDirectory();
// check if the working directory we're going to use for the exec command is a UNC path
workingDirectoryIsUNC = FileUtilitiesRegex.StartsWithUncPattern(_workingDirectory);
// if the working directory is a UNC path, and all drive letters are mapped, bail out, because the pushd command
// will not be able to auto-map to the UNC path
if (workingDirectoryIsUNC && NativeMethods.AllDrivesMapped())
{
Log.LogErrorWithCodeFromResources("Exec.AllDriveLettersMappedError", _workingDirectory);
return false;
}
return true;
}
/// <summary>
/// Accessor for ValidateParameters purely for unit-test use
/// </summary>
/// <returns></returns>
internal bool ValidateParametersAccessor()
{
return ValidateParameters();
}
/// <summary>
/// Determining the path to cmd.exe
/// </summary>
/// <returns>path to cmd.exe</returns>
protected override string GenerateFullPathToTool()
{
return CommandProcessorPath.Value;
}
private static readonly Lazy<string> CommandProcessorPath = new Lazy<string>(() =>
{
// Get the fully qualified path to cmd.exe
if (NativeMethodsShared.IsWindows)
{
var systemCmd = ToolLocationHelper.GetPathToSystemFile("cmd.exe");
#if WORKAROUND_COREFX_19110
// Work around https://github.com/dotnet/msbuild/issues/2273 and
// https://github.com/dotnet/corefx/issues/19110, which result in
// a bad path being returned above on Nano Server SKUs of Windows.
if (!FileSystems.Default.FileExists(systemCmd))
{
return Environment.GetEnvironmentVariable("ComSpec");
}
#endif
return systemCmd;
}
else
{
return "sh";
}
});
/// <summary>
/// Gets the working directory to use for the process. Should return null if ToolTask should use the
/// current directory.
/// May throw an IOException if the directory to be used is somehow invalid.
/// </summary>
/// <returns>working directory</returns>
protected override string GetWorkingDirectory()
{
// If the working directory is UNC, we're going to use "pushd" in the batch file to set it.
// If it's invalid, pushd won't fail: it will just go ahead and use the system folder.
// So verify it's valid here.
if (!FileSystems.Default.DirectoryExists(_workingDirectory))
{
throw new DirectoryNotFoundException(ResourceUtilities.FormatResourceStringStripCodeAndKeyword("Exec.InvalidWorkingDirectory", _workingDirectory));
}
if (workingDirectoryIsUNC)
{
// if the working directory for the exec command is UNC, set the process working directory to the system path
// so that it doesn't display this silly error message:
// '\\<server>\<share>'
// CMD.EXE was started with the above path as the current directory.
// UNC paths are not supported. Defaulting to Windows directory.
return ToolLocationHelper.PathToSystem;
}
else
{
return _workingDirectory;
}
}
/// <summary>
/// Accessor for GetWorkingDirectory purely for unit-test use
/// </summary>
/// <returns></returns>
internal string GetWorkingDirectoryAccessor()
{
return GetWorkingDirectory();
}
/// <summary>
/// Adds the arguments for cmd.exe
/// </summary>
/// <param name="commandLine">command line builder class to add arguments to</param>
protected internal override void AddCommandLineCommands(CommandLineBuilderExtension commandLine)
{
// Create the batch file now,
// so we have the file name for the cmd.exe command line
CreateTemporaryBatchFile();
string batchFileForCommandLine = _batchFile;
// Unix consoles cannot have their encodings changed in place (like chcp on windows).
// Instead, unix scripts receive encoding information via environment variables before invocation.
// In consequence, encoding setup has to be performed outside the script, not inside it.
if (NativeMethodsShared.IsUnixLike)
{
commandLine.AppendSwitch("-c");
commandLine.AppendTextUnquoted(" \"");
bool setLocale = !ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave17_10);
if (setLocale)
{
commandLine.AppendTextUnquoted("export LANG=en_US.UTF-8; export LC_ALL=en_US.UTF-8; ");
}
commandLine.AppendTextUnquoted(". ");
commandLine.AppendFileNameIfNotNull(batchFileForCommandLine);
commandLine.AppendTextUnquoted("\"");
}
else
{
if (NativeMethodsShared.IsWindows)
{
commandLine.AppendSwitch("/Q"); // echo off
if (!Traits.Instance.EscapeHatches.UseAutoRunWhenLaunchingProcessUnderCmd)
{
commandLine.AppendSwitch("/D"); // do not load AutoRun configuration from the registry (perf)
}
commandLine.AppendSwitch("/C"); // run then terminate
StringBuilder fileName = null;
// Escape special characters that need to be escaped.
for (int i = 0; i < batchFileForCommandLine.Length; i++)
{
char c = batchFileForCommandLine[i];
if (ShouldEscapeCharacter(c) && (i == 0 || batchFileForCommandLine[i - 1] != '^'))
{
// Avoid allocating a new string until we know we have something to escape.
if (fileName == null)
{
fileName = StringBuilderCache.Acquire(batchFileForCommandLine.Length);
fileName.Append(batchFileForCommandLine, 0, i);
}
fileName.Append('^');
}
fileName?.Append(c);
}
if (fileName != null)
{
batchFileForCommandLine = StringBuilderCache.GetStringAndRelease(fileName);
}
}
commandLine.AppendFileNameIfNotNull(batchFileForCommandLine);
}
}
private bool ShouldEscapeCharacter(char c)
{
for (int i = 0; i < _charactersToEscape.Length; i++)
{
if (c == _charactersToEscape[i])
{
return true;
}
}
return false;
}
#endregion
#region Overridden properties
/// <summary>
/// The name of the tool to execute
/// </summary>
protected override string ToolName => NativeMethodsShared.IsWindows ? "cmd.exe" : "sh";
/// <summary>
/// Importance with which to log ordinary messages in the
/// standard error stream.
/// </summary>
protected override MessageImportance StandardErrorLoggingImportance => MessageImportance.High;
/// <summary>
/// Importance with which to log ordinary messages in the
/// standard out stream.
/// </summary>
/// <remarks>
/// Overridden to increase from the default "Low" up to "High".
/// </remarks>
protected override MessageImportance StandardOutputLoggingImportance => MessageImportance.High;
#endregion
}
}
|