diff --git a/src/applications/diffusion/protocol/DiffusionSubversionWireProtocol.php b/src/applications/diffusion/protocol/DiffusionSubversionWireProtocol.php index 1cb0f107aa..251967f314 100644 --- a/src/applications/diffusion/protocol/DiffusionSubversionWireProtocol.php +++ b/src/applications/diffusion/protocol/DiffusionSubversionWireProtocol.php @@ -1,197 +1,220 @@ stack[] = $this->list; $this->list = array(); } private function popList() { $list = $this->list; $this->list = array_pop($this->stack); return $list; } private function pushItem($item, $type) { $this->list[] = array( 'type' => $type, 'value' => $item, ); } public function writeData($data) { $this->buffer .= $data; $messages = array(); while (true) { - if ($this->state == 'item') { + if ($this->state == 'space') { + // Consume zero or more extra spaces after matching an item. The + // protocol requires at least one space, but allows more than one. + + $matches = null; + if (!preg_match('/^(\s*)\S/', $this->buffer, $matches)) { + // Wait for more data. + break; + } + + // We have zero or more spaces and then some other character, so throw + // the spaces away and continue parsing frames. + if (strlen($matches[1])) { + $this->buffer = substr($this->buffer, strlen($matches[1])); + } + + $this->state = 'item'; + } else if ($this->state == 'item') { $match = null; $result = null; $buf = $this->buffer; if (preg_match('/^([a-z][a-z0-9-]*)\s/i', $buf, $match)) { $this->pushItem($match[1], 'word'); } else if (preg_match('/^(\d+)\s/', $buf, $match)) { $this->pushItem((int)$match[1], 'number'); } else if (preg_match('/^(\d+):/', $buf, $match)) { // NOTE: The "+ 1" includes the space after the string. $this->expectBytes = (int)$match[1] + 1; $this->state = 'bytes'; } else if (preg_match('/^(\\()\s/', $buf, $match)) { $this->pushList(); } else if (preg_match('/^(\\))\s/', $buf, $match)) { $list = $this->popList(); if ($this->stack) { $this->pushItem($list, 'list'); } else { $result = $list; } } else { $match = false; } if ($match !== false) { $this->raw .= substr($this->buffer, 0, strlen($match[0])); $this->buffer = substr($this->buffer, strlen($match[0])); if ($result !== null) { $messages[] = array( 'structure' => $list, 'raw' => $this->raw, ); $this->raw = ''; } + + // Consume any extra whitespace after an item. If we're in the + // "bytes" state, we aren't looking for whitespace. + if ($this->state == 'item') { + $this->state = 'space'; + } } else { // No matches yet, wait for more data. break; } } else if ($this->state == 'bytes') { $new_data = substr($this->buffer, 0, $this->expectBytes); if (!strlen($new_data)) { // No more bytes available yet, wait for more data. break; } $this->buffer = substr($this->buffer, strlen($new_data)); $this->expectBytes -= strlen($new_data); $this->raw .= $new_data; $this->byteBuffer .= $new_data; if (!$this->expectBytes) { $this->state = 'byte-space'; // Strip off the terminal space. $this->pushItem(substr($this->byteBuffer, 0, -1), 'string'); $this->byteBuffer = ''; - $this->state = 'item'; + $this->state = 'space'; } } else { throw new Exception(pht("Invalid state '%s'!", $this->state)); } } return $messages; } /** * Convert a parsed command struct into a wire protocol string. */ public function serializeStruct(array $struct) { $out = array(); $out[] = '( '; foreach ($struct as $item) { $value = $item['value']; $type = $item['type']; switch ($type) { case 'word': $out[] = $value; break; case 'number': $out[] = $value; break; case 'string': $out[] = strlen($value).':'.$value; break; case 'list': $out[] = self::serializeStruct($value); break; default: throw new Exception( pht( "Unknown SVN wire protocol structure '%s'!", $type)); } if ($type != 'list') { $out[] = ' '; } } $out[] = ') '; return implode('', $out); } public function isReadOnlyCommand(array $struct) { if (empty($struct[0]['type']) || ($struct[0]['type'] != 'word')) { // This isn't what we expect; fail defensively. throw new Exception( pht( "Unexpected command structure, expected '%s'.", '( word ... )')); } switch ($struct[0]['value']) { // Authentication command set. case 'EXTERNAL': // The "Main" command set. Some of the commands in this command set are // mutation commands, and are omitted from this list. case 'reparent': case 'get-latest-rev': case 'get-dated-rev': case 'rev-proplist': case 'rev-prop': case 'get-file': case 'get-dir': case 'check-path': case 'stat': case 'update': case 'get-mergeinfo': case 'switch': case 'status': case 'diff': case 'log': case 'get-file-revs': case 'get-locations': // The "Report" command set. These are not actually mutation // operations, they just define a request for information. case 'set-path': case 'delete-path': case 'link-path': case 'finish-report': case 'abort-report': // These are used to report command results. case 'success': case 'failure': // If we get here, we've matched some known read-only command. return true; default: // Anything else isn't a known read-only command, so require write // access to use it. break; } return false; } } diff --git a/src/applications/diffusion/protocol/__tests__/DiffusionSubversionWireProtocolTestCase.php b/src/applications/diffusion/protocol/__tests__/DiffusionSubversionWireProtocolTestCase.php index bd99b82035..f4309b0e7b 100644 --- a/src/applications/diffusion/protocol/__tests__/DiffusionSubversionWireProtocolTestCase.php +++ b/src/applications/diffusion/protocol/__tests__/DiffusionSubversionWireProtocolTestCase.php @@ -1,105 +1,160 @@ assertSameSubversionMessages( '( ) ', array( array( ), )); $this->assertSameSubversionMessages( '( duck 5:quack 42 ( item1 item2 ) ) ', array( array( array( 'type' => 'word', 'value' => 'duck', ), array( 'type' => 'string', 'value' => 'quack', ), array( 'type' => 'number', 'value' => 42, ), array( 'type' => 'list', 'value' => array( array( 'type' => 'word', 'value' => 'item1', ), array( 'type' => 'word', 'value' => 'item2', ), ), ), ), )); $this->assertSameSubversionMessages( '( msg1 ) ( msg2 ) ', array( array( array( 'type' => 'word', 'value' => 'msg1', ), ), array( array( 'type' => 'word', 'value' => 'msg2', ), ), )); + + // This is testing that multiple spaces are parsed correctly. See T13140 + // for discussion. + $this->assertSameSubversionMessages( + '( get-file true false ) ', + // ^-- Note extra space! + array( + array( + array( + 'type' => 'word', + 'value' => 'get-file', + ), + array( + 'type' => 'word', + 'value' => 'true', + ), + array( + 'type' => 'word', + 'value' => 'false', + ), + ), + ), + '( get-file true false ) '); + + $this->assertSameSubversionMessages( + '( duck 5:quack moo ) ', + array( + array( + array( + 'type' => 'word', + 'value' => 'duck', + ), + array( + 'type' => 'string', + 'value' => 'quack', + ), + array( + 'type' => 'word', + 'value' => 'moo', + ), + ), + ), + '( duck 5:quack moo ) '); + } public function testSubversionWireProtocolPartialFrame() { $proto = new DiffusionSubversionWireProtocol(); // This is primarily a test that we don't hang when we write() a frame // which straddles a string boundary. $msg1 = $proto->writeData('( duck 5:qu'); $msg2 = $proto->writeData('ack ) '); $this->assertEqual(array(), ipull($msg1, 'structure')); $this->assertEqual( array( array( array( 'type' => 'word', 'value' => 'duck', ), array( 'type' => 'string', 'value' => 'quack', ), ), ), ipull($msg2, 'structure')); } - private function assertSameSubversionMessages($string, array $structs) { + private function assertSameSubversionMessages( + $string, + array $structs, + $serial_string = null) { + $proto = new DiffusionSubversionWireProtocol(); // Verify that the wire message parses into the structs. $messages = $proto->writeData($string); $messages = ipull($messages, 'structure'); $this->assertEqual($structs, $messages, 'parse<'.$string.'>'); // Verify that the structs serialize into the wire message. $serial = array(); foreach ($structs as $struct) { $serial[] = $proto->serializeStruct($struct); } $serial = implode('', $serial); - $this->assertEqual($string, $serial, 'serialize<'.$string.'>'); + + if ($serial_string === null) { + $expect_serial = $string; + } else { + $expect_serial = $serial_string; + } + + $this->assertEqual($expect_serial, $serial, 'serialize<'.$string.'>'); } }