diff --git a/src/Bytes.php b/src/Bytes.php index 33141bc..6437349 100644 --- a/src/Bytes.php +++ b/src/Bytes.php @@ -4,7 +4,7 @@ namespace gapple\StructuredFields; -class Bytes +class Bytes implements \Stringable { public function __construct(private readonly string $value) { diff --git a/src/Dictionary.php b/src/Dictionary.php index c07259b..4c2fd55 100644 --- a/src/Dictionary.php +++ b/src/Dictionary.php @@ -37,7 +37,6 @@ public static function fromArray(array $array): self } /** - * @param string $name * @return TupleInterface|array{mixed, object}|null */ public function __get(string $name): mixed @@ -46,9 +45,7 @@ public function __get(string $name): mixed } /** - * @param string $name * @param TupleInterface|array{mixed, object} $value - * @return void */ public function __set(string $name, mixed $value): void { diff --git a/src/DisplayString.php b/src/DisplayString.php index ecc6cb0..ef9aca2 100644 --- a/src/DisplayString.php +++ b/src/DisplayString.php @@ -4,7 +4,7 @@ namespace gapple\StructuredFields; -class DisplayString +class DisplayString implements \Stringable { public function __construct(private readonly string $value) { diff --git a/src/InnerList.php b/src/InnerList.php index 88cbca3..11a3160 100644 --- a/src/InnerList.php +++ b/src/InnerList.php @@ -10,7 +10,6 @@ class InnerList implements TupleInterface /** * @param array $value - * @param object|null $parameters */ public function __construct(array $value, ?object $parameters = null) { @@ -25,11 +24,10 @@ public function __construct(array $value, ?object $parameters = null) * * @param array $array * An array of bare items or TupleInterface objects. - * @return InnerList */ public static function fromArray(array $array): InnerList { - array_walk($array, function (&$item) { + array_walk($array, function (&$item): void { if (!$item instanceof TupleInterface) { $item = new Item($item); } elseif ($item instanceof InnerList) { @@ -43,7 +41,6 @@ public static function fromArray(array $array): InnerList /** * @param TupleInterface|array{mixed, object} $value - * @return void */ private static function validateItemType(mixed $value): void { diff --git a/src/Item.php b/src/Item.php index a89cda9..6f11f7a 100644 --- a/src/Item.php +++ b/src/Item.php @@ -8,10 +8,6 @@ class Item implements TupleInterface { use TupleTrait; - /** - * @param mixed $value - * @param object|null $parameters - */ public function __construct(mixed $value, ?object $parameters = null) { $this->value = $value; diff --git a/src/OuterList.php b/src/OuterList.php index ea3bcfd..05708c6 100644 --- a/src/OuterList.php +++ b/src/OuterList.php @@ -31,11 +31,10 @@ public function __construct(array $value = []) * Create an OuterList from an array of bare values. * * @param array $array - * @return OuterList */ public static function fromArray(array $array): OuterList { - array_walk($array, function (&$item) { + array_walk($array, function (&$item): void { if (!$item instanceof TupleInterface) { if (is_array($item)) { $item = InnerList::fromArray($item); @@ -51,7 +50,6 @@ public static function fromArray(array $array): OuterList /** * @param TupleInterface|array{mixed, object} $value - * @return void */ private static function validateItemType(mixed $value): void { @@ -77,7 +75,6 @@ public function getIterator(): \Iterator /** * @param int $offset - * @return bool */ public function offsetExists($offset): bool { @@ -86,7 +83,6 @@ public function offsetExists($offset): bool /** * @param int $offset - * @return mixed * @phpstan-return TupleInterface|array{mixed, object}|null */ public function offsetGet($offset): mixed @@ -97,7 +93,6 @@ public function offsetGet($offset): mixed /** * @param int|null $offset * @param TupleInterface|array{mixed, object} $value - * @return void */ public function offsetSet($offset, $value): void { @@ -112,7 +107,6 @@ public function offsetSet($offset, $value): void /** * @param int $offset - * @return void */ public function offsetUnset($offset): void { diff --git a/src/Parameters.php b/src/Parameters.php index 63aefb7..8d0f76b 100644 --- a/src/Parameters.php +++ b/src/Parameters.php @@ -26,20 +26,11 @@ public static function fromArray(array $array): self return $parameters; } - /** - * @param string $name - * @return mixed|null - */ public function __get(string $name): mixed { return $this->value[$name] ?? null; } - /** - * @param string $name - * @param mixed $value - * @return void - */ public function __set(string $name, mixed $value): void { $this->value[$name] = $value; diff --git a/src/Parser.php b/src/Parser.php index f173274..8574963 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -20,8 +20,7 @@ public static function parseDictionary(string $string): Dictionary while (true) { $key = self::parseKey($input); - if ($input->isChar('=')) { - $input->consumeChar(); + if ($input->skipNextCharIf('=')) { $value->{$key} = self::parseItemOrInnerList($input); } else { // Bare boolean true value. @@ -85,7 +84,7 @@ public static function parseList(string $string): OuterList private static function parseItemOrInnerList(ParsingInput $input): TupleInterface { - if ($input->isChar('(')) { + if ($input->isNextChar('(')) { return self::parseInnerList($input); } else { return self::doParseItem($input); @@ -104,8 +103,7 @@ private static function parseInnerList(ParsingInput $input): InnerList while (!$input->empty()) { $input->trim(); - if ($input->isChar(')')) { - $input->consumeChar(); + if ($input->skipNextCharIf(')')) { return new InnerList( $value, self::parseParameters($input) @@ -114,7 +112,7 @@ private static function parseInnerList(ParsingInput $input): InnerList $value[] = self::doParseItem($input); - if (!($input->isChar(' ') || $input->isChar(')'))) { + if (!($input->isNextChar(' ') || $input->isNextChar(')'))) { if ($input->empty()) { break; } @@ -126,8 +124,6 @@ private static function parseInnerList(ParsingInput $input): InnerList } /** - * @param string $string - * * @return Item * A [value, parameters] tuple. */ @@ -190,16 +186,15 @@ private static function parseBareItem(ParsingInput $input): mixed private static function parseParameters(ParsingInput $input): Parameters { $parameters = new Parameters(); - while ($input->isChar(';')) { - $input->consumeChar(); + while ($input->skipNextCharIf(';')) { $input->trim(); $key = self::parseKey($input); - $parameters->{$key} = true; - if ($input->isChar('=')) { - $input->consumeChar(); + if ($input->skipNextCharIf('=')) { $parameters->{$key} = self::parseBareItem($input); + } else { + $parameters->{$key} = true; } } @@ -275,7 +270,7 @@ private static function parseString(ParsingInput $input): string } } elseif ($char === '"') { return $output; - } elseif (ord($char) <= 0x1f || ord($char) >= 0x7f) { + } elseif (!ctype_print($char)) { throw new ParseException('Invalid character in string at position ' . ($input->position() - 1)); } @@ -301,18 +296,21 @@ private static function parseDisplayString(ParsingInput $string): DisplayString while (!$string->empty()) { $char = $string->consumeChar(); - if (ord($char) <= 0x1f || ord($char) >= 0x7f) { + if (!ctype_print($char)) { throw new ParseException( 'Invalid character in display string at position ' . ($string->position() - 1) ); } elseif ($char === '%') { - try { - $encodedString .= '%' . $string->consumeRegex('/^[0-9a-f]{2}/'); - } catch (\RuntimeException) { + if ($string->remainingLength() < 2) { + break; + } + $encodedChar = $string->consume(2); + if (!ctype_xdigit($encodedChar) || ctype_upper($encodedChar)) { throw new ParseException( 'Invalid hex values in display string at position ' . ($string->position() - 1) ); } + $encodedString .= '%' . $encodedChar; } elseif ($char === '"') { $displayString = new DisplayString(rawurldecode($encodedString)); // An invalid UTF-8 subject will cause the preg_* function to match nothing. @@ -334,16 +332,19 @@ private static function parseDisplayString(ParsingInput $string): DisplayString */ private static function parseToken(ParsingInput $input): Token { - // Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing - // 3.2.6. Field Value Components - // @see https://tools.ietf.org/html/rfc7230#section-3.2.6 - $tchar = preg_quote("!#$%&'*+-.^_`|~"); + // RFC 9110: HTTP Semantics (5.6.2. Tokens) + // @see https://www.rfc-editor.org/rfc/rfc9110.html#name-tokens + // $tchar = preg_quote("!#$%&'*+-.^_`|~"); + $tchar = "!#$%&'*+\-.^_`|~"; // parseToken is only called by parseBareItem if the initial character // is valid, so a Token object is always returned. If there is an // invalid character in the token, the public function that was called // will detect that the remainder of the input string is invalid. - return new Token($input->consumeRegex('/^([a-z*][a-z0-9:\/' . $tchar . ']*)/i')); + return new Token($input->consumeRegex('/^( + (?:\*|[a-z]) # an alphabetic character or "*" + [a-z0-9:\/' . $tchar . ']* # zero to many token characters + )/ix')); } /** diff --git a/src/ParsingInput.php b/src/ParsingInput.php index 89bb1a2..fa09a0b 100644 --- a/src/ParsingInput.php +++ b/src/ParsingInput.php @@ -33,13 +33,17 @@ public function remaining(): string return substr($this->value, $this->position); } + public function remainingLength(): int + { + return $this->length - $this->position(); + } + /** * Trim whitespace from beginning of string. * * @param bool $ows * Whether all Optional Whitespace characters should be trimmed. If false, only space characters are trimmed. * @see https://tools.ietf.org/html/rfc7230#section-3.2.3 - * @return void */ public function trim(bool $ows = false): void { @@ -54,7 +58,16 @@ public function trim(bool $ows = false): void } } + /** + * @deprecated in 2.3.0 and will be removed in 3.0.0 + * @codeCoverageIgnore + */ public function isChar(string $char): bool + { + return $this->isNextChar($char); + } + + public function isNextChar(string $char): bool { assert(strlen($char) === 1); @@ -70,6 +83,15 @@ public function getChar(): string return $this->value[$this->position]; } + public function skipNextCharIf(string $char): bool + { + if ($this->isNextChar($char)) { + $this->position++; + return true; + } + return false; + } + /** * @phpstan-impure */ @@ -114,6 +136,7 @@ public function consumeString(string $value): void public function consumeRegex(string $pattern): string { assert(str_starts_with($pattern, '/^')); + assert(!preg_match('/\$\/[a-z]+$/i', $pattern)); if (preg_match($pattern, $this->remaining(), $matches)) { $this->position += strlen($matches[0]); diff --git a/src/Serializer.php b/src/Serializer.php index d86c4df..f5874fd 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -9,13 +9,10 @@ class Serializer /** * Serialize an item with optional parameters. * - * @param mixed $value + * @param Item|mixed $value * A bare value, or an Item object. * @param object|null $parameters - * An optional object containing parameter values if a bare value is provided. - * - * @return string - * The serialized value. + * If a bare value is provided, an optional object containing parameter values. */ public static function serializeItem(mixed $value, ?object $parameters = null): string { @@ -43,7 +40,6 @@ public static function serializeItem(mixed $value, ?object $parameters = null): /** * @param iterable $value - * @return string */ public static function serializeList(iterable $value): string { @@ -205,11 +201,10 @@ private static function serializeDecimal(float $value): string private static function serializeString(string $value): string { - if (preg_match('/[^\x20-\x7E]/i', $value)) { + if (!empty($value) && !ctype_print($value)) { throw new SerializeException("Invalid characters in string"); } - - return '"' . preg_replace('/(["\\\])/', '\\\$1', $value) . '"'; + return '"' . str_replace(['\\', '"'], ['\\\\', '\"'], $value) . '"'; } private static function serializeDisplayString(DisplayString $value): string @@ -224,12 +219,17 @@ private static function serializeDisplayString(DisplayString $value): string private static function serializeToken(Token $value): string { - // Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing - // 3.2.6. Field Value Components - // @see https://tools.ietf.org/html/rfc7230#section-3.2.6 - $tchar = preg_quote("!#$%&'*+-.^_`|~"); - - if (!preg_match('/^((?:\*|[a-z])[a-z0-9:\/' . $tchar . ']*)$/i', (string) $value)) { + // RFC 9110: HTTP Semantics (5.6.2. Tokens) + // @see https://www.rfc-editor.org/rfc/rfc9110.html#name-tokens + // $tchar = preg_quote("!#$%&'*+-.^_`|~"); + $tchar = "!#$%&'*+\-.^_`|~"; + + if ( + !preg_match('/^( + (?:\*|[a-z]) # an alphabetic character or "*" + [a-z0-9:\/' . $tchar . ']* # zero to many token characters + )$/ix', (string) $value) + ) { throw new SerializeException('Invalid characters in token'); } diff --git a/src/Token.php b/src/Token.php index 9083357..a8543ea 100644 --- a/src/Token.php +++ b/src/Token.php @@ -4,7 +4,7 @@ namespace gapple\StructuredFields; -class Token +class Token implements \Stringable { public function __construct(private readonly string $value) { diff --git a/src/TupleTrait.php b/src/TupleTrait.php index 86c3329..5a9bdb6 100644 --- a/src/TupleTrait.php +++ b/src/TupleTrait.php @@ -39,7 +39,6 @@ public function offsetExists($offset): bool /** * @param 0|1 $offset - * @return mixed * @phpstan-return ($offset is 0 ? mixed : $offset is 1 ? object : null) */ public function offsetGet($offset): mixed diff --git a/tests/Httpwg/HttpwgRuleExpectedConverter.php b/tests/Httpwg/HttpwgRuleExpectedConverter.php index 09a118d..13f4161 100644 --- a/tests/Httpwg/HttpwgRuleExpectedConverter.php +++ b/tests/Httpwg/HttpwgRuleExpectedConverter.php @@ -29,7 +29,6 @@ class HttpwgRuleExpectedConverter * Convert the expected value of an item tuple. * * @param ExpectedItem $item - * @return Item */ public static function item(array $item): Item { @@ -40,7 +39,6 @@ public static function item(array $item): Item * Convert the expected values of a dictionary. * * @param ExpectedDictionary $dictionary - * @return Dictionary */ public static function dictionary(array $dictionary): Dictionary { @@ -66,7 +64,6 @@ public static function dictionary(array $dictionary): Dictionary * Convert the expected values of a list. * * @param ExpectedOuterList $list - * @return OuterList */ public static function list(array $list): OuterList { @@ -86,8 +83,7 @@ public static function list(array $list): OuterList /** * Convert the expected values of a parameters map. * - * @param ExpectedParameters $parameters - * @return Parameters + * @param ExpectedParameters $parameters */ private static function parameters(array $parameters): Parameters { @@ -95,7 +91,7 @@ private static function parameters(array $parameters): Parameters foreach ($parameters as $value) { // Null byte is not supported as first character of property name. - if (strpos($value[0], "\0") === 0) { + if (str_starts_with($value[0], "\0")) { throw new \UnexpectedValueException(); } @@ -109,7 +105,6 @@ private static function parameters(array $parameters): Parameters * Convert the expected values of an inner list tuple. * * @param ExpectedInnerList $innerList - * @return InnerList */ private static function innerList(array $innerList): InnerList { diff --git a/tests/ParsingInputTest.php b/tests/ParsingInputTest.php index fa347f9..221f4f8 100644 --- a/tests/ParsingInputTest.php +++ b/tests/ParsingInputTest.php @@ -4,6 +4,7 @@ use gapple\StructuredFields\ParsingInput; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresSetting; use PHPUnit\Framework\TestCase; class ParsingInputTest extends TestCase @@ -21,6 +22,7 @@ public function testGetEmptyChar(): void public static function trimProvider(): array { return [ + // [input string, trim optional white space, expected remaining string] 'space' => [' test ', false, 'test '], 'ows' => [" \t test ", true, 'test '], 'non-ows' => [" \t test ", false, "\t test "], @@ -51,4 +53,37 @@ public function testConsumeNotMatched(): void $this->expectExceptionMessage('Unexpected character'); $input->consumeString('foo'); } + + /** + * @return array + */ + public static function regexProvider(): array + { + return [ + 'Valid' => ['/^test/', true], + 'Valid with modifier' => ['/^test/i', true], + 'Missing start anchor' => ['/test/', false], + 'End anchor' => ['/test$/', false], + 'End anchor and modifier' => ['/test$/i', false], + ]; + } + + #[RequiresSetting('zend.assertions', '1')] + #[DataProvider('regexProvider')] + public function testConsumeRegex(string $regex, bool $expected): void + { + try { + $input = new ParsingInput('test'); + $input->consumeRegex($regex); + + if (!$expected) { + $this->fail('Expression should not have passed assertions'); + } + } catch (\AssertionError $e) { + if ($expected) { + $this->fail('Expression failed assertion: ' . $e->getMessage()); + } + } + $this->addToAssertionCount(1); + } }