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_'.
+ 'S> 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);
+ }
+}