From dd3be51dcf98f4402b3cd20f8e03ac0756032a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Mon, 30 Mar 2026 11:28:02 +0200 Subject: [PATCH] Add printable() and prefixRange() utilities to KeyUtil Implements ticket #12: - KeyUtil::printable() converts binary strings to human-readable format (ASCII passthrough, \xHH for non-printable, backslash escaping) - KeyUtil::prefixRange() returns [prefix, strinc(prefix)] range pair - 24 unit tests covering edge cases Closes #12 --- src/KeyUtil.php | 36 ++++++++ tests/Unit/KeyUtilTest.php | 175 +++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 tests/Unit/KeyUtilTest.php diff --git a/src/KeyUtil.php b/src/KeyUtil.php index afe3022..deec59d 100644 --- a/src/KeyUtil.php +++ b/src/KeyUtil.php @@ -30,4 +30,40 @@ public static function strinc(string $key): ?string return null; } + + public static function printable(string $bytes): string + { + $result = ''; + $length = strlen($bytes); + + for ($i = 0; $i < $length; $i++) { + $byte = ord($bytes[$i]); + + if ($byte === 0x5C) { + $result .= '\\\\'; + } elseif ($byte >= 32 && $byte < 127) { + $result .= $bytes[$i]; + } else { + $result .= sprintf('\\x%02x', $byte); + } + } + + return $result; + } + + /** + * @return array{string, string} + */ + public static function prefixRange(string $prefix): array + { + $end = self::strinc($prefix); + + if ($end === null) { + throw new \InvalidArgumentException( + 'Cannot compute prefix range: prefix is empty or entirely 0xFF bytes' + ); + } + + return [$prefix, $end]; + } } diff --git a/tests/Unit/KeyUtilTest.php b/tests/Unit/KeyUtilTest.php new file mode 100644 index 0000000..136ca5c --- /dev/null +++ b/tests/Unit/KeyUtilTest.php @@ -0,0 +1,175 @@ + + */ + public static function prefixRangeProvider(): iterable + { + yield 'simple prefix' => ['abc', 'abc', 'abd']; + yield 'single byte' => ['a', 'a', 'b']; + yield 'binary prefix' => ["\x01\x02", "\x01\x02", "\x01\x03"]; + yield 'trailing 0xFF trimmed' => ["a\xFF", "a\xFF", "b"]; + } + + #[Test] + public function prefixRangeThrowsOnEmptyPrefix(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot compute prefix range'); + + KeyUtil::prefixRange(''); + } + + #[Test] + public function prefixRangeThrowsOnAllFfBytes(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot compute prefix range'); + + KeyUtil::prefixRange("\xFF\xFF\xFF"); + } + + #[Test] + public function prefixRangePreservesOriginalPrefix(): void + { + $prefix = "myprefix"; + $result = KeyUtil::prefixRange($prefix); + + self::assertSame($prefix, $result[0]); + } + + #[Test] + public function strincWithSimpleKey(): void + { + self::assertSame('abd', KeyUtil::strinc('abc')); + } + + #[Test] + public function strincWithTrailingFf(): void + { + self::assertSame('b', KeyUtil::strinc("a\xFF")); + } + + #[Test] + public function strincWithAllFfReturnsNull(): void + { + self::assertNull(KeyUtil::strinc("\xFF\xFF")); + } + + #[Test] + public function strincWithEmptyStringReturnsNull(): void + { + self::assertNull(KeyUtil::strinc('')); + } +}