From 1314133f4d7383bf6ff50224c6029d92c6ab3c52 Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 11:31:42 +0200 Subject: [PATCH 1/6] Add Base32 encoding and decoding utilities --- src/Base32.php | 129 +++++++++++++++++++++++++++++++++++++++++++ src/BinaryString.php | 10 ++++ tests/Base32Test.php | 87 +++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 src/Base32.php create mode 100644 tests/Base32Test.php diff --git a/src/Base32.php b/src/Base32.php new file mode 100644 index 0000000..2669074 --- /dev/null +++ b/src/Base32.php @@ -0,0 +1,129 @@ +> */ + private static array $decodeMaps = []; + + /** @var array */ + private static array $validatedAlphabets = []; + + public static function toBase32(string $binary, string $alphabet = self::DEFAULT_ALPHABET): string + { + self::ensureValidAlphabet($alphabet); + + if ($binary === '') { + return ''; + } + + $result = ''; + $buffer = 0; + $bitsLeft = 0; + $length = strlen($binary); + + for ($i = 0; $i < $length; $i++) { + $buffer = ($buffer << 8) | ord($binary[$i]); + $bitsLeft += 8; + + while ($bitsLeft >= 5) { + $bitsLeft -= 5; + $index = ($buffer >> $bitsLeft) & 0x1F; + $result .= $alphabet[$index]; + } + } + + if ($bitsLeft > 0) { + $index = ($buffer << (5 - $bitsLeft)) & 0x1F; + $result .= $alphabet[$index]; + } + + return $result; + } + + public static function fromBase32(string $base32, string $alphabet = self::DEFAULT_ALPHABET): string + { + if ($base32 === '') { + return ''; + } + + $map = self::decodeMap($alphabet); + + $buffer = 0; + $bitsLeft = 0; + $output = ''; + $length = strlen($base32); + + for ($i = 0; $i < $length; $i++) { + $char = $base32[$i]; + + if ($char === '=') { + break; // padding reached (RFC 4648) + } + + if (!isset($map[$char])) { + return ''; + } + + $buffer = ($buffer << 5) | $map[$char]; + $bitsLeft += 5; + + if ($bitsLeft >= 8) { + $bitsLeft -= 8; + $output .= chr(($buffer >> $bitsLeft) & 0xFF); + } + } + + return $output; + } + + /** + * @return array + */ + private static function decodeMap(string $alphabet): array + { + if (!isset(self::$decodeMaps[$alphabet])) { + self::ensureValidAlphabet($alphabet); + + $map = []; + for ($i = 0; $i < 32; $i++) { + $char = $alphabet[$i]; + $map[$char] = $i; + } + + self::$decodeMaps[$alphabet] = $map; + } + + return self::$decodeMaps[$alphabet]; + } + + private static function ensureValidAlphabet(string $alphabet): void + { + if (isset(self::$validatedAlphabets[$alphabet])) { + return; + } + + if (strlen($alphabet) !== 32) { + throw new InvalidArgumentException('Base32 alphabet must contain exactly 32 characters.'); + } + + $characters = []; + for ($i = 0; $i < 32; $i++) { + $char = $alphabet[$i]; + if (isset($characters[$char])) { + throw new InvalidArgumentException('Base32 alphabet must contain unique characters.'); + } + $characters[$char] = true; + } + + self::$validatedAlphabets[$alphabet] = true; + } +} diff --git a/src/BinaryString.php b/src/BinaryString.php index ae098c2..3267bb8 100644 --- a/src/BinaryString.php +++ b/src/BinaryString.php @@ -23,6 +23,11 @@ public function toBase64(): string return base64_encode($this->value); } + public function toBase32(string $alphabet = Base32::DEFAULT_ALPHABET): string + { + return Base32::toBase32($this->value, $alphabet); + } + public function size(): int { return strlen($this->value); @@ -43,6 +48,11 @@ public static function fromBase64(string $base64): static return new static(base64_decode($base64, true)); } + public static function fromBase32(string $base32, string $alphabet = Base32::DEFAULT_ALPHABET): static + { + return new static(Base32::fromBase32($base32, $alphabet)); + } + public function equals(BinaryString $other): bool { return hash_equals($this->value, $other->value); diff --git a/tests/Base32Test.php b/tests/Base32Test.php new file mode 100644 index 0000000..8b64468 --- /dev/null +++ b/tests/Base32Test.php @@ -0,0 +1,87 @@ +assertSame('', Base32::toBase32('')); + $this->assertSame('', Base32::fromBase32('')); + + $binaryString = BinaryString::fromString(''); + $this->assertSame('', $binaryString->toBase32()); + $this->assertTrue($binaryString->equals(BinaryString::fromBase32(''))); + } + + /** + * RFC 4648 test vectors (uppercase, no padding) adapted to unpadded form. + * + * @return array + */ + public static function vectors(): array + { + return [ + 'f' => ['plain' => 'f', 'base32' => 'MY'], + 'fo' => ['plain' => 'fo', 'base32' => 'MZXQ'], + 'foo' => ['plain' => 'foo', 'base32' => 'MZXW6'], + 'foob' => ['plain' => 'foob', 'base32' => 'MZXW6YQ'], + 'fooba' => ['plain' => 'fooba', 'base32' => 'MZXW6YTB'], + 'foobar' => ['plain' => 'foobar', 'base32' => 'MZXW6YTBOI'], + 'A' => ['plain' => 'A', 'base32' => 'IE'], + 'AB' => ['plain' => 'AB', 'base32' => 'IFBA'], + 'ABC' => ['plain' => 'ABC', 'base32' => 'IFBEG'], + ]; + } + + #[DataProvider('vectors')] + public function testToBase32MatchesKnownVectors(string $plain, string $base32): void + { + $this->assertSame($base32, Base32::toBase32($plain)); + $this->assertSame($base32, BinaryString::fromString($plain)->toBase32()); + } + + #[DataProvider('vectors')] + public function testFromBase32MatchesKnownVectors(string $plain, string $base32): void + { + $this->assertSame($plain, Base32::fromBase32($base32)); + $this->assertTrue(BinaryString::fromString($plain)->equals(BinaryString::fromBase32($base32))); + } + + public function testRoundTripRandomBinary(): void + { + $lengths = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 16, 31, 32, 33, 64, 100, 256, 1024]; + + foreach ($lengths as $length) { + $binary = ($length === 0) ? '' : random_bytes($length); + $encoded = Base32::toBase32($binary); + $decoded = Base32::fromBase32($encoded); + + $this->assertSame($binary, $decoded, "Failed round-trip at length {$length}"); + } + } + + public function testDecodeThenEncodeIsIdempotent(): void + { + $original = 'MZXW6YTBOI'; // "foobar" + $decoded = Base32::fromBase32($original); + $reEncoded = Base32::toBase32($decoded); + + $this->assertSame($original, $reEncoded); + } + + public function testLowercaseInputDoesNotDecodeToExpectedValue(): void + { + $lower = 'mzxw6ytboi'; + $decodedLower = Base32::fromBase32($lower); + + $this->assertNotSame('foobar', $decodedLower); + } +} From 94ec357ec9fe8f13379843adaa409e75173f9cef Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 15:02:00 +0200 Subject: [PATCH 2/6] Update src/Base32.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Base32.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Base32.php b/src/Base32.php index 2669074..fce6233 100644 --- a/src/Base32.php +++ b/src/Base32.php @@ -70,7 +70,13 @@ public static function fromBase32(string $base32, string $alphabet = self::DEFAU } if (!isset($map[$char])) { - return ''; + throw new InvalidArgumentException( + sprintf( + "Invalid character '%s' at position %d in Base32 input.", + $char, + $i + ) + ); } $buffer = ($buffer << 5) | $map[$char]; From 784223b7fe617b608277ce6f6f9054c647a91df9 Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 16:08:10 +0200 Subject: [PATCH 3/6] Add missing tests --- tests/Base32Test.php | 57 ++++++++++++++++++++++++++++++++++++-- tests/BinaryStringTest.php | 14 ++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/tests/Base32Test.php b/tests/Base32Test.php index 8b64468..e8c4397 100644 --- a/tests/Base32Test.php +++ b/tests/Base32Test.php @@ -77,11 +77,62 @@ public function testDecodeThenEncodeIsIdempotent(): void $this->assertSame($original, $reEncoded); } - public function testLowercaseInputDoesNotDecodeToExpectedValue(): void + public function testLowercaseInputThrowsException(): void { $lower = 'mzxw6ytboi'; - $decodedLower = Base32::fromBase32($lower); - $this->assertNotSame('foobar', $decodedLower); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid character 'm' at position 0 in Base32 input."); + + Base32::fromBase32($lower); + } + + public function testCustomAlphabet(): void + { + $customAlphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUV'; + $data = 'Hello World'; + + $encoded = Base32::toBase32($data, $customAlphabet); + $decoded = Base32::fromBase32($encoded, $customAlphabet); + + $this->assertSame($data, $decoded); + } + + public function testInvalidAlphabetLengthThrowsException(): void + { + $shortAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12345'; // 31 chars instead of 32 + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Base32 alphabet must contain exactly 32 characters.'); + + Base32::toBase32('test', $shortAlphabet); + } + + public function testDuplicateCharactersInAlphabetThrowsException(): void + { + $duplicateAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456A'; // 'A' appears twice + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Base32 alphabet must contain unique characters.'); + + Base32::toBase32('test', $duplicateAlphabet); + } + + public function testPaddingIsIgnoredDuringDecoding(): void + { + $paddedBase32 = 'MZXW6YTBOI======'; // "foobar" with padding + $decoded = Base32::fromBase32($paddedBase32); + + $this->assertSame('foobar', $decoded); + } + + public function testInvalidCharacterInMiddleThrowsException(): void + { + $invalidBase32 = 'MZXW@YTBOI'; // '@' is invalid + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid character '@' at position 4 in Base32 input."); + + Base32::fromBase32($invalidBase32); } } diff --git a/tests/BinaryStringTest.php b/tests/BinaryStringTest.php index 3141092..f18f4cb 100644 --- a/tests/BinaryStringTest.php +++ b/tests/BinaryStringTest.php @@ -64,4 +64,18 @@ public function testEquals() $this->assertTrue($this->binaryString->equals(BinaryString::fromString("\x01\x02\x03\x04"))); $this->assertFalse($this->binaryString->equals(BinaryString::fromString("\xFF\xFF\xFF\xFF"))); } + + public function testToBase32() + { + $binaryString = BinaryString::fromString("foobar"); + $this->assertEquals("MZXW6YTBOI", $binaryString->toBase32()); + } + + public function testFromBase32() + { + $reconstructedString = BinaryString::fromBase32("MZXW6YTBOI"); + $expected = BinaryString::fromString("foobar"); + + $this->assertTrue($expected->equals($reconstructedString)); + } } From 51a9c519cc7957cd4be5ecfa127eae82141d36db Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 16:10:30 +0200 Subject: [PATCH 4/6] Update src/BinaryString.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/BinaryString.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/BinaryString.php b/src/BinaryString.php index 9b14e80..8e98c57 100644 --- a/src/BinaryString.php +++ b/src/BinaryString.php @@ -48,6 +48,13 @@ public static function fromBase64(string $base64): static return new static(base64_decode($base64, true)); } + /** + * Decodes a Base32-encoded string to a BinaryString instance. + * + * @param string $base32 The Base32-encoded string to decode. + * @param string $alphabet The alphabet used for Base32 encoding. Defaults to Base32::DEFAULT_ALPHABET. + * @return static A new BinaryString instance containing the decoded binary data. + */ public static function fromBase32(string $base32, string $alphabet = Base32::DEFAULT_ALPHABET): static { return new static(Base32::fromBase32($base32, $alphabet)); From efa97e0669bf2cf1f29ea697b739fd4c9486529a Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 16:10:39 +0200 Subject: [PATCH 5/6] Update src/BinaryString.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/BinaryString.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/BinaryString.php b/src/BinaryString.php index 8e98c57..da928aa 100644 --- a/src/BinaryString.php +++ b/src/BinaryString.php @@ -23,6 +23,12 @@ public function toBase64(): string return base64_encode($this->value); } + /** + * Returns a Base32-encoded string representation of the binary value. + * + * @param string $alphabet The alphabet to use for Base32 encoding. + * @return string The Base32-encoded string. + */ public function toBase32(string $alphabet = Base32::DEFAULT_ALPHABET): string { return Base32::toBase32($this->value, $alphabet); From e5a79f471256a9bf48c14f980beb39d737ea09a0 Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 16:18:14 +0200 Subject: [PATCH 6/6] Update tests/Base32Test.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/Base32Test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Base32Test.php b/tests/Base32Test.php index e8c4397..a37d0b7 100644 --- a/tests/Base32Test.php +++ b/tests/Base32Test.php @@ -22,7 +22,7 @@ public function testEmpty(): void } /** - * RFC 4648 test vectors (uppercase, no padding) adapted to unpadded form. + * RFC 4648 test vectors (uppercase, unpadded form). * * @return array */