Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Changed taskExec to use symfony process placeholders #1038

Open
wants to merge 5 commits into
base: 3.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 55 additions & 24 deletions src/Common/CommandArguments.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,51 @@ trait CommandArguments
protected $arguments = '';

/**
* Pass argument to executable. Its value will be automatically escaped.
* @var array
*/
protected $argumentsEnv = [];
Alenore marked this conversation as resolved.
Show resolved Hide resolved


/**
* Adds an argument and its placeholder value, to be executed
*
* @param string $argument
* @param string|null $key
* @param string|null $prefix
* @param string|null $separator
* @return void
*/
protected function addArgument($argument, $key = null, $prefix = null, $separator = ' ')
{
if (is_null($key)) {
$key = "arg" . md5($argument);
}

$this->arguments .= null == $prefix ? '' : $separator . $prefix;
if (!is_null($argument)) {
$this->arguments .= ' "${:' . $key . '}"';
$this->argumentsEnv[$key] = $argument;
}
}

/**
* Escape a command line argument.
*
* @param string $argument
* @return string
*
* @deprecated Use \Robo\Common\ProcessUtils::escapeArgument() instead.
*/
public static function escape($argument)
{
if (preg_match('/^[a-zA-Z0-9\/\.@~_-]+$/', $argument)) {
return $argument;
}
return ProcessUtils::escapeArgument($argument);
Alenore marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Pass argument to executable. It will be changed to a placeholder.
*
* @param string $arg
*
Expand All @@ -27,8 +71,8 @@ public function arg($arg)
}

/**
* Pass methods parameters as arguments to executable. Argument values
* are automatically escaped.
* Pass methods parameters as arguments to executable. Argument are
* automatically passed as placeholders
*
* @param string|string[] $args
*
Expand All @@ -40,7 +84,10 @@ public function args($args)
if (!is_array($args)) {
$args = $func_args;
}
$this->arguments .= ' ' . implode(' ', array_map('static::escape', $args));

foreach ($args as $arg) {
$this->addArgument($arg);
}
return $this;
}

Expand All @@ -58,25 +105,9 @@ public function rawArg($arg)
return $this;
}

/**
* Escape the provided value, unless it contains only alphanumeric
* plus a few other basic characters.
*
* @param string $value
*
* @return string
*/
public static function escape($value)
Alenore marked this conversation as resolved.
Show resolved Hide resolved
{
if (preg_match('/^[a-zA-Z0-9\/\.@~_-]+$/', $value)) {
return $value;
}
return ProcessUtils::escapeArgument($value);
}

/**
* Pass option to executable. Options are prefixed with `--` , value can be provided in second parameter.
* Option values are automatically escaped.
* Option values are automatically passed as placeholders.
*
* @param string $option
* @param string $value
Expand All @@ -89,15 +120,15 @@ public function option($option, $value = null, $separator = ' ')
if ($option !== null and strpos($option, '-') !== 0) {
$option = "--$option";
}
$this->arguments .= null == $option ? '' : " " . $option;
$this->arguments .= null == $value ? '' : $separator . static::escape($value);

$this->addArgument($value, null, $option, $separator);
return $this;
}

/**
* Pass multiple options to executable. The associative array contains
* the key:value pairs that become `--key value`, for each item in the array.
* Values are automatically escaped.
* Values are passed as placeholders.
*
* @param array $options
* @param string $separator
Expand Down
83 changes: 36 additions & 47 deletions src/Common/ProcessUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
use Symfony\Component\Process\Exception\InvalidArgumentException;

/**
* ProcessUtils is a bunch of utility methods. We want to allow Robo 1.x
* to work with Symfony 4.x while remaining backwards compatibility. This
* requires us to replace some deprecated functionality removed in Symfony.
* ProcessUtils is a bunch of utility methods.
* These methods are usually private, and are needed to execute and escape
* some functions for display purposes
*/
class ProcessUtils
{
Expand All @@ -24,58 +24,47 @@ private function __construct()
}

/**
* Escapes a string to be used as a shell argument.
*
* This method is a copy of a method that was deprecated by Symfony 3.3 and
* removed in Symfony 4; it will be removed once there is an actual
* replacement for escapeArgument.
*
* @param string $argument
* The argument that will be escaped.
* Symfony Process has a private method to replace Placeholders in command lines,
* which we use to rebuild a Command Description.
*
* @param string $commandline
* @param array $env
* @return string
* The escaped argument.
*/
public static function escapeArgument($argument)
public static function replacePlaceholders(string $commandline, array $env)
{
//Fix for PHP bug #43784 escapeshellarg removes % from given string
//Fix for PHP bug #49446 escapeshellarg doesn't work on Windows
//@see https://bugs.php.net/bug.php?id=43784
//@see https://bugs.php.net/bug.php?id=49446
if ('\\' === DIRECTORY_SEPARATOR) {
if ('' === $argument) {
return escapeshellarg($argument);
}

$escapedArgument = '';
$quote = false;
foreach (preg_split('/(")/', $argument, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE) as $part) {
if ('"' === $part) {
$escapedArgument .= '\\"';
} elseif (self::isSurroundedBy($part, '%')) {
// Avoid environment variable expansion
$escapedArgument .= '^%"' . substr($part, 1, -1) . '"^%';
} else {
// escape trailing backslash
if ('\\' === substr($part, -1)) {
$part .= '\\';
}
$quote = true;
$escapedArgument .= $part;
}
return preg_replace_callback('/"\$\{:([_a-zA-Z]++[_a-zA-Z0-9]*+)\}"/', function ($matches) use ($commandline, $env) {
if (!isset($env[$matches[1]]) || false === $env[$matches[1]]) {
throw new InvalidArgumentException(sprintf('Command line is missing a value for parameter "%s": ', $matches[1]).$commandline);
}
if ($quote) {
$escapedArgument = '"' . $escapedArgument . '"';
}

return $escapedArgument;
}

return "'" . str_replace("'", "'\\''", $argument) . "'";
return self::escapeArgument($env[$matches[1]]);
}, $commandline);
}

private static function isSurroundedBy($arg, $char)
/**
* Used by Symfony Process to form the final command line, with escaped parameters.
* Robo uses placeholders to handle the process arguments without escaping the whole string.
*
* @param string|null $argument
* @return string
*/
public static function escapeArgument(?string $argument): string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since escapeArgument is only used for display purposes in taskExec, maybe we should retain the existing implementation. The new version might have some benefits for Windows, but since it will only be used by code that calls escape directly, it would make an odd edge case if the changed version caused problems.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm undecided about this; if we don't update the implementation, then the printed string won't exactly match the string that Symfony is actually exec'ing, which could be odd. Then again, you could also get into that situation if you ended up with an older version of Symfony that has a different implementation of escape. I'm not sure if our Composer version constraints on our dependencies allows that, though.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Symfony Process is set at 4.4.11 min, and escapeArgument has been changed to this implementation since at least 4.1.7.
Besides, that method was set as deprecated and meant to be removed in Robo 2.0, with a E_USER_DEPRECATED triggered. I don't think anybody would be really surprised it finally gets changed.

{
return 2 < strlen($arg) && $char === $arg[0] && $char === $arg[strlen($arg) - 1];
if ('' === $argument || null === $argument) {
return '""';
}
if ('\\' !== \DIRECTORY_SEPARATOR) {
return "'".str_replace("'", "'\\''", $argument)."'";
}
if (false !== strpos($argument, "\0")) {
$argument = str_replace("\0", '?', $argument);
}
if (!preg_match('/[\/()%!^"<>&|\s]/', $argument)) {
return $argument;
}
$argument = preg_replace('/(\\\\+)$/', '$1$1', $argument);

return '"'.str_replace(['"', '^', '%', '!', "\n"], ['""', '"^^"', '"^%"', '"^!"', '!LF!'], $argument).'"';
}
}
6 changes: 4 additions & 2 deletions src/Task/Base/Exec.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Robo\Result;
use Robo\Common\CommandReceiver;
use Robo\Common\ExecOneCommand;
use Robo\Common\ProcessUtils;

/**
* Executes shell script. Closes it when running in background mode.
Expand Down Expand Up @@ -98,8 +99,9 @@ public function background($arg = true)
*/
protected function getCommandDescription()
{
return $this->getCommand();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I am wondering if this PR is in fact breaking, since you have changed the contract on getCommand(). This isn't just relevant for unit tests; Robo uses getCommand() for a number of things, and if this method starts returning a string that contains placeholder instead of a string with escaped arguments, then code that called getCommand and passed it to some process execution function would fail.

Would it work out to make getCommandDescription() continue to call / return getCommand(), have getCommand() call replacePlaceholders, and add a new method getCommandTemplate() that returns the command string with placeholders, and use that in Task\Exec::run()?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I've just tested outside of taskExec, and it does break on other commands : most command include a custom run() function, so placeholders don't get replaced.
Something I've noticed on other commands is that getCommand() is often used to display the command to be executed, when getCommandDescription() should be used instead.

In any case, there's more than Exec::run() that needs replacing, but every run() method that uses the ExecOnCommand trait, since they all get their arguments from CommandArguments and need to have their placeholders replaced.
As far as I know, only Exec has a custom process creation, every other calling the ExecCommand::executeCommand() method. It would simply be a matter of overwriting it in an ExecOneCommand::executeCommand() method that would handle placeholders.

So to sum things up :

  • Add a getCommandTemplate() method ;
  • Change ExecOneCommand::executeOneCommand() to replace placeholders.

Sounds good?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that sounds good.

return ProcessUtils::replacePlaceholders($this->getCommand(), $this->argumentsEnv);
}

/**
* {@inheritdoc}
*/
Expand Down Expand Up @@ -132,7 +134,7 @@ public function run()
{
$this->hideProgressIndicator();
// TODO: Symfony 4 requires that we supply the working directory.
$result_data = $this->execute(Process::fromShellCommandline($this->getCommand(), getcwd()));
$result_data = $this->execute(Process::fromShellCommandline($this->getCommand(), getcwd(), $this->argumentsEnv));
Alenore marked this conversation as resolved.
Show resolved Hide resolved
$result = new Result(
$this,
$result_data->getExitCode(),
Expand Down
6 changes: 6 additions & 0 deletions src/Task/Remote/Rsync.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Robo\Task\BaseTask;
use Robo\Exception\TaskException;
use Robo\Common\ExecOneCommand;
use Robo\Common\ProcessUtils;

/**
* Executes rsync in a flexible manner.
Expand Down Expand Up @@ -432,6 +433,11 @@ public function run()
return $this->executeCommand($command);
}

public function getCommandDescription()
{
return ProcessUtils::replacePlaceholders($this->getCommand(), $this->argumentsEnv);
}

/**
* Returns command that can be executed.
* This method is used to pass generated command from one task to another.
Expand Down
4 changes: 4 additions & 0 deletions tests/integration/ExecTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ public function testExecLsCommand()

public function testMultipleEnvVars()
{
if (strncasecmp(PHP_OS, 'WIN', 3) == 0) {
$this->markTestSkipped('Environment variables is not supported on Windows.');
}

$task = $this->taskExec('env')->interactive(false);
$task->env('FOO', 'BAR');
$task->env('BAR', 'BAZ');
Expand Down
41 changes: 8 additions & 33 deletions tests/phpunit/Common/CommandArgumentsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,58 +13,33 @@ class CommandArgumentsTest extends TestCase
public function casesArgs() {
return [
'no arguments' => [
' ',
' ',
'',
'',
[],
],
'empty string' => [
" ''",
' ""',
[''],
' ""',
[""],
],
'space' => [
" ' '",
' " "',
[' '],
],
'no escape - a' => [
" a",
" a",
['a'],
],
'no escape - A' => [
" A",
" A",
['A'],
],
'no escape - 0' => [
" 0",
" 0",
['0'],
],
'no escape - --' => [
" --",
" --",
['--'],
],
'no escape - @_~.' => [
" @_~.",
" @_~.",
['@_~.'],
],
'$' => [
" 'a\$b'",
' "a$b"',
' a$b',
['a$b'],
],
'*' => [
" 'a*b'",
' "a*b"',
' a*b',
['a*b'],
],
'multi' => [
" '' a '\$PATH'",
' "" a "$PATH"',
' "" \'a\' \'$PATH\'',
' "" a $PATH',
['', 'a', '$PATH'],
],
];
Expand Down
Loading