From c84abdba280809d207761347f7f0c8178351e339 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Wed, 11 Dec 2019 22:17:44 +0100 Subject: [PATCH 1/2] Implement hpack using nghttp2 via FFI if available --- .gitignore | 1 + .travis.yml | 3 +- composer.json | 11 +- examples/bench.php | 19 +- phpunit.xml.dist | 22 +- src/HPack.php | 643 +--------------------------- src/HPackException.php | 7 + src/Internal/HPackNative.php | 645 +++++++++++++++++++++++++++++ src/Internal/HPackNghttp2.php | 270 ++++++++++++ test/HPackTest.php | 65 ++- test/Internal/HPackNativeTest.php | 13 + test/Internal/HPackNghttp2Test.php | 17 + 12 files changed, 1040 insertions(+), 676 deletions(-) create mode 100644 src/HPackException.php create mode 100644 src/Internal/HPackNative.php create mode 100644 src/Internal/HPackNghttp2.php create mode 100644 test/Internal/HPackNativeTest.php create mode 100644 test/Internal/HPackNghttp2Test.php diff --git a/.gitignore b/.gitignore index ba079f4..47b65f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .php_cs.cache +.phpunit.result.cache build composer.lock coverage diff --git a/.travis.yml b/.travis.yml index dc0ffc6..40ccd30 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,11 +3,10 @@ sudo: false language: php php: - - 7.0 - 7.1 - 7.2 - 7.3 - - 7.4snapshot + - 7.4 - nightly matrix: diff --git a/composer.json b/composer.json index 151ba60..e60c7c0 100644 --- a/composer.json +++ b/composer.json @@ -26,12 +26,12 @@ } ], "require": { - "php": ">=7" + "php": ">=7.1" }, "require-dev": { "amphp/php-cs-fixer-config": "dev-master", "http2jp/hpack-test-case": "^1", - "phpunit/phpunit": "^6" + "phpunit/phpunit": "^6 | ^7" }, "autoload": { "psr-4": { @@ -40,12 +40,7 @@ }, "autoload-dev": { "psr-4": { - "Amp\\Http\\HPack\\Test\\": "test" - } - }, - "config": { - "platform": { - "php": "7.0.13" + "Amp\\Http\\": "test" } }, "repositories": [ diff --git a/examples/bench.php b/examples/bench.php index 95038bb..f4d159d 100644 --- a/examples/bench.php +++ b/examples/bench.php @@ -24,19 +24,30 @@ } $minDuration = \PHP_INT_MAX; +$minOps = \PHP_INT_MAX; for ($i = 0; $i < 10; $i++) { $start = \microtime(true); + $ops = 0; foreach ($tests as $test) { - $hpack = new Amp\Http\HPack; - foreach ($cases as list($input, $output)) { - $hpack->decode($input, 8192); + $hpack = new Amp\Http\Internal\HPackNative; + foreach ($cases as [$input, $output]) { + $headers = $hpack->decode($input, 4096); + $hpack->encode($headers); + + if ($headers !== $output) { + print 'Invalid headers' . \PHP_EOL; + exit(1); + } + + $ops++; } } $duration = \microtime(true) - $start; $minDuration = \min($minDuration, $duration); + $minOps = \min($ops, $minOps); } -print "$minDuration s" . PHP_EOL . PHP_EOL; +print "$minOps in $minDuration seconds" . \PHP_EOL; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f179481..5c7bd9e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,21 +1,11 @@ - + - - + + @@ -28,7 +18,7 @@ - + diff --git a/src/HPack.php b/src/HPack.php index bd4aa87..3b8a729 100644 --- a/src/HPack.php +++ b/src/HPack.php @@ -2,646 +2,43 @@ namespace Amp\Http; +use Amp\Http\Internal\HPackNative; +use Amp\Http\Internal\HPackNghttp2; + final class HPack { - const HUFFMAN_CODE = [ - /* 0x00 */ 0x1ff8, 0x7fffd8, 0xfffffe2, 0xfffffe3, 0xfffffe4, 0xfffffe5, 0xfffffe6, 0xfffffe7, - /* 0x08 */ 0xfffffe8, 0xffffea, 0x3ffffffc, 0xfffffe9, 0xfffffea, 0x3ffffffd, 0xfffffeb, 0xfffffec, - /* 0x10 */ 0xfffffed, 0xfffffee, 0xfffffef, 0xffffff0, 0xffffff1, 0xffffff2, 0x3ffffffe, 0xffffff3, - /* 0x18 */ 0xffffff4, 0xffffff5, 0xffffff6, 0xffffff7, 0xffffff8, 0xffffff9, 0xffffffa, 0xffffffb, - /* 0x20 */ 0x14, 0x3f8, 0x3f9, 0xffa, 0x1ff9, 0x15, 0xf8, 0x7fa, - /* 0x28 */ 0x3fa, 0x3fb, 0xf9, 0x7fb, 0xfa, 0x16, 0x17, 0x18, - /* 0x30 */ 0x0, 0x1, 0x2, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, - /* 0x38 */ 0x1e, 0x1f, 0x5c, 0xfb, 0x7ffc, 0x20, 0xffb, 0x3fc, - /* 0x40 */ 0x1ffa, 0x21, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, - /* 0x48 */ 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, - /* 0x50 */ 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, - /* 0x58 */ 0xfc, 0x73, 0xfd, 0x1ffb, 0x7fff0, 0x1ffc, 0x3ffc, 0x22, - /* 0x60 */ 0x7ffd, 0x3, 0x23, 0x4, 0x24, 0x5, 0x25, 0x26, - /* 0x68 */ 0x27, 0x6, 0x74, 0x75, 0x28, 0x29, 0x2a, 0x7, - /* 0x70 */ 0x2b, 0x76, 0x2c, 0x8, 0x9, 0x2d, 0x77, 0x78, - /* 0x78 */ 0x79, 0x7a, 0x7b, 0x7ffe, 0x7fc, 0x3ffd, 0x1ffd, 0xffffffc, - /* 0x80 */ 0xfffe6, 0x3fffd2, 0xfffe7, 0xfffe8, 0x3fffd3, 0x3fffd4, 0x3fffd5, 0x7fffd9, - /* 0x88 */ 0x3fffd6, 0x7fffda, 0x7fffdb, 0x7fffdc, 0x7fffdd, 0x7fffde, 0xffffeb, 0x7fffdf, - /* 0x90 */ 0xffffec, 0xffffed, 0x3fffd7, 0x7fffe0, 0xffffee, 0x7fffe1, 0x7fffe2, 0x7fffe3, - /* 0x98 */ 0x7fffe4, 0x1fffdc, 0x3fffd8, 0x7fffe5, 0x3fffd9, 0x7fffe6, 0x7fffe7, 0xffffef, - /* 0xA0 */ 0x3fffda, 0x1fffdd, 0xfffe9, 0x3fffdb, 0x3fffdc, 0x7fffe8, 0x7fffe9, 0x1fffde, - /* 0xA8 */ 0x7fffea, 0x3fffdd, 0x3fffde, 0xfffff0, 0x1fffdf, 0x3fffdf, 0x7fffeb, 0x7fffec, - /* 0xB0 */ 0x1fffe0, 0x1fffe1, 0x3fffe0, 0x1fffe2, 0x7fffed, 0x3fffe1, 0x7fffee, 0x7fffef, - /* 0xB8 */ 0xfffea, 0x3fffe2, 0x3fffe3, 0x3fffe4, 0x7ffff0, 0x3fffe5, 0x3fffe6, 0x7ffff1, - /* 0xC0 */ 0x3ffffe0, 0x3ffffe1, 0xfffeb, 0x7fff1, 0x3fffe7, 0x7ffff2, 0x3fffe8, 0x1ffffec, - /* 0xC8 */ 0x3ffffe2, 0x3ffffe3, 0x3ffffe4, 0x7ffffde, 0x7ffffdf, 0x3ffffe5, 0xfffff1, 0x1ffffed, - /* 0xD0 */ 0x7fff2, 0x1fffe3, 0x3ffffe6, 0x7ffffe0, 0x7ffffe1, 0x3ffffe7, 0x7ffffe2, 0xfffff2, - /* 0xD8 */ 0x1fffe4, 0x1fffe5, 0x3ffffe8, 0x3ffffe9, 0xffffffd, 0x7ffffe3, 0x7ffffe4, 0x7ffffe5, - /* 0xE0 */ 0xfffec, 0xfffff3, 0xfffed, 0x1fffe6, 0x3fffe9, 0x1fffe7, 0x1fffe8, 0x7ffff3, - /* 0xE8 */ 0x3fffea, 0x3fffeb, 0x1ffffee, 0x1ffffef, 0xfffff4, 0xfffff5, 0x3ffffea, 0x7ffff4, - /* 0xF0 */ 0x3ffffeb, 0x7ffffe6, 0x3ffffec, 0x3ffffed, 0x7ffffe7, 0x7ffffe8, 0x7ffffe9, 0x7ffffea, - /* 0xF8 */ 0x7ffffeb, 0xffffffe, 0x7ffffec, 0x7ffffed, 0x7ffffee, 0x7ffffef, 0x7fffff0, 0x3ffffee, - /* end! */ 0x3fffffff - ]; - - const HUFFMAN_CODE_LENGTHS = [ - /* 0x00 */ 13, 23, 28, 28, 28, 28, 28, 28, - /* 0x08 */ 28, 24, 30, 28, 28, 30, 28, 28, - /* 0x10 */ 28, 28, 28, 28, 28, 28, 30, 28, - /* 0x18 */ 28, 28, 28, 28, 28, 28, 28, 28, - /* 0x20 */ 6, 10, 10, 12, 13, 6, 8, 11, - /* 0x28 */ 10, 10, 8, 11, 8, 6, 6, 6, - /* 0x30 */ 5, 5, 5, 6, 6, 6, 6, 6, - /* 0x38 */ 6, 6, 7, 8, 15, 6, 12, 10, - /* 0x40 */ 13, 6, 7, 7, 7, 7, 7, 7, - /* 0x48 */ 7, 7, 7, 7, 7, 7, 7, 7, - /* 0x50 */ 7, 7, 7, 7, 7, 7, 7, 7, - /* 0x58 */ 8, 7, 8, 13, 19, 13, 14, 6, - /* 0x60 */ 15, 5, 6, 5, 6, 5, 6, 6, - /* 0x68 */ 6, 5, 7, 7, 6, 6, 6, 5, - /* 0x70 */ 6, 7, 6, 5, 5, 6, 7, 7, - /* 0x78 */ 7, 7, 7, 15, 11, 14, 13, 28, - /* 0x80 */ 20, 22, 20, 20, 22, 22, 22, 23, - /* 0x88 */ 22, 23, 23, 23, 23, 23, 24, 23, - /* 0x90 */ 24, 24, 22, 23, 24, 23, 23, 23, - /* 0x98 */ 23, 21, 22, 23, 22, 23, 23, 24, - /* 0xA0 */ 22, 21, 20, 22, 22, 23, 23, 21, - /* 0xA8 */ 23, 22, 22, 24, 21, 22, 23, 23, - /* 0xB0 */ 21, 21, 22, 21, 23, 22, 23, 23, - /* 0xB8 */ 20, 22, 22, 22, 23, 22, 22, 23, - /* 0xC0 */ 26, 26, 20, 19, 22, 23, 22, 25, - /* 0xC8 */ 26, 26, 26, 27, 27, 26, 24, 25, - /* 0xD0 */ 19, 21, 26, 27, 27, 26, 27, 24, - /* 0xD8 */ 21, 21, 26, 26, 28, 27, 27, 27, - /* 0xE0 */ 20, 24, 20, 21, 22, 21, 21, 23, - /* 0xE8 */ 22, 22, 25, 25, 24, 24, 26, 23, - /* 0xF0 */ 26, 27, 26, 26, 27, 27, 27, 27, - /* 0xF8 */ 27, 28, 27, 27, 27, 27, 27, 26, - /* end! */ 30 - ]; - - const DEFAULT_COMPRESSION_THRESHOLD = 1024; - const DEFAULT_MAX_SIZE = 4096; - - private static $huffmanLookup; - private static $huffmanCodes; - private static $huffmanLengths; - - private static $indexMap = []; + /** @var HPackNative|HPackNghttp2 */ + private $implementation; - /** @var string[][] */ - private $headers = []; - - /** @var int */ - private $hardMaxSize = self::DEFAULT_MAX_SIZE; - - /** @var int Max table size. */ - private $currentMaxSize = self::DEFAULT_MAX_SIZE; - - /** @var int Current table size. */ - private $size = 0; - - /** Called via bindTo(), see end of file */ - private static function init() /* : void */ + public function __construct(int $tableSizeLimit = 4096) { - self::$huffmanLookup = self::huffmanLookupInit(); - self::$huffmanCodes = self::huffmanCodesInit(); - self::$huffmanLengths = self::huffmanLengthsInit(); - - foreach (\array_column(self::TABLE, 0) as $index => $name) { - if (isset(self::$indexMap[$name])) { - continue; - } - - self::$indexMap[$name] = $index + 1; + if (HPackNghttp2::isSupported()) { + $this->implementation = new HPackNghttp2($tableSizeLimit); + } else { + $this->implementation = new HPackNative($tableSizeLimit); } } - // (micro-)optimized decode - private static function huffmanLookupInit(): array - { - \gc_disable(); - $encodingAccess = []; - $terminals = []; - - foreach (self::HUFFMAN_CODE as $chr => $bits) { - $len = self::HUFFMAN_CODE_LENGTHS[$chr]; - - for ($bit = 0; $bit < 8; $bit++) { - $offlen = $len + $bit; - $next = &$encodingAccess[$bit]; - - for ($byte = ($offlen - 1) >> 3; $byte > 0; $byte--) { - $cur = \str_pad(\decbin(($bits >> ($byte * 8 - ((0x30 - $offlen) & 7))) & 0xFF), 8, "0", STR_PAD_LEFT); - if (isset($next[$cur]) && $next[$cur][0] !== $encodingAccess[0]) { - $next = &$next[$cur][0]; - } else { - $tmp = &$next; - unset($next); - $tmp[$cur] = [&$next, null]; - } - } - - $key = \str_pad( - \decbin($bits & ((1 << ((($offlen - 1) & 7) + 1)) - 1)), - (($offlen - 1) & 7) + 1, - "0", - STR_PAD_LEFT - ); - $next[$key] = [null, $chr > 0xFF ? "" : \chr($chr)]; - - if ($offlen & 7) { - $terminals[$offlen & 7][] = [$key, &$next]; - } else { - $next[$key][0] = &$encodingAccess[0]; - } - } - } - - $memoize = []; - for ($off = 7; $off > 0; $off--) { - foreach ($terminals[$off] as &$terminal) { - $key = $terminal[0]; - $next = &$terminal[1]; - - if ($next[$key][0] === null) { - foreach ($encodingAccess[$off] as $chr => &$cur) { - $next[($memoize[$key] ?? $memoize[$key] = \str_pad($key, 8, "0", STR_PAD_RIGHT)) | $chr] = - [&$cur[0], $next[$key][1] != "" ? $next[$key][1] . $cur[1] : ""]; - } - - unset($next[$key], $cur); - } - } - - unset($terminal); - } - - $memoize = []; - for ($off = 7; $off > 0; $off--) { - foreach ($terminals[$off] as &$terminal) { - $next = &$terminal[1]; - foreach ($next as $k => $v) { - if (\strlen($k) !== 1) { - $next[$memoize[$k] ?? $memoize[$k] = \chr(\bindec($k))] = $v; - unset($next[$k]); - } - } - } - } - - unset($tmp, $cur, $next, $terminals, $terminal); - \gc_enable(); - \gc_collect_cycles(); - - return $encodingAccess[0]; - } - /** - * @param string $input + * @param string $input Input to decode. + * @param int $maxSize Maximum deflated size. * - * @return string|null Returns null if decoding fails. + * @return array|null Decoded headers. */ - public static function huffmanDecode(string $input) /* : ?string */ + public function decode(string $input, int $maxSize): ?array { - $lookup = self::$huffmanLookup; - $lengths = self::$huffmanLengths; - $length = \strlen($input); - $out = \str_repeat("\0", $length / 5 * 8 + 1); // max length - - // Fail if EOS symbol is found. - if (\strpos($input, "\x3f\xff\xff\xff") !== false) { - return null; - } - - for ($bitCount = $off = $i = 0; $i < $length; $i++) { - $currentByte = $input[$i]; - - list($lookup, $chr) = $lookup[$currentByte]; - - if ($chr === null) { - continue; - } - - if ($chr === "") { - return null; - } - - $out[$off++] = $chr[0]; - $bitCount += $lengths[$chr[0]]; - - if (isset($chr[1])) { - $out[$off++] = $chr[1]; - $bitCount += $lengths[$chr[1]]; - } - } - - // Padding longer than 7-bits - if ($i && $chr === null) { - return null; - } - - // Check for 0's in padding - if ($bitCount & 7) { - $mask = 0xff >> ($bitCount & 7); - if ((\ord($input[$i - 1]) & $mask) !== $mask) { - return null; - } - } - - return \substr($out, 0, $off); - } - - private static function huffmanCodesInit(): array - { - $lookup = []; - - for ($chr = 0; $chr <= 0xFF; $chr++) { - $bits = self::HUFFMAN_CODE[$chr]; - $length = self::HUFFMAN_CODE_LENGTHS[$chr]; - - for ($bit = 0; $bit < 8; $bit++) { - $bytes = ($length + $bit - 1) >> 3; - - for ($byte = $bytes; $byte >= 0; $byte--) { - $lookup[$bit][\chr($chr)][] = \chr( - $byte - ? $bits >> ($length - ($bytes - $byte + 1) * 8 + $bit) - : ($bits << ((0x30 - $length - $bit) & 7)) - ); - } - } - } - - return $lookup; - } - - private static function huffmanLengthsInit(): array - { - $lengths = []; - - for ($chr = 0; $chr <= 0xFF; $chr++) { - $lengths[\chr($chr)] = self::HUFFMAN_CODE_LENGTHS[$chr]; - } - - return $lengths; - } - - public static function huffmanEncode(string $input): string - { - $codes = self::$huffmanCodes; - $lengths = self::$huffmanLengths; - - $length = \strlen($input); - $out = \str_repeat("\0", $length * 5 + 1); // max length - - for ($bitCount = $i = 0; $i < $length; $i++) { - $chr = $input[$i]; - $byte = $bitCount >> 3; - - foreach ($codes[$bitCount & 7][$chr] as $bits) { - // Note: |= can't be used with strings in PHP - $out[$byte] = $out[$byte] | $bits; - $byte++; - } - - $bitCount += $lengths[$chr]; - } - - if ($bitCount & 7) { - // Note: |= can't be used with strings in PHP - $out[$byte - 1] = $out[$byte - 1] | \chr(0xFF >> ($bitCount & 7)); - } - - return $i ? \substr($out, 0, $byte) : ''; - } - - /** @see RFC 7541 Appendix A */ - const LAST_INDEX = 61; - const TABLE = [ // starts at 1 - [":authority", ""], - [":method", "GET"], - [":method", "POST"], - [":path", "/"], - [":path", "/index.html"], - [":scheme", "http"], - [":scheme", "https"], - [":status", "200"], - [":status", "204"], - [":status", "206"], - [":status", "304"], - [":status", "400"], - [":status", "404"], - [":status", "500"], - ["accept-charset", ""], - ["accept-encoding", "gzip, deflate"], - ["accept-language", ""], - ["accept-ranges", ""], - ["accept", ""], - ["access-control-allow-origin", ""], - ["age", ""], - ["allow", ""], - ["authorization", ""], - ["cache-control", ""], - ["content-disposition", ""], - ["content-encoding", ""], - ["content-language", ""], - ["content-length", ""], - ["content-location", ""], - ["content-range", ""], - ["content-type", ""], - ["cookie", ""], - ["date", ""], - ["etag", ""], - ["expect", ""], - ["expires", ""], - ["from", ""], - ["host", ""], - ["if-match", ""], - ["if-modified-since", ""], - ["if-none-match", ""], - ["if-range", ""], - ["if-unmodified-since", ""], - ["last-modified", ""], - ["link", ""], - ["location", ""], - ["max-forwards", ""], - ["proxy-authentication", ""], - ["proxy-authorization", ""], - ["range", ""], - ["referer", ""], - ["refresh", ""], - ["retry-after", ""], - ["server", ""], - ["set-cookie", ""], - ["strict-transport-security", ""], - ["transfer-encoding", ""], - ["user-agent", ""], - ["vary", ""], - ["via", ""], - ["www-authenticate", ""] - ]; - - private static function decodeDynamicInteger(string &$input, int &$off): int - { - $c = \ord($input[$off++]); - $int = $c & 0x7f; - $i = 0; - - while ($c & 0x80) { - if (!isset($input[$off])) { - return -0x80; - } - - $c = \ord($input[$off++]); - $int += ($c & 0x7f) << (++$i * 7); - } - - return $int; - } - - /** - * @param int $maxSize Upper limit on table size. - */ - public function __construct(int $maxSize = self::DEFAULT_MAX_SIZE) - { - $this->hardMaxSize = $maxSize; - } - - /** - * Sets the upper limit on table size. Dynamic table updates requesting a size above this size will result in a - * decoding error (i.e., returning null from decode()). - * - * @param int $maxSize - */ - public function setTableSizeLimit(int $maxSize) /* : void */ - { - $this->hardMaxSize = $maxSize; - } - - /** - * Resizes the table to the given size, removing old entries as per section 4.4 if necessary. - * - * @param int|null $size - */ - private function resizeTable(int $size = null) /* : void */ - { - if ($size !== null) { - \assert($size <= $this->hardMaxSize); - $this->currentMaxSize = \min($size, $this->hardMaxSize); - } - - while ($this->size > $this->currentMaxSize) { - list($name, $value) = \array_pop($this->headers); - $this->size -= 32 + \strlen($name) + \strlen($value); - } + return $this->implementation->decode($input, $maxSize); } /** - * @param string $input Encoded headers. - * @param int $maxSize Maximum length of the decoded header string. + * @param array $headers Headers to encode. * - * @return string[][]|null Returns null if decoding fails or if $maxSize is exceeded. - */ - public function decode(string $input, int $maxSize) /* : ?array */ - { - $headers = []; - $off = 0; - $inputLength = \strlen($input); - $size = 0; - - // dynamic $table as per 2.3.2 - while ($off < $inputLength) { - $index = \ord($input[$off++]); - - if ($index & 0x80) { - // range check - if ($index <= self::LAST_INDEX + 0x80) { - if ($index === 0x80) { - return null; - } - - list($name, $value) = $headers[] = self::TABLE[$index - 0x81]; - } else { - if ($index == 0xff) { - $index = self::decodeDynamicInteger($input, $off) + 0xff; - } - - $index -= 0x81 + self::LAST_INDEX; - if (!isset($this->headers[$index])) { - return null; - } - - list($name, $value) = $headers[] = $this->headers[$index]; - } - } elseif (($index & 0x60) !== 0x20) { // (($index & 0x40) || !($index & 0x20)): bit 4: never index is ignored - $dynamic = (bool) ($index & 0x40); - - if ($index & ($dynamic ? 0x3f : 0x0f)) { // separate length - if ($dynamic) { - if ($index === 0x7f) { - $index = self::decodeDynamicInteger($input, $off) + 0x3f; - } else { - $index &= 0x3f; - } - } else { - $index &= 0x0f; - if ($index === 0x0f) { - $index = self::decodeDynamicInteger($input, $off) + 0x0f; - } - } - - if ($index <= self::LAST_INDEX) { - $header = self::TABLE[$index - 1]; - } else { - $header = $this->headers[$index - 1 - self::LAST_INDEX]; - } - } else { - if ($off >= $inputLength) { - return null; - } - - $length = \ord($input[$off++]); - $huffman = $length & 0x80; - $length &= 0x7f; - - if ($length === 0x7f) { - $length = self::decodeDynamicInteger($input, $off) + 0x7f; - } - - if ($inputLength - $off < $length || $length <= 0) { - return null; - } - - if ($huffman) { - $header = [self::huffmanDecode(\substr($input, $off, $length))]; - if ($header[0] === null) { - return null; - } - } else { - $header = [\substr($input, $off, $length)]; - } - - $off += $length; - } - - if ($off >= $inputLength) { - return null; - } - - $length = \ord($input[$off++]); - $huffman = $length & 0x80; - $length &= 0x7f; - - if ($length === 0x7f) { - $length = self::decodeDynamicInteger($input, $off) + 0x7f; - } - - if ($inputLength - $off < $length || $length < 0) { - return null; - } - - if ($huffman) { - $header[1] = self::huffmanDecode(\substr($input, $off, $length)); - if ($header[1] === null) { - return null; - } - } else { - $header[1] = \substr($input, $off, $length); - } - - $off += $length; - - if ($dynamic) { - \array_unshift($this->headers, $header); - $this->size += 32 + \strlen($header[0]) + \strlen($header[1]); - if ($this->currentMaxSize < $this->size) { - $this->resizeTable(); - } - } - - list($name, $value) = $headers[] = $header; - } else { // if ($index & 0x20) { - if ($off >= $inputLength) { - return null; // Dynamic table size update must not be the last entry in header block. - } - - $index &= 0x1f; - if ($index === 0x1f) { - $index = self::decodeDynamicInteger($input, $off) + 0x1f; - } - - if ($index > $this->hardMaxSize) { - return null; - } - - $this->resizeTable($index); - - continue; - } - - $size += \strlen($name) + \strlen($value); - - if ($size > $maxSize) { - return null; - } - } - - return $headers; - } - - private static function encodeDynamicInteger(int $int): string - { - $out = ""; - for ($i = 0; ($int >> $i) > 0x80; $i += 7) { - $out .= \chr(0x80 | (($int >> $i) & 0x7f)); - } - return $out . \chr($int >> $i); - } - - /** - * @param string[][] $headers - * @param int $compressionThreshold Compress strings whose length is at least the number of bytes given. + * @return string Encoded headers. * - * @return string + * @throws HPackException If encoding fails. */ - public function encode(array $headers, int $compressionThreshold = self::DEFAULT_COMPRESSION_THRESHOLD): string - { - // @TODO implementation is deliberately primitive... [doesn't use any dynamic table...] - $output = ""; - - foreach ($headers as $name => $values) { - foreach ((array) $values as $value) { - if (isset(self::$indexMap[$name])) { - $index = self::$indexMap[$name]; - if ($index < 0x10) { - $output .= \chr($index); - } else { - $output .= "\x0f" . \chr($index - 0x0f); - } - } else { - $output .= "\0" . $this->encodeString($name, $compressionThreshold); - } - - $output .= $this->encodeString($value, $compressionThreshold); - } - } - - return $output; - } - - private function encodeString(string $value, int $compressionThreshold): string + public function encode(array $headers): string { - $prefix = "\0"; - if (\strlen($value) >= $compressionThreshold) { - $value = self::huffmanEncode($value); - $prefix = "\x80"; - } - - if (\strlen($value) < 0x7f) { - return ($prefix | \chr(\strlen($value))) . $value; - } - - return ($prefix | "\x7f") . self::encodeDynamicInteger(\strlen($value) - 0x7f) . $value; + return $this->implementation->encode($headers); } } - -(function () { - static::init(); -})->bindTo(null, HPack::class)(); diff --git a/src/HPackException.php b/src/HPackException.php new file mode 100644 index 0000000..acbc34a --- /dev/null +++ b/src/HPackException.php @@ -0,0 +1,7 @@ + $name) { + if (isset(self::$indexMap[$name])) { + continue; + } + + self::$indexMap[$name] = $index + 1; + } + } + + // (micro-)optimized decode + private static function huffmanLookupInit(): array + { + \gc_disable(); + $encodingAccess = []; + $terminals = []; + + foreach (self::HUFFMAN_CODE as $chr => $bits) { + $len = self::HUFFMAN_CODE_LENGTHS[$chr]; + + for ($bit = 0; $bit < 8; $bit++) { + $offlen = $len + $bit; + $next = &$encodingAccess[$bit]; + + for ($byte = ($offlen - 1) >> 3; $byte > 0; $byte--) { + $cur = \str_pad(\decbin(($bits >> ($byte * 8 - ((0x30 - $offlen) & 7))) & 0xFF), 8, "0", STR_PAD_LEFT); + if (isset($next[$cur]) && $next[$cur][0] !== $encodingAccess[0]) { + $next = &$next[$cur][0]; + } else { + $tmp = &$next; + unset($next); + $tmp[$cur] = [&$next, null]; + } + } + + $key = \str_pad( + \decbin($bits & ((1 << ((($offlen - 1) & 7) + 1)) - 1)), + (($offlen - 1) & 7) + 1, + "0", + STR_PAD_LEFT + ); + $next[$key] = [null, $chr > 0xFF ? "" : \chr($chr)]; + + if ($offlen & 7) { + $terminals[$offlen & 7][] = [$key, &$next]; + } else { + $next[$key][0] = &$encodingAccess[0]; + } + } + } + + $memoize = []; + for ($off = 7; $off > 0; $off--) { + foreach ($terminals[$off] as &$terminal) { + $key = $terminal[0]; + $next = &$terminal[1]; + + if ($next[$key][0] === null) { + foreach ($encodingAccess[$off] as $chr => &$cur) { + $next[($memoize[$key] ?? $memoize[$key] = \str_pad($key, 8, "0", STR_PAD_RIGHT)) | $chr] = + [&$cur[0], $next[$key][1] != "" ? $next[$key][1] . $cur[1] : ""]; + } + + unset($next[$key], $cur); + } + } + + unset($terminal); + } + + $memoize = []; + for ($off = 7; $off > 0; $off--) { + foreach ($terminals[$off] as &$terminal) { + $next = &$terminal[1]; + foreach ($next as $k => $v) { + if (\strlen($k) !== 1) { + $next[$memoize[$k] ?? $memoize[$k] = \chr(\bindec($k))] = $v; + unset($next[$k]); + } + } + } + } + + unset($tmp, $cur, $next, $terminals, $terminal); + \gc_enable(); + \gc_collect_cycles(); + + return $encodingAccess[0]; + } + + /** + * @param string $input + * + * @return string|null Returns null if decoding fails. + */ + public static function huffmanDecode(string $input) /* : ?string */ + { + $lookup = self::$huffmanLookup; + $lengths = self::$huffmanLengths; + $length = \strlen($input); + $out = \str_repeat("\0", $length / 5 * 8 + 1); // max length + + // Fail if EOS symbol is found. + if (\strpos($input, "\x3f\xff\xff\xff") !== false) { + return null; + } + + for ($bitCount = $off = $i = 0; $i < $length; $i++) { + $currentByte = $input[$i]; + + [$lookup, $chr] = $lookup[$currentByte]; + + if ($chr === null) { + continue; + } + + if ($chr === "") { + return null; + } + + $out[$off++] = $chr[0]; + $bitCount += $lengths[$chr[0]]; + + if (isset($chr[1])) { + $out[$off++] = $chr[1]; + $bitCount += $lengths[$chr[1]]; + } + } + + // Padding longer than 7-bits + if ($i && $chr === null) { + return null; + } + + // Check for 0's in padding + if ($bitCount & 7) { + $mask = 0xff >> ($bitCount & 7); + if ((\ord($input[$i - 1]) & $mask) !== $mask) { + return null; + } + } + + return \substr($out, 0, $off); + } + + private static function huffmanCodesInit(): array + { + $lookup = []; + + for ($chr = 0; $chr <= 0xFF; $chr++) { + $bits = self::HUFFMAN_CODE[$chr]; + $length = self::HUFFMAN_CODE_LENGTHS[$chr]; + + for ($bit = 0; $bit < 8; $bit++) { + $bytes = ($length + $bit - 1) >> 3; + + for ($byte = $bytes; $byte >= 0; $byte--) { + $lookup[$bit][\chr($chr)][] = \chr( + $byte + ? $bits >> ($length - ($bytes - $byte + 1) * 8 + $bit) + : ($bits << ((0x30 - $length - $bit) & 7)) + ); + } + } + } + + return $lookup; + } + + private static function huffmanLengthsInit(): array + { + $lengths = []; + + for ($chr = 0; $chr <= 0xFF; $chr++) { + $lengths[\chr($chr)] = self::HUFFMAN_CODE_LENGTHS[$chr]; + } + + return $lengths; + } + + public static function huffmanEncode(string $input): string + { + $codes = self::$huffmanCodes; + $lengths = self::$huffmanLengths; + + $length = \strlen($input); + $out = \str_repeat("\0", $length * 5 + 1); // max length + + for ($bitCount = $i = 0; $i < $length; $i++) { + $chr = $input[$i]; + $byte = $bitCount >> 3; + + foreach ($codes[$bitCount & 7][$chr] as $bits) { + // Note: |= can't be used with strings in PHP + $out[$byte] = $out[$byte] | $bits; + $byte++; + } + + $bitCount += $lengths[$chr]; + } + + if ($bitCount & 7) { + // Note: |= can't be used with strings in PHP + $out[$byte - 1] = $out[$byte - 1] | \chr(0xFF >> ($bitCount & 7)); + } + + return $i ? \substr($out, 0, $byte) : ''; + } + + /** @see RFC 7541 Appendix A */ + const LAST_INDEX = 61; + const TABLE = [ // starts at 1 + [":authority", ""], + [":method", "GET"], + [":method", "POST"], + [":path", "/"], + [":path", "/index.html"], + [":scheme", "http"], + [":scheme", "https"], + [":status", "200"], + [":status", "204"], + [":status", "206"], + [":status", "304"], + [":status", "400"], + [":status", "404"], + [":status", "500"], + ["accept-charset", ""], + ["accept-encoding", "gzip, deflate"], + ["accept-language", ""], + ["accept-ranges", ""], + ["accept", ""], + ["access-control-allow-origin", ""], + ["age", ""], + ["allow", ""], + ["authorization", ""], + ["cache-control", ""], + ["content-disposition", ""], + ["content-encoding", ""], + ["content-language", ""], + ["content-length", ""], + ["content-location", ""], + ["content-range", ""], + ["content-type", ""], + ["cookie", ""], + ["date", ""], + ["etag", ""], + ["expect", ""], + ["expires", ""], + ["from", ""], + ["host", ""], + ["if-match", ""], + ["if-modified-since", ""], + ["if-none-match", ""], + ["if-range", ""], + ["if-unmodified-since", ""], + ["last-modified", ""], + ["link", ""], + ["location", ""], + ["max-forwards", ""], + ["proxy-authentication", ""], + ["proxy-authorization", ""], + ["range", ""], + ["referer", ""], + ["refresh", ""], + ["retry-after", ""], + ["server", ""], + ["set-cookie", ""], + ["strict-transport-security", ""], + ["transfer-encoding", ""], + ["user-agent", ""], + ["vary", ""], + ["via", ""], + ["www-authenticate", ""] + ]; + + private static function decodeDynamicInteger(string &$input, int &$off): int + { + $c = \ord($input[$off++]); + $int = $c & 0x7f; + $i = 0; + + while ($c & 0x80) { + if (!isset($input[$off])) { + return -0x80; + } + + $c = \ord($input[$off++]); + $int += ($c & 0x7f) << (++$i * 7); + } + + return $int; + } + + /** + * @param int $maxSize Upper limit on table size. + */ + public function __construct(int $maxSize = self::DEFAULT_MAX_SIZE) + { + $this->hardMaxSize = $maxSize; + } + + /** + * Sets the upper limit on table size. Dynamic table updates requesting a size above this size will result in a + * decoding error (i.e., returning null from decode()). + * + * @param int $maxSize + */ + public function setTableSizeLimit(int $maxSize) /* : void */ + { + $this->hardMaxSize = $maxSize; + } + + /** + * Resizes the table to the given size, removing old entries as per section 4.4 if necessary. + * + * @param int|null $size + */ + public function resizeTable(int $size = null) /* : void */ + { + if ($size !== null) { + $this->currentMaxSize = \min($size, $this->hardMaxSize); + } + + while ($this->size > $this->currentMaxSize) { + [$name, $value] = \array_pop($this->headers); + $this->size -= 32 + \strlen($name) + \strlen($value); + } + } + + /** + * @param string $input Encoded headers. + * @param int $maxSize Maximum length of the decoded header string. + * + * @return string[][]|null Returns null if decoding fails or if $maxSize is exceeded. + */ + public function decode(string $input, int $maxSize) /* : ?array */ + { + $headers = []; + $off = 0; + $inputLength = \strlen($input); + $size = 0; + + // dynamic $table as per 2.3.2 + while ($off < $inputLength) { + $index = \ord($input[$off++]); + + if ($index & 0x80) { + // range check + if ($index <= self::LAST_INDEX + 0x80) { + if ($index === 0x80) { + return null; + } + + [$name, $value] = $headers[] = self::TABLE[$index - 0x81]; + } else { + if ($index == 0xff) { + $index = self::decodeDynamicInteger($input, $off) + 0xff; + } + + $index -= 0x81 + self::LAST_INDEX; + if (!isset($this->headers[$index])) { + return null; + } + + [$name, $value] = $headers[] = $this->headers[$index]; + } + } elseif (($index & 0x60) !== 0x20) { // (($index & 0x40) || !($index & 0x20)): bit 4: never index is ignored + $dynamic = (bool) ($index & 0x40); + + if ($index & ($dynamic ? 0x3f : 0x0f)) { // separate length + if ($dynamic) { + if ($index === 0x7f) { + $index = self::decodeDynamicInteger($input, $off) + 0x3f; + } else { + $index &= 0x3f; + } + } else { + $index &= 0x0f; + if ($index === 0x0f) { + $index = self::decodeDynamicInteger($input, $off) + 0x0f; + } + } + + if ($index <= self::LAST_INDEX) { + $header = self::TABLE[$index - 1]; + } else { + $header = $this->headers[$index - 1 - self::LAST_INDEX]; + } + } else { + if ($off >= $inputLength) { + return null; + } + + $length = \ord($input[$off++]); + $huffman = $length & 0x80; + $length &= 0x7f; + + if ($length === 0x7f) { + $length = self::decodeDynamicInteger($input, $off) + 0x7f; + } + + if ($inputLength - $off < $length || $length <= 0) { + return null; + } + + if ($huffman) { + $header = [self::huffmanDecode(\substr($input, $off, $length))]; + if ($header[0] === null) { + return null; + } + } else { + $header = [\substr($input, $off, $length)]; + } + + $off += $length; + } + + if ($off >= $inputLength) { + return null; + } + + $length = \ord($input[$off++]); + $huffman = $length & 0x80; + $length &= 0x7f; + + if ($length === 0x7f) { + $length = self::decodeDynamicInteger($input, $off) + 0x7f; + } + + if ($inputLength - $off < $length || $length < 0) { + return null; + } + + if ($huffman) { + $header[1] = self::huffmanDecode(\substr($input, $off, $length)); + if ($header[1] === null) { + return null; + } + } else { + $header[1] = \substr($input, $off, $length); + } + + $off += $length; + + if ($dynamic) { + \array_unshift($this->headers, $header); + $this->size += 32 + \strlen($header[0]) + \strlen($header[1]); + if ($this->currentMaxSize < $this->size) { + $this->resizeTable(); + } + } + + [$name, $value] = $headers[] = $header; + } else { // if ($index & 0x20) { + if ($off >= $inputLength) { + return null; // Dynamic table size update must not be the last entry in header block. + } + + $index &= 0x1f; + if ($index === 0x1f) { + $index = self::decodeDynamicInteger($input, $off) + 0x1f; + } + + if ($index > $this->hardMaxSize) { + return null; + } + + $this->resizeTable($index); + + continue; + } + + $size += \strlen($name) + \strlen($value); + + if ($size > $maxSize) { + return null; + } + } + + return $headers; + } + + private static function encodeDynamicInteger(int $int): string + { + $out = ""; + for ($i = 0; ($int >> $i) > 0x80; $i += 7) { + $out .= \chr(0x80 | (($int >> $i) & 0x7f)); + } + return $out . \chr($int >> $i); + } + + /** + * @param string[][] $headers + * @param int $compressionThreshold Compress strings whose length is at least the number of bytes given. + * + * @return string + */ + public function encode(array $headers, int $compressionThreshold = self::DEFAULT_COMPRESSION_THRESHOLD): string + { + // @TODO implementation is deliberately primitive... [doesn't use any dynamic table...] + $output = ""; + + foreach ($headers as [$name, $value]) { + if (isset(self::$indexMap[$name])) { + $index = self::$indexMap[$name]; + if ($index < 0x10) { + $output .= \chr($index); + } else { + $output .= "\x0f" . \chr($index - 0x0f); + } + } else { + $output .= "\0" . $this->encodeString($name, $compressionThreshold); + } + + $output .= $this->encodeString($value, $compressionThreshold); + } + + return $output; + } + + private function encodeString(string $value, int $compressionThreshold): string + { + $prefix = "\0"; + if (\strlen($value) >= $compressionThreshold) { + $value = self::huffmanEncode($value); + $prefix = "\x80"; + } + + if (\strlen($value) < 0x7f) { + return ($prefix | \chr(\strlen($value))) . $value; + } + + return ($prefix | "\x7f") . self::encodeDynamicInteger(\strlen($value) - 0x7f) . $value; + } +} + +(function () { + static::init(); +})->bindTo(null, HPackNative::class)(); diff --git a/src/Internal/HPackNghttp2.php b/src/Internal/HPackNghttp2.php new file mode 100644 index 0000000..9974f3d --- /dev/null +++ b/src/Internal/HPackNghttp2.php @@ -0,0 +1,270 @@ + self::FLAG_NO_COPY_SENSITIVE, + 'cookie' => self::FLAG_NO_COPY_SENSITIVE, + 'proxy-authorization' => self::FLAG_NO_COPY_SENSITIVE, + 'set-cookie' => self::FLAG_NO_COPY_SENSITIVE, + ]; + + private static $ffi; + private static $deflatePtrType; + private static $inflatePtrType; + private static $nvType; + private static $nvSize; + private static $charType; + private static $uint8Type; + private static $uint8PtrType; + private static $decodeNv; + private static $decodeNvPtr; + private static $decodeFlags; + private static $decodeFlagsPtr; + private static $supported; + + public static function isSupported(): bool + { + if (isset(self::$supported)) { + return self::$supported; + } + + if (!\extension_loaded('ffi')) { + return self::$supported = false; + } + + if (!\class_exists(FFI::class)) { + return self::$supported = false; + } + + try { + self::init(); + + return self::$supported = true; + } catch (\Throwable $e) { + return self::$supported = false; + } + } + + private static function init(): void + { + if (self::$ffi) { + return; + } + + self::$ffi = FFI::cdef(' + +#define FFI_SCOPE "amphp-hpack-nghttp2" +#define FFI_LIB "libnghttp2.so" + +typedef struct nghttp2_hd_deflater nghttp2_hd_deflater; + +typedef struct nghttp2_hd_inflater nghttp2_hd_inflater; + +typedef struct { + uint8_t *name; + uint8_t *value; + + size_t namelen; + size_t valuelen; + + uint8_t flags; +} nghttp2_nv; + +int nghttp2_hd_deflate_new(nghttp2_hd_deflater **deflater_ptr, size_t deflate_hd_table_bufsize_max); + +ssize_t nghttp2_hd_deflate_hd(nghttp2_hd_deflater *deflater, uint8_t *buf, size_t buflen, const nghttp2_nv *nva, size_t nvlen); + +size_t nghttp2_hd_deflate_bound(nghttp2_hd_deflater *deflater, const nghttp2_nv *nva, size_t nvlen); + +int nghttp2_hd_inflate_new(nghttp2_hd_inflater **inflater_ptr); + +ssize_t nghttp2_hd_inflate_hd2(nghttp2_hd_inflater *inflater, nghttp2_nv *nv_out, int *inflate_flags, const uint8_t *in, size_t inlen, int in_final); + +int nghttp2_hd_inflate_end_headers(nghttp2_hd_inflater *inflater); + +', 'libnghttp2.so'); + + self::$deflatePtrType = self::$ffi->type('nghttp2_hd_deflater*'); + self::$inflatePtrType = self::$ffi->type('nghttp2_hd_inflater*'); + self::$nvType = self::$ffi->type('nghttp2_nv'); + self::$nvSize = FFI::sizeof(self::$nvType); + self::$charType = self::$ffi->type('char'); + self::$uint8Type = self::$ffi->type('uint8_t'); + self::$uint8PtrType = self::$ffi->type('uint8_t*'); + + self::$decodeNv = self::$ffi->new(self::$nvType); + self::$decodeNvPtr = FFI::addr(self::$decodeNv); + + self::$decodeFlags = self::$ffi->new('int'); + self::$decodeFlagsPtr = FFI::addr(self::$decodeFlags); + } + + private static function createBufferFromString(string $value) + { + $length = \strlen($value); + + $buffer = FFI::new(FFI::arrayType(self::$uint8Type, [$length])); + FFI::memcpy($buffer, $value, $length); + + return $buffer; + } + + private $deflatePtr; + private $inflatePtr; + + /** + * @param int $maxSize Upper limit on table size. + */ + public function __construct(int $maxSize = 4096) + { + self::init(); + + $this->deflatePtr = self::$ffi->new(self::$deflatePtrType); + $this->inflatePtr = self::$ffi->new(self::$inflatePtrType); + + $return = self::$ffi->nghttp2_hd_deflate_new(FFI::addr($this->deflatePtr), $maxSize); + if ($return !== 0) { + throw new \RuntimeException('Failed to init deflate context'); + } + + $return = self::$ffi->nghttp2_hd_inflate_new(FFI::addr($this->inflatePtr)); + if ($return !== 0) { + throw new \RuntimeException('Failed to init inflate context'); + } + } + + /** + * @param string $input Encoded headers. + * @param int $maxSize Maximum length of the decoded header string. + * + * @return string[][]|null Returns null if decoding fails or if $maxSize is exceeded. + */ + public function decode(string $input, int $maxSize): ?array + { + $ffi = self::$ffi; + $pair = self::$decodeNv; + $pairPtr = self::$decodeNvPtr; + $flags = self::$decodeFlags; + $flagsPtr = self::$decodeFlagsPtr; + $inflate = $this->inflatePtr; + + $size = 0; + + $bufferLength = \strlen($input); + $buffer = self::createBufferFromString($input); + + $offset = 0; + $bufferPtr = FFI::cast(self::$uint8PtrType, $buffer); + + $headers = []; + + while (true) { + $read = $ffi->nghttp2_hd_inflate_hd2($inflate, $pairPtr, $flagsPtr, $bufferPtr, $bufferLength - $offset, 1); + + if ($read < 0) { + return null; + } + + $offset += $read; + $bufferPtr += $read; + + $cFlags = $flags->cdata; + if ($cFlags & 0x02) { // NGHTTP2_HD_INFLATE_EMIT + $nameLength = $pair->namelen; + $valueLength = $pair->valuelen; + + $headers[] = [ + FFI::string($pair->name, $nameLength), + FFI::string($pair->value, $valueLength), + ]; + + $size += $nameLength + $valueLength; + + if ($size > $maxSize) { + return null; + } + } + + if ($cFlags & 0x01) { // NGHTTP2_HD_INFLATE_FINAL + $ffi->nghttp2_hd_inflate_end_headers($inflate); + + FFI::memset($pair, 0, self::$nvSize); + + return $headers; + } + + if ($read === 0 || $offset > $bufferLength) { + return null; + } + } + + return null; + } + + /** + * @param string[][] $headers + * + * @return string Encoded headers. + */ + public function encode(array $headers): string + { + $ffi = self::$ffi; + + // To keep memory buffers + $buffers = []; + + $headerCount = \count($headers); + $current = 0; + + $pairs = $ffi->new(FFI::arrayType(self::$nvType, [$headerCount])); + + foreach ($headers as $index => [$name, $value]) { + \assert($index === $current); + + $pair = $pairs[$current]; + + $nameBuffer = self::createBufferFromString($name); + $valueBuffer = self::createBufferFromString($value); + + $pair->name = FFI::cast(self::$uint8PtrType, $nameBuffer); + $pair->namelen = \strlen($name); + + $pair->value = FFI::cast(self::$uint8PtrType, $valueBuffer); + $pair->valuelen = \strlen($value); + + $pair->flags = self::SENSITIVE_HEADERS[$name] ?? self::FLAG_NO_COPY; + + $buffers[] = $nameBuffer; + $buffers[] = $valueBuffer; + + $current++; + } + + $bufferLength = $ffi->nghttp2_hd_deflate_bound($this->deflatePtr, $pairs, $headerCount); + $buffer = FFI::new(FFI::arrayType(self::$uint8Type, [$bufferLength])); + + $bufferLength = $ffi->nghttp2_hd_deflate_hd($this->deflatePtr, $buffer, $bufferLength, $pairs, $headerCount); + + if ($bufferLength < 0) { + throw new HPackException('Failed to compress headers using nghttp2'); + } + + return FFI::string($buffer, $bufferLength); + } +} diff --git a/test/HPackTest.php b/test/HPackTest.php index 9abbe91..1430306 100644 --- a/test/HPackTest.php +++ b/test/HPackTest.php @@ -1,31 +1,34 @@ list($input, $output)) { + $hpack = $this->createInstance(); + + foreach ($cases as $i => [$input, $output]) { $result = $hpack->decode($input, self::MAX_LENGTH); - $this->assertEquals($output, $result, "Failure on testcase #$i"); + $this->assertEquals($output, $result, "Failure on test case #$i"); } } - public function provideDecodeCases() + public function provideDecodeCases(): \Generator { - $root = __DIR__."/../vendor/http2jp/hpack-test-case"; + $root = __DIR__ . "/../vendor/http2jp/hpack-test-case"; $paths = \glob("$root/*/*.json"); + foreach ($paths as $path) { if (\basename(\dirname($path)) === "raw-data") { continue; @@ -33,63 +36,79 @@ public function provideDecodeCases() $data = \json_decode(\file_get_contents($path)); $cases = []; + foreach ($data->cases as $case) { foreach ($case->headers as &$header) { $header = (array) $header; $header = [\key($header), \current($header)]; } + $cases[$case->seqno] = [\hex2bin($case->wire), $case->headers]; } - yield \basename($path).": $data->description" => [$cases]; + + yield \basename($path) . ": $data->description" => [$cases]; } } /** - * @depends testDecode + * @depends testDecode * @dataProvider provideEncodeCases */ - public function testEncode($cases) + public function testEncode($cases): void { - foreach ($cases as $i => list($input, $output)) { - $hpack = new HPack; + foreach ($cases as $i => [$input, $output]) { + $hpack = $this->createInstance(); + $encoded = $hpack->encode($input); $decoded = $hpack->decode($encoded, self::MAX_LENGTH); + \sort($output); \sort($decoded); - $this->assertEquals($output, $decoded, "Failure on testcase #$i (standalone)"); + + $this->assertEquals($output, $decoded, "Failure on test case #$i (standalone)"); } // Ensure that usage of dynamic table works as expected - $encHpack = new HPack; - $decHpack = new HPack; - foreach ($cases as $i => list($input, $output)) { + $encHpack = $this->createInstance(); + $decHpack = $this->createInstance(); + + foreach ($cases as $i => [$input, $output]) { $encoded = $encHpack->encode($input); $decoded = $decHpack->decode($encoded, self::MAX_LENGTH); + \sort($output); \sort($decoded); - $this->assertEquals($output, $decoded, "Failure on testcase #$i (shared context)"); + + $this->assertEquals($output, $decoded, "Failure on test case #$i (shared context)"); } } - public function provideEncodeCases() + public function provideEncodeCases(): \Generator { - $root = __DIR__."/../vendor/http2jp/hpack-test-case"; + $root = __DIR__ . "/../vendor/http2jp/hpack-test-case"; $paths = \glob("$root/raw-data/*.json"); + foreach ($paths as $path) { $data = \json_decode(\file_get_contents($path)); $cases = []; $i = 0; + foreach ($data->cases as $case) { $headers = []; + foreach ($case->headers as &$header) { $header = (array) $header; $header = [\key($header), \current($header)]; - $headers[$header[0]][] = $header[1]; + $headers[] = $header; } + $cases[$case->seqno ?? $i] = [$headers, $case->headers]; $i++; } + yield \basename($path) . (isset($data->description) ? ": $data->description" : "") => [$cases]; } } + + abstract protected function createInstance(); } diff --git a/test/Internal/HPackNativeTest.php b/test/Internal/HPackNativeTest.php new file mode 100644 index 0000000..c37bf1e --- /dev/null +++ b/test/Internal/HPackNativeTest.php @@ -0,0 +1,13 @@ +markTestSkipped(HPackNghttp2::class . ' is not supported in the current environment'); + } + + return new HPackNghttp2; + } +} From 6c5407e589dd846dc1e6678ee4d731f506f47ced Mon Sep 17 00:00:00 2001 From: Aaron Piotrowski Date: Thu, 12 Dec 2019 15:21:41 -0600 Subject: [PATCH 2/2] Mac compatibility with nghttp2 --- src/Internal/HPackNghttp2.php | 37 ++++++----------------------------- src/Internal/amp-hpack.h | 29 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 31 deletions(-) create mode 100644 src/Internal/amp-hpack.h diff --git a/src/Internal/HPackNghttp2.php b/src/Internal/HPackNghttp2.php index 9974f3d..5fb3222 100644 --- a/src/Internal/HPackNghttp2.php +++ b/src/Internal/HPackNghttp2.php @@ -67,38 +67,13 @@ private static function init(): void return; } - self::$ffi = FFI::cdef(' + $header = \file_get_contents(__DIR__ . '/amp-hpack.h'); -#define FFI_SCOPE "amphp-hpack-nghttp2" -#define FFI_LIB "libnghttp2.so" - -typedef struct nghttp2_hd_deflater nghttp2_hd_deflater; - -typedef struct nghttp2_hd_inflater nghttp2_hd_inflater; - -typedef struct { - uint8_t *name; - uint8_t *value; - - size_t namelen; - size_t valuelen; - - uint8_t flags; -} nghttp2_nv; - -int nghttp2_hd_deflate_new(nghttp2_hd_deflater **deflater_ptr, size_t deflate_hd_table_bufsize_max); - -ssize_t nghttp2_hd_deflate_hd(nghttp2_hd_deflater *deflater, uint8_t *buf, size_t buflen, const nghttp2_nv *nva, size_t nvlen); - -size_t nghttp2_hd_deflate_bound(nghttp2_hd_deflater *deflater, const nghttp2_nv *nva, size_t nvlen); - -int nghttp2_hd_inflate_new(nghttp2_hd_inflater **inflater_ptr); - -ssize_t nghttp2_hd_inflate_hd2(nghttp2_hd_inflater *inflater, nghttp2_nv *nv_out, int *inflate_flags, const uint8_t *in, size_t inlen, int in_final); - -int nghttp2_hd_inflate_end_headers(nghttp2_hd_inflater *inflater); - -', 'libnghttp2.so'); + try { + self::$ffi = FFI::cdef($header, 'libnghttp2.so'); + } catch (\Throwable $exception) { + self::$ffi = FFI::cdef($header, 'libnghttp2.dylib'); + } self::$deflatePtrType = self::$ffi->type('nghttp2_hd_deflater*'); self::$inflatePtrType = self::$ffi->type('nghttp2_hd_inflater*'); diff --git a/src/Internal/amp-hpack.h b/src/Internal/amp-hpack.h new file mode 100644 index 0000000..d5846ac --- /dev/null +++ b/src/Internal/amp-hpack.h @@ -0,0 +1,29 @@ + +#define FFI_SCOPE "amphp-hpack-nghttp2" +#define FFI_LIB "libnghttp2.so" + +typedef struct nghttp2_hd_deflater nghttp2_hd_deflater; + +typedef struct nghttp2_hd_inflater nghttp2_hd_inflater; + +typedef struct { + uint8_t *name; + uint8_t *value; + + size_t namelen; + size_t valuelen; + + uint8_t flags; +} nghttp2_nv; + +int nghttp2_hd_deflate_new(nghttp2_hd_deflater **deflater_ptr, size_t deflate_hd_table_bufsize_max); + +ssize_t nghttp2_hd_deflate_hd(nghttp2_hd_deflater *deflater, uint8_t *buf, size_t buflen, const nghttp2_nv *nva, size_t nvlen); + +size_t nghttp2_hd_deflate_bound(nghttp2_hd_deflater *deflater, const nghttp2_nv *nva, size_t nvlen); + +int nghttp2_hd_inflate_new(nghttp2_hd_inflater **inflater_ptr); + +ssize_t nghttp2_hd_inflate_hd2(nghttp2_hd_inflater *inflater, nghttp2_nv *nv_out, int *inflate_flags, const uint8_t *in, size_t inlen, int in_final); + +int nghttp2_hd_inflate_end_headers(nghttp2_hd_inflater *inflater);