diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -45,6 +45,7 @@ 'ExecFuture' => 'future/exec/ExecFuture.php', 'ExecFutureTestCase' => 'future/exec/__tests__/ExecFutureTestCase.php', 'ExecPassthruTestCase' => 'future/exec/__tests__/ExecPassthruTestCase.php', + 'ExecPowershellTestCase' => 'future/exec/__tests__/ExecPowershellTestCase.php', 'FileFinder' => 'filesystem/FileFinder.php', 'FileFinderTestCase' => 'filesystem/__tests__/FileFinderTestCase.php', 'FileList' => 'filesystem/FileList.php', @@ -497,6 +498,7 @@ 'ExecFuture' => 'Future', 'ExecFutureTestCase' => 'PhutilTestCase', 'ExecPassthruTestCase' => 'PhutilTestCase', + 'ExecPowershellTestCase' => 'PhutilTestCase', 'FileFinderTestCase' => 'PhutilTestCase', 'FilesystemException' => 'Exception', 'FilesystemTestCase' => 'PhutilTestCase', diff --git a/src/future/exec/ExecFuture.php b/src/future/exec/ExecFuture.php --- a/src/future/exec/ExecFuture.php +++ b/src/future/exec/ExecFuture.php @@ -32,6 +32,9 @@ private $stdin = null; private $closePipe = true; + private $powershellXml = false; + private $powershellStderr = null; + private $stdoutPos = 0; private $stderrPos = 0; private $command = null; @@ -207,6 +210,19 @@ /** + * Enable parsing the nonsense XML crap that Powershell outputs to stderr, + * which wraps all standard error output as CLIXML. + * + * @param bool Whether this future should parse standard error as CLIXML. + * @return this + */ + public function setPowershellXML($powershell_xml) { + $this->powershellXml = $powershell_xml; + return $this; + } + + + /** * Set the value of a specific environmental variable for this command. * * @param string Environmental variable name. @@ -728,11 +744,27 @@ } if ($max_stderr_read_bytes > 0) { - $this->stderr .= $this->readAndDiscard( - $stderr, - $this->getStderrSizeLimit() - strlen($this->stderr), - 'stderr', - $max_stderr_read_bytes); + if ($this->powershellXml) { + $this->powershellStderr .= $this->readAndDiscard( + $stderr, + $this->getStderrSizeLimit() - strlen($this->powershellStderr), + 'stderr', + $max_stderr_read_bytes); + + list($parsed_stderr, $stderr_taken) = $this->parsePowershellXML( + $this->powershellStderr); + + $this->stderr .= $parsed_stderr; + $this->powershellStderr = substr( + $this->powershellStderr, + $stderr_taken); + } else { + $this->stderr .= $this->readAndDiscard( + $stderr, + $this->getStderrSizeLimit() - strlen($this->stderr), + 'stderr', + $max_stderr_read_bytes); + } } if (!$status['running']) { @@ -756,6 +788,73 @@ /** + * Parse the powershell XML and convert it into output for CLIXML. + */ + public function parsePowershellXML($powershell_xml) { + $lines = phutil_split_lines($powershell_xml); + + $result = ''; + $consumed_characters = 0; + $previous_characters = 0; + + // With CLIXML, each line contains XML, or the CLIXML start line. + for ($i = 0; $i < count($lines); $i++) { + $line = $lines[$i]; + + // Calculate how many characters we're consuming. + $previous_characters = $consumed_characters; + $consumed_characters += strlen($line); + while ( + $consumed_characters < strlen($powershell_xml) && ( + $powershell_xml[$consumed_characters] === "\r" || + $powershell_xml[$consumed_characters] === "\n")) { + + $consumed_characters += 1; + } + + // CLIXML outputs "#< CLIXML" on the first line, so we discard it. + if (trim($line) === '#< CLIXML') { + continue; + } + + // Try and load the line as XML. Because other processes can write to + // standard error, it can be random output from other processes (yay!) + $xml = @simplexml_load_string($line); + if ($xml === false) { + + // Check to see if this is the last line; if it is, we might not be + // able to parse it because it's not fully read yet. + if ($i === count($lines) - 1) { + return array($result, $previous_characters); + } + + // If we've fully read this line, and we still can't read it, then it + // might be output from another program, so just return it as part + // of the results. + $result .= $line; + continue; + } + + $xml->registerXPathNamespace( + 'ns', + 'http://schemas.microsoft.com/powershell/2004/04'); + + $error_lines = $xml->xpath('//ns:S[@S=\'Error\']'); + foreach ($error_lines as $error) { + + // Microsoft; land of the completely made-up standards. + $error = str_replace('_x000D_', "\r", $error); + $error = str_replace('_x000A_', "\n", $error); + + $result .= $error; + } + } + + return array($result, $consumed_characters); + } + + + /** * @return void * @task internal */ diff --git a/src/future/exec/__tests__/ExecPowershellTestCase.php b/src/future/exec/__tests__/ExecPowershellTestCase.php new file mode 100644 --- /dev/null +++ b/src/future/exec/__tests__/ExecPowershellTestCase.php @@ -0,0 +1,165 @@ +cd Z:\366_x000D__x000A_trap_x000'. + 'D__x000A_{_x000D__x000A_ #try {_x000'. + 'D__x000A_ # #$Host.UI.WriteErrorLine($_)_x000D__x000'. + 'A_ #} catch {_x000D__x000A_ # #[Mi'. + 'crosoft.PowerShell.Commands.WriteErrorException]_x000D__x000A_ # #Write-Host $__x000D__x000A_ #}_x000D__x'. + '000A_ _x000D__x000A_ #exit 1_x000'. + 'D__x000A_}_x000D__x000A_try {_x000D__'. + 'x000A_ Write-Host "TEST"; Write-Error _x000D__x000A_<'. + '/S>"<xml>microsoft<you>better<not>suck&l'. + 't;/not></you></xml><S WHOOOOPS>"_x000D__x000A_'. + '} catch {_x000D__x000A_ Write-Host "exce'. + 'ption occurred"_x000D__x000A_ exit 1_x000D__x000A_}_x000D__x000A_if ($LastExitCode -ne 0) '. + '{_x000D__x000A_ exit $LastExitCode_x000D__x000A_<'. + 'S S="Error">} : <xml>microsoft<you>better<not>suck<'. + ';/not></you></xml><S WHOOOOPS>_x000D__x000A_ + CategoryInfo : NotSpecified: (:) [Write-Error'. + '], WriteErrorExcep _x000D__x000A_ tion_x000D__x000A_'. + ' + FullyQualifiedErrorId : Microsoft.PowerShell.Com'. + 'mands.WriteErrorExceptio _x000D__x000A_ n_x000D__x00'. + '0A_ _x000D__x000A_'; + } + + private function getIntermixedPowershellXML() { + return + 'whatever blah blah blah blah.'."\r\n". + 'some other lines here.'."\r\n". + 'microsoft y u do dis'."\r\n". + '#< CLIXML'."\r\n". + 'abcdefghi_x000D__x000A_At line:1'. + ' char:1_x000D__x000A_+ abcdefghi _x000D__x000A_-Force_x000D__x000A_+ _x000D__x000A_~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~'. + '~~~~~~~~~~~~~~~~_x000D__x000A_ + CategoryInfo '. + ' : NotSpecified: (:) [Stop-EC2Instance], AmazonEC2E _x000D__x000A_ xception_x000D__x000A_ + FullyQua'. + 'lifiedErrorId : Amazon.EC2.AmazonEC2Exception,Amazon.PowerShell. _x000D'. + '__x000A_ Cmdlets.EC2.StopEC2InstanceCmdlet_x000D__x0'. + '00A_ _x000D__x000A_'."\r\n"; + } + + private function getOnlyPowershellExpected() { + return + 'cd Z:\366'."\r\n". + 'trap'."\r\n". + '{'."\r\n". + ' #try {'."\r\n". + ' # #$Host.UI.WriteErrorLine($_)'."\r\n". + ' #} catch {'."\r\n". + ' # #[Microsoft.PowerShell.Commands.WriteErrorException]'."\r\n". + ' # #Write-Host $_'."\r\n". + ' #}'."\r\n". + ' '."\r\n". + ' #exit 1'."\r\n". + '}'."\r\n". + 'try {'."\r\n". + ' Write-Host "TEST"; Write-Error '."\r\n". + '"microsoftbettersuck"'. + "\r\n". + '} catch {'."\r\n". + ' Write-Host "exception occurred"'."\r\n". + ' exit 1'."\r\n". + '}'."\r\n". + 'if ($LastExitCode -ne 0) {'."\r\n". + ' exit $LastExitCode'."\r\n". + '} : microsoftbettersuck'. + "\r\n". + ' + CategoryInfo : NotSpecified: (:) [Write-Error], WriteErr'. + 'orExcep '."\r\n". + ' tion'."\r\n". + ' + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorE'. + 'xceptio '."\r\n". + ' n'."\r\n". + ' '."\r\n"; + } + + private function getIntermixedPowershellExpected() { + return + 'whatever blah blah blah blah.'."\r\n". + 'some other lines here.'."\r\n". + 'microsoft y u do dis'."\r\n". + 'abcdefghi'."\r\n". + 'At line:1 char:1'."\r\n". + '+ abcdefghi '."\r\n". + '-Force'."\r\n". + '+ '."\r\n". + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~'. + '~~~~~~~'."\r\n". + ' + CategoryInfo : NotSpecified: (:) [Stop-EC2Instance], Ama'. + 'zonEC2E '."\r\n". + ' xception'."\r\n". + ' + FullyQualifiedErrorId : Amazon.EC2.AmazonEC2Exception,Amazon.Powe'. + 'rShell. '."\r\n". + ' Cmdlets.EC2.StopEC2InstanceCmdlet'."\r\n". + ' '."\r\n"; + } + + public function testParseOnlyPowershellXML() { + $powershell = $this->getOnlyPowershellXML(); + + list($parsed, $consumed) = + id(new ExecFuture(''))->parsePowershellXML($powershell); + $expected = $this->getOnlyPowershellExpected(); + + $parsed = str_replace("\r", '', $parsed); + $expected = str_replace("\r", '', $expected); + + $this->assertEqual($expected, $parsed); + } + + public function testParseIntermixedPowershellXML() { + $powershell = $this->getIntermixedPowershellXML(); + + list($parsed, $consumed) = + id(new ExecFuture(''))->parsePowershellXML($powershell); + $expected = $this->getIntermixedPowershellExpected(); + + $parsed = str_replace("\r", '', $parsed); + $expected = str_replace("\r", '', $expected); + + $this->assertEqual($expected, $parsed); + } + + public function testExecOnlyPowershellXML() { + $file = id(new TempFile()); + Filesystem::writeFile($file, $this->getOnlyPowershellXML()); + + list($stdout, $stderr) = id(new ExecFuture('cat %s >&2', $file)) + ->setPowershellXML(true) + ->resolvex(); + + $expected = $this->getOnlyPowershellExpected(); + + $stderr = str_replace("\r", '', $stderr); + $expected = str_replace("\r", '', $expected); + + $this->assertEqual($expected, $stderr); + } + + public function testExecIntermixedPowershellXML() { + $file = id(new TempFile()); + Filesystem::writeFile($file, $this->getIntermixedPowershellXML()); + + list($stdout, $stderr) = id(new ExecFuture('cat %s >&2', $file)) + ->setPowershellXML(true) + ->resolvex(); + + $expected = $this->getIntermixedPowershellExpected(); + + $stderr = str_replace("\r", '', $stderr); + $expected = str_replace("\r", '', $expected); + + $this->assertEqual($expected, $stderr); + } +}