From 98e2fac9c106d78051b1a318410bfa9c125d4320 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 06:07:07 +0200 Subject: [PATCH] fix: truncate URI Template prefix modifier by character, not byte (#192) Operator::inject() applied the ":N" prefix modifier with the byte-oriented substr(), splitting multibyte UTF-8 characters in half and producing invalid percent-encoded output (e.g. the euro sign truncated to its first byte only). RFC 6570 Section 2.4.1 defines the prefix length as a count of characters, so use mb_substr() with an explicit UTF-8 encoding instead. ext-mbstring is already a hard dependency of this package. --- uri/UriTemplate/Operator.php | 6 ++++-- uri/UriTemplateTest.php | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) 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*}';