diff --git a/src/future/exec/__tests__/ExecFutureTestCase.php b/src/future/exec/__tests__/ExecFutureTestCase.php index 572c5692..690ccee0 100644 --- a/src/future/exec/__tests__/ExecFutureTestCase.php +++ b/src/future/exec/__tests__/ExecFutureTestCase.php @@ -1,173 +1,220 @@ newCat() ->write('') ->resolvex(); $this->assertEqual('', $stdout); } private function newCat() { $bin = $this->getSupportExecutable('cat'); return new ExecFuture('php -f %R', $bin); } private function newSleep($duration) { $bin = $this->getSupportExecutable('sleep'); return new ExecFuture('php -f %R -- %s', $bin, $duration); } public function testKeepPipe() { // NOTE: This is mostly testing the semantics of $keep_pipe in write(). list($stdout) = $this->newCat() ->write('', true) ->start() ->write('x', true) ->write('y', true) ->write('z', false) ->resolvex(); $this->assertEqual('xyz', $stdout); } public function testLargeBuffer() { // NOTE: This is mostly a coverage test to hit branches where we're still // flushing a buffer. $data = str_repeat('x', 1024 * 1024 * 4); list($stdout) = $this->newCat()->write($data)->resolvex(); $this->assertEqual($data, $stdout); } public function testBufferLimit() { $data = str_repeat('x', 1024 * 1024); list($stdout) = $this->newCat() ->setStdoutSizeLimit(1024) ->write($data) ->resolvex(); $this->assertEqual(substr($data, 0, 1024), $stdout); } public function testResolveTimeoutTestShouldRunLessThan1Sec() { // NOTE: This tests interactions between the resolve() timeout and the // resolution timeout, which are somewhat similar but not identical. $future = $this->newSleep(32000); $future->setTimeout(32000); // We expect this to return in 0.01s. $iterator = (new FutureIterator(array($future))) ->setUpdateInterval(0.01); foreach ($iterator as $resolved_result) { $result = $resolved_result; break; } $this->assertEqual($result, null); // We expect this to now force the time out / kill immediately. If we don't // do this, we'll hang when exiting until our subprocess exits (32000 // seconds!) $future->setTimeout(0.01); $iterator->resolveAll(); } public function testTerminateWithoutStart() { // We never start this future, but it should be fine to kill a future from // any state. $future = $this->newSleep(1); $future->resolveKill(); $this->assertTrue(true); } public function testTimeoutTestShouldRunLessThan1Sec() { // NOTE: This is partly testing that we choose appropriate select wait // times; this test should run for significantly less than 1 second. $future = $this->newSleep(32000); list($err) = $future->setTimeout(0.01)->resolve(); $this->assertTrue($err > 0); $this->assertTrue($future->getWasKilledByTimeout()); } public function testMultipleTimeoutsTestShouldRunLessThan1Sec() { $futures = array(); for ($ii = 0; $ii < 4; $ii++) { $futures[] = $this->newSleep(32000)->setTimeout(0.01); } foreach (new FutureIterator($futures) as $future) { list($err) = $future->resolve(); $this->assertTrue($err > 0); $this->assertTrue($future->getWasKilledByTimeout()); } } public function testMultipleResolves() { // It should be safe to call resolve(), resolvex(), resolveKill(), etc., // as many times as you want on the same process. $bin = $this->getSupportExecutable('echo'); $future = new ExecFuture('php -f %R -- quack', $bin); $future->resolve(); $future->resolvex(); list($err) = $future->resolveKill(); $this->assertEqual(0, $err); } + public function testEscaping() { + $inputs = array( + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + '!@#$%^&*()-=_+`~,.<>/?[]{};\':"|', + '', + ' ', + 'x y', + '%PATH%', + '"', + '""', + 'a "b" c', + '\\', + '\\a', + 'a\\', + '\\a\\', + 'a\\a', + '\\\\', + '\\"', + ); + + $bin = $this->getSupportExecutable('echo'); + + foreach ($inputs as $input) { + if (!is_array($input)) { + $input = array($input); + } + + list($stdout) = execx( + 'php -f %R -- %Ls', + $bin, + $input); + + $stdout = explode("\n", $stdout); + $output = array(); + foreach ($stdout as $line) { + $output[] = stripcslashes($line); + } + + $this->assertEqual( + $input, + $output, + pht( + 'Arguments are preserved for input: %s', + implode(' ', $input))); + } + } + public function testReadBuffering() { $str_len_8 = 'abcdefgh'; $str_len_4 = 'abcd'; // This is a write/read with no read buffer. $future = $this->newCat(); $future->write($str_len_8); do { $future->isReady(); list($read) = $future->read(); if (strlen($read)) { break; } } while (true); // We expect to get the entire string back in the read. $this->assertEqual($str_len_8, $read); $future->resolve(); // This is a write/read with a read buffer. $future = $this->newCat(); $future->write($str_len_8); // Set the read buffer size. $future->setReadBufferSize(4); do { $future->isReady(); list($read) = $future->read(); if (strlen($read)) { break; } } while (true); // We expect to get the entire string back in the read. $this->assertEqual($str_len_4, $read); // Remove the limit so we can resolve the future. $future->setReadBufferSize(null); $future->resolve(); } } diff --git a/src/toolset/workflow/ArcanistVersionWorkflow.php b/src/toolset/workflow/ArcanistVersionWorkflow.php index eafca523..fae0e607 100644 --- a/src/toolset/workflow/ArcanistVersionWorkflow.php +++ b/src/toolset/workflow/ArcanistVersionWorkflow.php @@ -1,87 +1,85 @@ newWorkflowInformation() ->setSynopsis(pht('Show toolset version information.')) ->addExample(pht('**version**')) ->setHelp($help); } public function getWorkflowArguments() { return array(); } public function runWorkflow() { // TOOLSETS: Show the toolset version, not just the "arc" version. $console = PhutilConsole::getConsole(); if (!Filesystem::binaryExists('git')) { throw new ArcanistUsageException( pht( 'Cannot display current version without "%s" installed.', 'git')); } $roots = array( 'arcanist' => dirname(phutil_get_library_root('arcanist')), ); foreach ($roots as $lib => $root) { $is_git = false; $working_copy = ArcanistWorkingCopy::newFromWorkingDirectory($root); if ($working_copy) { $repository_api = $working_copy->newRepositoryAPI(); if ($repository_api instanceof ArcanistGitAPI) { $is_git = true; } } if (!$is_git) { throw new PhutilArgumentUsageException( pht( 'Library "%s" (at "%s") is not a Git working copy, so no version '. 'information can be provided.', $lib, Filesystem::readablePath($root))); } - // NOTE: Carefully execute these commands in a way that works on Windows - // until T8298 is properly fixed. See PHI52. - - list($commit) = $repository_api->execxLocal('log -1 --format=%%H'); + list($commit) = $repository_api->execxLocal( + 'log -1 --format=%s', + '%ct%x01%H'); $commit = trim($commit); - list($timestamp) = $repository_api->execxLocal('log -1 --format=%%ct'); - $timestamp = trim($timestamp); + list($timestamp, $commit) = explode("\1", $commit); $console->writeOut( "%s %s (%s)\n", $lib, $commit, date('j M Y', (int)$timestamp)); } } } diff --git a/src/xsprintf/PhutilCommandString.php b/src/xsprintf/PhutilCommandString.php index 85eb673a..671600ed 100644 --- a/src/xsprintf/PhutilCommandString.php +++ b/src/xsprintf/PhutilCommandString.php @@ -1,85 +1,171 @@ argv = $argv; $this->escapingMode = self::MODE_DEFAULT; // This makes sure we throw immediately if there are errors in the // parameters. $this->getMaskedString(); } public function __toString() { return $this->getMaskedString(); } public function getUnmaskedString() { return $this->renderString(true); } public function getMaskedString() { return $this->renderString(false); } public function setEscapingMode($escaping_mode) { $this->escapingMode = $escaping_mode; return $this; } private function renderString($unmasked) { return xsprintf( 'xsprintf_command', array( 'unmasked' => $unmasked, 'mode' => $this->escapingMode, ), $this->argv); } public static function escapeArgument($value, $mode) { + if ($mode === self::MODE_DEFAULT) { + if (phutil_is_windows()) { + $mode = self::MODE_WINDOWS; + } else { + $mode = self::MODE_LINUX; + } + } + switch ($mode) { - case self::MODE_DEFAULT: - return escapeshellarg($value); + case self::MODE_LINUX: + return self::escapeLinux($value); + case self::MODE_WINDOWS: + return self::escapeWindows($value); case self::MODE_POWERSHELL: return self::escapePowershell($value); default: throw new Exception(pht('Unknown escaping mode!')); } } private static function escapePowershell($value) { // These escape sequences are from http://ss64.com/ps/syntax-esc.html // Replace backticks first. $value = str_replace('`', '``', $value); // Now replace other required notations. $value = str_replace("\0", '`0', $value); $value = str_replace(chr(7), '`a', $value); $value = str_replace(chr(8), '`b', $value); $value = str_replace("\f", '`f', $value); $value = str_replace("\n", '`n', $value); $value = str_replace("\r", '`r', $value); $value = str_replace("\t", '`t', $value); $value = str_replace("\v", '`v', $value); $value = str_replace('#', '`#', $value); $value = str_replace("'", '`\'', $value); $value = str_replace('"', '`"', $value); // The rule on dollar signs is mentioned further down the page, and // they only need to be escaped when using double quotes (which we are). $value = str_replace('$', '`$', $value); return '"'.$value.'"'; } + private static function escapeLinux($value) { + if (strpos($value, "\0") !== false) { + throw new Exception( + pht( + 'Command string argument includes a NULL byte. This byte can not '. + 'be safely escaped in command line arguments in Linux '. + 'environments.')); + } + + // If the argument is nonempty and contains only common printable + // characters, we do not need to escape it. This makes debugging + // workflows a little more user-friendly by making command output + // more readable. + if (preg_match('(^[a-zA-Z0-9:/@._+-]+\z)', $value)) { + return $value; + } + + return escapeshellarg($value); + } + + private static function escapeWindows($value) { + if (strpos($value, "\0") !== false) { + throw new Exception( + pht( + 'Command string argument includes a NULL byte. This byte can not '. + 'be safely escaped in command line arguments in Windows '. + 'environments.')); + } + + if (!phutil_is_utf8($value)) { + throw new Exception( + pht( + 'Command string argument includes text which is not valid UTF-8. '. + 'This library can not safely escape this sequence in command '. + 'line arguments in Windows environments.')); + } + + $has_backslash = (strpos($value, '\\') !== false); + $has_space = (strpos($value, ' ') !== false); + $has_quote = (strpos($value, '"') !== false); + $is_empty = (strlen($value) === 0); + + // If a backslash appears before another backslash, a double quote, or + // the end of the argument, we must escape it. Otherwise, we must leave + // it unescaped. + + if ($has_backslash) { + $value_v = preg_split('//', $value, -1, PREG_SPLIT_NO_EMPTY); + $len = count($value_v); + for ($ii = 0; $ii < $len; $ii++) { + if ($value_v[$ii] === '\\') { + if ($ii + 1 < $len) { + $next = $value_v[$ii + 1]; + } else { + $next = null; + } + + if ($next === '"' || $next === '\\' || $next === null) { + $value_v[$ii] = '\\\\'; + } + } + } + $value = implode('', $value_v); + } + + // Then, escape double quotes by prefixing them with backslashes. + if ($has_quote || $has_space || $has_backslash || $is_empty) { + $value = addcslashes($value, '"'); + $value = '"'.$value.'"'; + } + + return $value; + } + }