diff --git a/uri/UriTemplate/Operator.php b/uri/UriTemplate/Operator.php index 0316b80..1e49792 100644 --- a/uri/UriTemplate/Operator.php +++ b/uri/UriTemplate/Operator.php @@ -19,10 +19,10 @@ use Stringable; use function implode; use function is_array; +use function mb_substr; use function preg_match; use function rawurlencode; use function str_contains; -use function substr; /** * Processing behavior according to the expression type operator. @@ -156,7 +156,9 @@ enum Operator: string } if (':' === $varSpec->modifier) { - $value = substr($value, 0, $varSpec->position); + // RFC 6570 section 2.4.1: the prefix length counts characters, not octets, + // so truncating has to be multibyte aware. + $value = mb_substr($value, 0, $varSpec->position, 'UTF-8'); } return [$this->decode($value), $this->isNamed()]; diff --git a/uri/UriTemplateTest.php b/uri/UriTemplateTest.php index 281f2fc..a9985de 100644 --- a/uri/UriTemplateTest.php +++ b/uri/UriTemplateTest.php @@ -261,6 +261,20 @@ final class UriTemplateTest extends TestCase self::assertSame($expectedUri, $uriTemplate->expandToUrlOrFail($variables)->toAsciiString()); } + public function testPrefixModifierTruncatesByCharacterNotByte(): void + { + // RFC 6570 section 2.4.1: the prefix counts characters, so a multibyte + // character must never be split. The euro sign and the G-clef are 3 and 4 + // bytes but one character each. + $uriTemplate = new UriTemplate('{?currency:1}{clef:1}'); + $variables = [ + 'currency' => "\u{20AC}uro", + 'clef' => "\u{1D11E}stave", + ]; + + self::assertSame('?currency=%E2%82%AC%F0%9D%84%9E', $uriTemplate->expand($variables)->toString()); + } + public function testDisallowNestedArrayExpansion(): void { $template = 'http://example.com{?query,data*,foo*}';