From 977bf115e9280636972466f1a9223eb353c399c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Sat, 2 May 2026 15:09:18 +0200 Subject: [PATCH 1/3] M1I13: Implement WebSocketFrame (RFC 6455 frame encoder/decoder) --- src/Server/Protocol/WebSocketFrame.php | 175 ++++++++++++++++ tests/Server/Protocol/WebSocketFrameTest.php | 205 +++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 src/Server/Protocol/WebSocketFrame.php create mode 100644 tests/Server/Protocol/WebSocketFrameTest.php diff --git a/src/Server/Protocol/WebSocketFrame.php b/src/Server/Protocol/WebSocketFrame.php new file mode 100644 index 0000000..4ce2a54 --- /dev/null +++ b/src/Server/Protocol/WebSocketFrame.php @@ -0,0 +1,175 @@ +assertSame(0x81, \ord($frame[0])); + $this->assertSame(5, \ord($frame[1])); + $this->assertSame('Hello', substr($frame, 2)); + } + + public function testEncodeBinaryFrame(): void + { + $hex = \hex2bin('deadbeef'); + $this->assertNotFalse($hex); + $frame = WebSocketFrame::encode($hex, 0x02); + + $this->assertSame(0x82, \ord($frame[0])); + $this->assertSame(4, \ord($frame[1])); + $this->assertSame($hex, substr($frame, 2)); + } + + public function testEncodeDecodeRoundTrip(): void + { + $original = 'Hello, WebSocket!'; + $encoded = WebSocketFrame::encode($original); + $decoded = WebSocketFrame::decode($encoded); + + $this->assertInstanceOf(WebSocketFrame::class, $decoded); + $this->assertSame(0x01, $decoded->opcode); + $this->assertSame($original, $decoded->payload); + $this->assertTrue($decoded->fin); + } + + public function testDecodeReturnsNullForEmptyData(): void + { + $this->assertNull(WebSocketFrame::decode('')); + } + + public function testDecodeReturnsNullForSingleByte(): void + { + $this->assertNull(WebSocketFrame::decode("\x81")); + } + + public function testDecodeReturnsNullForIncompleteSmallPayload(): void + { + $frame = "\x81\x0aHello"; + $this->assertNull(WebSocketFrame::decode($frame)); + } + + public function testDecodeReturnsNullForIncompleteExtendedPayload(): void + { + $frame = "\x81\x7e\x00\x0a" . 'Hello'; + $this->assertNull(WebSocketFrame::decode($frame)); + } + + public function testDecodeReturnsFrameForCompleteSmallPayload(): void + { + $frame = "\x81\x05Hello"; + $decoded = WebSocketFrame::decode($frame); + + $this->assertInstanceOf(WebSocketFrame::class, $decoded); + $this->assertSame('Hello', $decoded->payload); + $this->assertSame(0x01, $decoded->opcode); + $this->assertTrue($decoded->fin); + } + + public function testFrameSizeReturnsNullForEmptyData(): void + { + $this->assertNull(WebSocketFrame::frameSize('')); + } + + public function testFrameSizeReturnsNullForSingleByte(): void + { + $this->assertNull(WebSocketFrame::frameSize("\x81")); + } + + public function testFrameSizeReturnsCorrectSizeForSmallPayload(): void + { + $frame = "\x81\x05Hello"; + $this->assertSame(7, WebSocketFrame::frameSize($frame)); + } + + public function testFrameSizeReturnsNullForIncompleteFrame(): void + { + $frame = "\x81\x7e\x10\x00" . \str_repeat('a', 100); + $this->assertNull(WebSocketFrame::frameSize($frame)); + } + + public function testFrameSizeWithMaskedFrame(): void + { + $mask = \pack('N', 0x12345678); + $payload = 'Hello'; + $maskedPayload = ''; + for ($i = 0; $i < \strlen($payload); $i++) { + $maskedPayload .= \chr((\ord($payload[$i]) ^ \ord($mask[$i % 4])) & 0xff); + } + $frame = "\x81\x85" . $mask . $maskedPayload; + + $this->assertSame(11, WebSocketFrame::frameSize($frame)); + } + + public function testDecodeUnmasksPayload(): void + { + $mask = "\x12\x34\x56\x78"; + $payload = 'Hello'; + $masked = ''; + for ($i = 0; $i < \strlen($payload); $i++) { + $masked .= \chr((\ord($payload[$i]) ^ \ord($mask[$i % 4])) & 0xff); + } + $frame = "\x81\x85" . $mask . $masked; + + $decoded = WebSocketFrame::decode($frame); + + $this->assertInstanceOf(WebSocketFrame::class, $decoded); + $this->assertSame('Hello', $decoded->payload); + } + + public function test16BitExtendedPayloadLength(): void + { + $payload = \str_repeat('A', 200); + $encoded = WebSocketFrame::encode($payload); + + $this->assertSame(0x81, \ord($encoded[0])); + $this->assertSame(126, \ord($encoded[1])); + $unpacked = \unpack('n', substr($encoded, 2, 2)); + $this->assertNotFalse($unpacked); + $this->assertSame(200, $unpacked[1]); + + $decoded = WebSocketFrame::decode($encoded); + $this->assertInstanceOf(WebSocketFrame::class, $decoded); + $this->assertSame($payload, $decoded->payload); + } + + public function test64BitExtendedPayloadLength(): void + { + $payload = \str_repeat('B', 70000); + $encoded = WebSocketFrame::encode($payload); + + $this->assertSame(0x81, \ord($encoded[0])); + $this->assertSame(127, \ord($encoded[1])); + $unpacked = \unpack('J', substr($encoded, 2, 8)); + $this->assertNotFalse($unpacked); + $this->assertSame(70000, $unpacked[1]); + + $decoded = WebSocketFrame::decode($encoded); + $this->assertInstanceOf(WebSocketFrame::class, $decoded); + $this->assertSame($payload, $decoded->payload); + } + + public function testFrameSize16BitExtended(): void + { + $payload = \str_repeat('C', 300); + $encoded = WebSocketFrame::encode($payload); + $expectedSize = 2 + 2 + \strlen($payload); + + $this->assertSame($expectedSize, WebSocketFrame::frameSize($encoded)); + } + + public function testFrameSize64BitExtended(): void + { + $payload = \str_repeat('D', 70000); + $encoded = WebSocketFrame::encode($payload); + $expectedSize = 2 + 8 + \strlen($payload); + + $this->assertSame($expectedSize, WebSocketFrame::frameSize($encoded)); + } + + public function testDecodeFrameWithFinFalse(): void + { + $frame = "\x01\x05Hello"; + $decoded = WebSocketFrame::decode($frame); + + $this->assertInstanceOf(WebSocketFrame::class, $decoded); + $this->assertFalse($decoded->fin); + } + + public function testDecodeReturnsNullForIncomplete16BitLengthField(): void + { + $frame = "\x81\x7e\x00"; + $this->assertNull(WebSocketFrame::decode($frame)); + } + + public function testDecodeReturnsNullForIncomplete64BitLengthField(): void + { + $frame = "\x81\xff\x00\x00\x00\x00\x00\x00\x00"; + $this->assertNull(WebSocketFrame::decode($frame)); + } + + public function testEncodeAlwaysSetsFin(): void + { + $encoded = WebSocketFrame::encode('Hello', 0x01); + + $this->assertSame(0x81, \ord($encoded[0])); + } +} From 62d6e0e4fdb1290641738a14d6ba5df6267d897d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Sat, 2 May 2026 15:10:48 +0200 Subject: [PATCH 2/3] Fix PHP 8.2 compat: remove typed constants --- src/Server/Protocol/WebSocketFrame.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Server/Protocol/WebSocketFrame.php b/src/Server/Protocol/WebSocketFrame.php index 4ce2a54..6b6a6a0 100644 --- a/src/Server/Protocol/WebSocketFrame.php +++ b/src/Server/Protocol/WebSocketFrame.php @@ -6,8 +6,8 @@ class WebSocketFrame { - private const int MAX_7BIT = 125; - private const int MAX_16BIT = 65535; + private const MAX_7BIT = 125; + private const MAX_16BIT = 65535; public function __construct( public readonly int $opcode, From 5f8e12c3e3e51e353a54613199c36fc2bc208ec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Sat, 2 May 2026 15:18:37 +0200 Subject: [PATCH 3/3] Fix review: guard negative 64-bit length, use tracked offset in frameSize --- src/Server/Protocol/WebSocketFrame.php | 13 ++++++++----- tests/Server/Protocol/WebSocketFrameTest.php | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Server/Protocol/WebSocketFrame.php b/src/Server/Protocol/WebSocketFrame.php index 6b6a6a0..b285b47 100644 --- a/src/Server/Protocol/WebSocketFrame.php +++ b/src/Server/Protocol/WebSocketFrame.php @@ -56,7 +56,7 @@ private static function readUint64(string $data, int $offset): ?int $value = $unpacked[1]; - return \is_int($value) ? $value : null; + return \is_int($value) && $value >= 0 ? $value : null; } public static function decode(string $data): ?self @@ -138,28 +138,31 @@ public static function frameSize(string $data): ?int $masked = (bool) ($secondByte & 0x80); $headerSize = 2; + $offset = 2; $payloadLength = $payloadLengthCode; if ($payloadLengthCode === 126) { - if ($len < $headerSize + 2) { + if ($len < $offset + 2) { return null; } - $read = self::readUint16($data, 2); + $read = self::readUint16($data, $offset); if ($read === null) { return null; } $payloadLength = $read; $headerSize += 2; + $offset += 2; } elseif ($payloadLengthCode === 127) { - if ($len < $headerSize + 8) { + if ($len < $offset + 8) { return null; } - $read = self::readUint64($data, 2); + $read = self::readUint64($data, $offset); if ($read === null) { return null; } $payloadLength = $read; $headerSize += 8; + $offset += 8; } if ($masked) { diff --git a/tests/Server/Protocol/WebSocketFrameTest.php b/tests/Server/Protocol/WebSocketFrameTest.php index 98954f5..294c6f5 100644 --- a/tests/Server/Protocol/WebSocketFrameTest.php +++ b/tests/Server/Protocol/WebSocketFrameTest.php @@ -202,4 +202,18 @@ public function testEncodeAlwaysSetsFin(): void $this->assertSame(0x81, \ord($encoded[0])); } + + public function testDecodeReturnsNullFor64BitLengthWithMsbSet(): void + { + $frame = "\x81\xff\x80\x00\x00\x00\x00\x00\x00\x00" . 'Hello'; + + $this->assertNull(WebSocketFrame::decode($frame)); + } + + public function testFrameSizeReturnsNullFor64BitLengthWithMsbSet(): void + { + $frame = "\x81\xff\x80\x00\x00\x00\x00\x00\x00\x00" . 'Hello'; + + $this->assertNull(WebSocketFrame::frameSize($frame)); + } }