Page MenuHomePhabricator

D19011.id.diff
No OneTemporary

D19011.id.diff

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
@@ -257,6 +257,9 @@
'PhutilGitURITestCase' => 'parser/__tests__/PhutilGitURITestCase.php',
'PhutilGoogleAuthAdapter' => 'auth/PhutilGoogleAuthAdapter.php',
'PhutilHTTPEngineExtension' => 'future/http/PhutilHTTPEngineExtension.php',
+ 'PhutilHTTPResponse' => 'parser/http/PhutilHTTPResponse.php',
+ 'PhutilHTTPResponseParser' => 'parser/http/PhutilHTTPResponseParser.php',
+ 'PhutilHTTPResponseParserTestCase' => 'parser/http/__tests__/PhutilHTTPResponseParserTestCase.php',
'PhutilHangForeverDaemon' => 'daemon/torture/PhutilHangForeverDaemon.php',
'PhutilHashingIterator' => 'utils/PhutilHashingIterator.php',
'PhutilHashingIteratorTestCase' => 'utils/__tests__/PhutilHashingIteratorTestCase.php',
@@ -890,6 +893,9 @@
'PhutilGitURITestCase' => 'PhutilTestCase',
'PhutilGoogleAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilHTTPEngineExtension' => 'Phobject',
+ 'PhutilHTTPResponse' => 'Phobject',
+ 'PhutilHTTPResponseParser' => 'Phobject',
+ 'PhutilHTTPResponseParserTestCase' => 'PhutilTestCase',
'PhutilHangForeverDaemon' => 'PhutilTortureTestDaemon',
'PhutilHashingIterator' => array(
'PhutilProxyIterator',
diff --git a/src/parser/http/PhutilHTTPResponse.php b/src/parser/http/PhutilHTTPResponse.php
new file mode 100644
--- /dev/null
+++ b/src/parser/http/PhutilHTTPResponse.php
@@ -0,0 +1,40 @@
+<?php
+
+final class PhutilHTTPResponse
+ extends Phobject {
+
+ private $headers = array();
+ private $body;
+ private $status;
+
+ public function __construct() {
+ $this->body = new PhutilRope();
+ }
+
+ public function setHeaders(array $headers) {
+ $this->headers = $headers;
+ return $this;
+ }
+
+ public function getHeaders() {
+ return $this->headers;
+ }
+
+ public function setStatus(HTTPFutureResponseStatus $status) {
+ $this->status = $status;
+ return $this;
+ }
+
+ public function getStatus() {
+ return $this->status;
+ }
+
+ public function appendBody($bytes) {
+ $this->body->append($bytes);
+ }
+
+ public function getBody() {
+ return $this->body->getAsString();
+ }
+
+}
diff --git a/src/parser/http/PhutilHTTPResponseParser.php b/src/parser/http/PhutilHTTPResponseParser.php
new file mode 100644
--- /dev/null
+++ b/src/parser/http/PhutilHTTPResponseParser.php
@@ -0,0 +1,174 @@
+<?php
+
+final class PhutilHTTPResponseParser extends Phobject {
+
+ private $followLocationHeaders;
+ private $responses = array();
+ private $response;
+ private $buffer;
+ private $state = 'headers';
+
+ public function setFollowLocationHeaders($follow_location_headers) {
+ $this->followLocationHeaders = $follow_location_headers;
+ return $this;
+ }
+
+ public function getFollowLocationHeaders() {
+ return $this->followLocationHeaders;
+ }
+
+ public function readBytes($bytes) {
+ if ($this->state == 'discard') {
+ return $this;
+ }
+
+ $this->buffer .= $bytes;
+
+ while (true) {
+ if ($this->state == 'headers') {
+ $matches = null;
+
+ $ok = preg_match(
+ "/(\r?\n\r?\n)/",
+ $this->buffer,
+ $matches,
+ PREG_OFFSET_CAPTURE);
+ if (!$ok) {
+ break;
+ }
+
+ $headers_len = $matches[1][1];
+ $boundary_len = strlen($matches[1][0]);
+ $raw_headers = substr($this->buffer, 0, $headers_len);
+ $this->buffer = substr($this->buffer, $headers_len + $boundary_len);
+
+ $header_lines = phutil_split_lines($raw_headers);
+ $first_line = array_shift($header_lines);
+ $response_valid = true;
+
+ $matches = null;
+ $ok = preg_match(
+ '(^HTTP/\S+\s+(\d+)\s+(.*)$)i',
+ $first_line,
+ $matches);
+
+ if ($ok) {
+ $http_code = (int)$matches[1];
+ $http_status = phutil_utf8_strtolower($matches[2]);
+ } else {
+ $response_valid = false;
+ }
+
+ $header_list = array();
+ $saw_location = false;
+ foreach ($header_lines as $header_line) {
+ $pos = strpos($header_line, ':');
+ if ($pos === false) {
+ $response_valid = false;
+ break;
+ }
+
+ $name = substr($header_line, 0, $pos);
+ $value = ltrim(substr($header_line, $pos + 1), ' ');
+
+ if (phutil_utf8_strtolower($name) == 'location') {
+ $saw_location = true;
+ }
+
+ $header_list[] = array(
+ $name,
+ $value,
+ );
+ }
+
+ // If the response didn't start with a properly formatted "HTTP/..."
+ // line, or any of the header lines were not formatted correctly, add
+ // a malformed response to the response list and discard anything else
+ // we're given.
+ if (!$response_valid) {
+ $malformed = new HTTPFutureParseResponseStatus(
+ HTTPFutureParseResponseStatus::ERROR_MALFORMED_RESPONSE,
+ $raw_headers);
+
+ $this->newHTTPRepsonse()
+ ->setStatus($malformed);
+
+ $this->buffer = '';
+ $this->state = 'discard';
+ break;
+ }
+
+ // Otherwise, we have a valid set of response headers.
+ $response_status = new HTTPFutureHTTPResponseStatus(
+ $http_code,
+ null,
+ $header_list);
+
+ $this->newHTTPResponse()
+ ->setStatus($response_status)
+ ->setHeaders($header_list);
+
+ $is_https_proxy =
+ ($http_code === 200) &&
+ ($http_status === 'connection established');
+
+ if ($http_code === 100) {
+ // If this is "HTTP/1.1 100 Continue", this is just the server
+ // telling us that everything is okay. This response won't have
+ // a body associated with it.
+ $more_headers = true;
+ } else if ($is_https_proxy) {
+ // If this is "HTTP/1.1 200 Connection Established", this is a
+ // response to a CONNECT request made automatically by cURL to
+ // an HTTPS proxy. This response won't have a body associated
+ // with it, and the real body will follow later.
+ $more_headers = true;
+ } else if ($saw_location && $this->followLocationHeaders) {
+ // If we're following location headers and this response had
+ // a location header, cURL will automatically follow it. This
+ // response shouldn't have a body.
+ $more_headers = true;
+ } else {
+ $more_headers = false;
+ }
+
+ // If we're expecting more headers, we're going to stay in the
+ // "headers" state and parse another set of headers. Otherwise,
+ // we transition to the "body" state and look for a body.
+ if (!$more_headers) {
+ $this->state = 'body';
+ }
+
+ continue;
+ }
+
+ if ($this->state == 'body') {
+ if (strlen($this->buffer)) {
+ $this->response->appendBody($this->buffer);
+ $this->buffer = '';
+ }
+ break;
+ }
+ }
+
+ return $this;
+ }
+
+ public function getResponses() {
+ if ($this->state !== 'body') {
+ throw new HTTPFutureParseResponseStatus(
+ HTTPFutureParseResponseStatus::ERROR_MALFORMED_RESPONSE,
+ $this->buffer);
+ }
+
+ return $this->responses;
+ }
+
+ private function newHTTPResponse() {
+ $response = new PhutilHTTPResponse();
+ $this->responses[] = $response;
+ $this->response = $response;
+ return $response;
+ }
+
+}
diff --git a/src/parser/http/__tests__/PhutilHTTPResponseParserTestCase.php b/src/parser/http/__tests__/PhutilHTTPResponseParserTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/parser/http/__tests__/PhutilHTTPResponseParserTestCase.php
@@ -0,0 +1,124 @@
+<?php
+
+final class PhutilHTTPResponseParserTestCase
+ extends PhutilTestCase {
+
+ public function testSimpleParsing() {
+ $input = <<<EORESPONSE
+HTTP/1.0 200 OK
+Duck: Quack
+
+I am the very model of a modern major general.
+EORESPONSE;
+
+ $this->assertParse(
+ array(
+ array(
+ 'headers' => array(
+ array('Duck', 'Quack'),
+ ),
+ 'body' => 'I am the very model of a modern major general.',
+ ),
+ ),
+ $input);
+
+ $input = <<<EORESPONSE
+HTTP/1.0 200 Connection Established
+X-I-Am-A-Proxy-Server: Hello
+
+HTTP/1.0 100 Continue
+X-Everything-Is-Fine: true
+
+HTTP/1.1 302 Found
+Location: Over-There
+
+HTTP/1.0 404 Not Found
+
+Not all who wander are lost.
+EORESPONSE;
+
+ $this->assertParse(
+ array(
+ array(
+ 'code' => 200,
+ 'headers' => array(
+ array('X-I-Am-A-Proxy-Server', 'Hello'),
+ ),
+ 'body' => '',
+ ),
+ array(
+ 'code' => 100,
+ 'headers' => array(
+ array('X-Everything-Is-Fine', 'true'),
+ ),
+ 'body' => '',
+ ),
+ array(
+ 'code' => 302,
+ 'headers' => array(
+ array('Location', 'Over-There'),
+ ),
+ 'body' => '',
+ ),
+ array(
+ 'code' => 404,
+ 'headers' => array(),
+ 'body' => 'Not all who wander are lost.',
+ ),
+ ),
+ $input,
+ id(new PhutilHTTPResponseParser())
+ ->setFollowLocationHeaders(true));
+ }
+
+ private function assertParse(array $expect, $input, $parser = null) {
+ if ($parser === null) {
+ $parser = new PhutilHTTPResponseParser();
+ }
+
+ // Submit the input in little bits to try to catch any weird parser bugs.
+ $length = strlen($input);
+ $pos = 0;
+ while ($pos < $length) {
+ $next_len = mt_rand(1, 32);
+ $piece = substr($input, $pos, $next_len);
+ $pos = $pos + $next_len;
+
+ $parser->readBytes($piece);
+ }
+
+ $responses = $parser->getResponses();
+
+ $this->assertEqual(count($expect), count($responses));
+
+ $expect = array_values($expect);
+ $responses = array_values($responses);
+
+ for ($ii = 0; $ii < count($expect); $ii++) {
+ $expect_map = $expect[$ii];
+ $actual = $responses[$ii];
+
+ foreach ($expect_map as $key => $spec) {
+ switch ($key) {
+ case 'headers':
+ $this->assertEqual($spec, $actual->getHeaders());
+ break;
+ case 'body':
+ $this->assertEqual($spec, $actual->getBody());
+ break;
+ case 'code':
+ $status = $actual->getStatus();
+ $this->assertTrue($status instanceof HTTPFutureHTTPResponseStatus);
+ $this->assertEqual($spec, $status->getStatusCode());
+ break;
+ default:
+ throw new Exception(
+ pht(
+ 'Unknown HTTPResponseParser test ("%s").',
+ $key));
+ }
+ }
+ }
+ }
+
+}

File Metadata

Mime Type
text/plain
Expires
Mar 26 2025, 2:04 PM (4 w, 2 d ago)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
7707506
Default Alt Text
D19011.id.diff (10 KB)

Event Timeline