From 6af0a9d27d0b57dcc3d0c57b21d93820c52bf898 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Wed, 16 Sep 2026 17:02:07 +0200 Subject: [PATCH 1/9] fix: wrong return type / invalid-leading-byte --- src/Helper/Char.php | 2 +- tests/Helper/CharTest.php | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Helper/Char.php b/src/Helper/Char.php index 6bc7878..2faefc7 100644 --- a/src/Helper/Char.php +++ b/src/Helper/Char.php @@ -168,7 +168,7 @@ public function toUnicode(): int if ($h <= 0x7F) { return $h; } elseif ($h < 0xC2) { - return false; + return -1; } elseif ($h <= 0xDF) { return ($h & 0x1F) << 6 | (ord($this->char[1]) & 0x3F); } elseif ($h <= 0xEF) { diff --git a/tests/Helper/CharTest.php b/tests/Helper/CharTest.php index d13cb38..cc905e0 100644 --- a/tests/Helper/CharTest.php +++ b/tests/Helper/CharTest.php @@ -256,6 +256,13 @@ public function testUnicode1Byte() static::calculateUTF8Ordinal("\x7F"), Char::fromHex("7F")->toUnicode() ); + + // + // INVALID LEADING BYTE (< 0xC2) + // e.g., 0x80 – 0xC1 should return false + // + $this->assertEquals(-1, Char::fromHex('80')->toUnicode()); + $this->assertEquals(-1, Char::fromHex('C1')->toUnicode()); } public function testFromHexRejectsMalformedInput(): void From 2c5b827adaab9c7a4ddcca7d0f0fa3a602fb6efe Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Wed, 16 Sep 2026 17:16:58 +0200 Subject: [PATCH 2/9] fix: char 2-byte utf-8 --- src/Helper/Char.php | 15 +++++++++++---- tests/Helper/CharTest.php | 9 +++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Helper/Char.php b/src/Helper/Char.php index 2faefc7..73b7211 100644 --- a/src/Helper/Char.php +++ b/src/Helper/Char.php @@ -27,7 +27,7 @@ class Char */ public function __construct(string $char) { - if (strlen($char) != 1) { + if (mb_strlen($char, 'UTF-8') !== 1) { throw new HelperException("char parameter may not contain more or less than one character"); } @@ -199,11 +199,18 @@ public function toHex(): string */ public static function fromHex(string $hex): Char { - if (strlen($hex) != 2 || !ctype_xdigit($hex)) { - throw new HelperException("given parameter '" . $hex . "' is not a valid hexadecimal number"); + // Check: only even numbers of hex characters allowed, all must be valid + if (strlen($hex) % 2 !== 0 || ! ctype_xdigit($hex)) { + throw new HelperException("given parameter '".$hex."' is not a valid hexadecimal number"); } - return new self(hex2bin($hex)); + // Hex → Binary string (UTF-8 compatible) + $bytes = hex2bin($hex); + if ($bytes === false) { + throw new HelperException("given parameter '".$hex."' could not be converted to binary data"); + } + + return new self($bytes); } /** diff --git a/tests/Helper/CharTest.php b/tests/Helper/CharTest.php index cc905e0..cd99100 100644 --- a/tests/Helper/CharTest.php +++ b/tests/Helper/CharTest.php @@ -263,6 +263,15 @@ public function testUnicode1Byte() // $this->assertEquals(-1, Char::fromHex('80')->toUnicode()); $this->assertEquals(-1, Char::fromHex('C1')->toUnicode()); + + // + // 2-BYTE UTF-8 (U+0080 – U+07FF) + // Example: '¢' (U+00A2) → C2 A2 + // + $this->assertEquals( + static::calculateUTF8Ordinal("\xC2\xA2"), + Char::fromHex('C2A2')->toUnicode() + ); } public function testFromHexRejectsMalformedInput(): void From ca3d75654c783d9eb3abbbd235dd4e6547ef02a1 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Wed, 16 Sep 2026 17:18:48 +0200 Subject: [PATCH 3/9] add: more tests to unicode1Byte --- tests/Helper/CharTest.php | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/Helper/CharTest.php b/tests/Helper/CharTest.php index cd99100..851169c 100644 --- a/tests/Helper/CharTest.php +++ b/tests/Helper/CharTest.php @@ -272,6 +272,50 @@ public function testUnicode1Byte() static::calculateUTF8Ordinal("\xC2\xA2"), Char::fromHex('C2A2')->toUnicode() ); + + // Upper end of 2-byte range: '߿' (U+07FF) → DF BF + $this->assertEquals( + static::calculateUTF8Ordinal("\xDF\xBF"), + Char::fromHex('DFBF')->toUnicode() + ); + + // + // 3-BYTE UTF-8 (U+0800 – U+FFFF) + // Example: '€' (U+20AC) → E2 82 AC + // + $this->assertEquals( + static::calculateUTF8Ordinal("\xE2\x82\xAC"), + Char::fromHex('E282AC')->toUnicode() + ); + + // Upper end of 3-byte range: '￿' (U+FFFF) → EF BF BF + $this->assertEquals( + static::calculateUTF8Ordinal("\xEF\xBF\xBF"), + Char::fromHex('EFBFBF')->toUnicode() + ); + + // + // 4-BYTE UTF-8 (U+10000 – U+10FFFF) + // Example: '😀' (U+1F600) → F0 9F 98 80 + // + $this->assertEquals( + static::calculateUTF8Ordinal("\xF0\x9F\x98\x80"), + Char::fromHex('F09F9880')->toUnicode() + ); + + // Upper end: U+10FFFF → F4 8F BF BF + $this->assertEquals( + static::calculateUTF8Ordinal("\xF4\x8F\xBF\xBF"), + Char::fromHex('F48FBFBF')->toUnicode() + ); + + // + // INVALID TOO-HIGH LEAD BYTE (> 0xF4) + // + $this->assertEquals( + -1, + Char::fromHex('F5')->toUnicode() + ); } public function testFromHexRejectsMalformedInput(): void From 3ea2eda077b9c4089a1b26509fe2c76cdf3de2a0 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Wed, 16 Sep 2026 17:22:08 +0200 Subject: [PATCH 4/9] change: calculateUTF8Ordinal --- tests/Helper/CharTest.php | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/tests/Helper/CharTest.php b/tests/Helper/CharTest.php index 851169c..95f67a2 100644 --- a/tests/Helper/CharTest.php +++ b/tests/Helper/CharTest.php @@ -363,13 +363,30 @@ public function testUnicode2Bytes() { */ private static function calculateUTF8Ordinal(string $char): int { - $charString = mb_substr($char, 0, 1, 'utf-8'); - $charLength = strlen($charString); - $ordinal = ord($charString[0]) & (0xFF >> $charLength); - //Merge other characters into the value - for ($i = 1; $i < $charLength; $i++) { - $ordinal = $ordinal << 6 | (ord($charString[$i]) & 127); + $bytes = array_map('ord', str_split($char)); + $length = strlen($char); + + if ($length === 1) { + // 1-byte (ASCII) + return $bytes[0]; + } elseif ($length === 2) { + // 2-byte + return (($bytes[0] & 0x1F) << 6) | + ($bytes[1] & 0x3F); + } elseif ($length === 3) { + // 3-byte + return (($bytes[0] & 0x0F) << 12) | + (($bytes[1] & 0x3F) << 6) | + ($bytes[2] & 0x3F); + } elseif ($length === 4) { + // 4-byte + return (($bytes[0] & 0x07) << 18) | + (($bytes[1] & 0x3F) << 12) | + (($bytes[2] & 0x3F) << 6) | + ($bytes[3] & 0x3F); } - return $ordinal; + + // invalid UTF-8 (longer than 4 bytes) + return -1; } } From e2a07aaed7a7aef2bbc63ca6f2fb0a01f2d91e71 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Wed, 16 Sep 2026 21:04:11 +0200 Subject: [PATCH 5/9] add: regression test --- tests/Helper/CharTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Helper/CharTest.php b/tests/Helper/CharTest.php index 95f67a2..acfbbe3 100644 --- a/tests/Helper/CharTest.php +++ b/tests/Helper/CharTest.php @@ -318,6 +318,14 @@ public function testUnicode1Byte() ); } + /** + * @throws HelperException + */ + public function testUnicodeRejectsTruncatedUtf8Sequence(): void + { + $this->assertSame(-1, Char::fromHex('C2')->toUnicode()); + } + public function testFromHexRejectsMalformedInput(): void { $this->expectException(HelperException::class); From 81330a89554af8b9c31b2eb7989d506a63ad3c72 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Wed, 16 Sep 2026 21:06:24 +0200 Subject: [PATCH 6/9] update: validate char->toUnicode() --- src/Helper/Char.php | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Helper/Char.php b/src/Helper/Char.php index 73b7211..45c0279 100644 --- a/src/Helper/Char.php +++ b/src/Helper/Char.php @@ -163,23 +163,55 @@ public function toAscii(): int */ public function toUnicode(): int { + $length = strlen($this->char); $h = ord($this->char[0]); if ($h <= 0x7F) { - return $h; + return $length === 1 ? $h : -1; } elseif ($h < 0xC2) { return -1; } elseif ($h <= 0xDF) { + if ($length !== 2 || !$this->isContinuationByte(1)) { + return -1; + } + return ($h & 0x1F) << 6 | (ord($this->char[1]) & 0x3F); } elseif ($h <= 0xEF) { + if ($length !== 3 || !$this->isContinuationByte(1) || !$this->isContinuationByte(2)) { + return -1; + } + return ($h & 0x0F) << 12 | (ord($this->char[1]) & 0x3F) << 6 | (ord($this->char[2]) & 0x3F); } elseif ($h <= 0xF4) { - return ($h & 0x0F) << 18 | (ord($this->char[1]) & 0x3F) << 12 | (ord($this->char[2]) & 0x3F) << 6 | (ord($this->char[3]) & 0x3F); + if ( + $length !== 4 + || !$this->isContinuationByte(1) + || !$this->isContinuationByte(2) + || !$this->isContinuationByte(3) + ) { + return -1; + } + + return ($h & 0x07) << 18 + | (ord($this->char[1]) & 0x3F) << 12 + | (ord($this->char[2]) & 0x3F) << 6 + | (ord($this->char[3]) & 0x3F); } else { return -1; } } + private function isContinuationByte(int $offset): bool + { + if (!isset($this->char[$offset])) { + return false; + } + + $byte = ord($this->char[$offset]); + + return $byte >= 0x80 && $byte <= 0xBF; + } + /** * Returns the hexadecimal value of the char. * From a4958a03fc81f40839b0588bdf15ccf6fb1fbd2e Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Thu, 17 Sep 2026 00:15:10 +0200 Subject: [PATCH 7/9] add: testFromHexToHexRoundTripsMultibyteUtf8Characters() --- tests/Helper/CharTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/Helper/CharTest.php b/tests/Helper/CharTest.php index acfbbe3..7b1b69c 100644 --- a/tests/Helper/CharTest.php +++ b/tests/Helper/CharTest.php @@ -326,6 +326,16 @@ public function testUnicodeRejectsTruncatedUtf8Sequence(): void $this->assertSame(-1, Char::fromHex('C2')->toUnicode()); } + /** + * @throws HelperException + */ + public function testFromHexToHexRoundTripsMultibyteUtf8Characters(): void + { + $this->assertSame('C2A2', Char::fromHex('C2A2')->toHex()); + $this->assertSame('E282AC', (new Char('€'))->toHex()); + $this->assertSame('F09F9880', (new Char('😀'))->toHex()); + } + public function testFromHexRejectsMalformedInput(): void { $this->expectException(HelperException::class); From 82880f70547d28d476b88001676d2f85e5acd97d Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Thu, 17 Sep 2026 00:18:38 +0200 Subject: [PATCH 8/9] change: preserve round-trip --- src/Helper/Char.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Helper/Char.php b/src/Helper/Char.php index 45c0279..632421e 100644 --- a/src/Helper/Char.php +++ b/src/Helper/Char.php @@ -219,7 +219,7 @@ private function isContinuationByte(int $offset): bool */ public function toHex(): string { - return strtoupper(dechex($this->toAscii())); + return strtoupper(bin2hex($this->char)); } /** From dfe8ddbe81a7e255551f2e3648ac758d05e0efb7 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Tue, 22 Sep 2026 09:56:16 +0200 Subject: [PATCH 9/9] fix: comment --- tests/Helper/CharTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Helper/CharTest.php b/tests/Helper/CharTest.php index 7b1b69c..777ea24 100644 --- a/tests/Helper/CharTest.php +++ b/tests/Helper/CharTest.php @@ -259,7 +259,7 @@ public function testUnicode1Byte() // // INVALID LEADING BYTE (< 0xC2) - // e.g., 0x80 – 0xC1 should return false + // e.g., 0x80 – 0xC1 should return -1 // $this->assertEquals(-1, Char::fromHex('80')->toUnicode()); $this->assertEquals(-1, Char::fromHex('C1')->toUnicode());