• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

sirn-se / websocket-php / 13436574091

20 Feb 2025 01:37PM UTC coverage: 99.906% (-0.09%) from 100.0%
13436574091

push

github

sirn-se
phpstan level 7

16 of 17 new or added lines in 5 files covered. (94.12%)

1059 of 1060 relevant lines covered (99.91%)

22.3 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.9
/src/Frame/FrameHandler.php
1
<?php
2

3
/**
4
 * Copyright (C) 2014-2025 Textalk and contributors.
5
 * This file is part of Websocket PHP and is free software under the ISC License.
6
 */
7

8
namespace WebSocket\Frame;
9

10
use Phrity\Net\SocketStream;
11
use Psr\Log\{
12
    LoggerInterface,
13
    LoggerAwareInterface,
14
    NullLogger
15
};
16
use RuntimeException;
17
use Stringable;
18
use WebSocket\Exception\CloseException;
19
use WebSocket\Trait\{
20
    OpcodeTrait,
21
    StringableTrait
22
};
23

24
/**
25
 * WebSocket\Frame\FrameHandler class.
26
 * Reads and writes Frames on stream.
27
 */
28
class FrameHandler implements LoggerAwareInterface, Stringable
29
{
30
    use OpcodeTrait;
31
    use StringableTrait;
32

33
    private SocketStream $stream;
34
    private LoggerInterface $logger;
35
    private bool $pushMasked;
36
    private bool $pullMaskedRequired;
37

38
    public function __construct(SocketStream $stream, bool $pushMasked, bool $pullMaskedRequired)
39
    {
40
        $this->stream = $stream;
117✔
41
        $this->pushMasked = $pushMasked;
117✔
42
        $this->pullMaskedRequired = $pullMaskedRequired;
117✔
43
        $this->setLogger(new NullLogger());
117✔
44
    }
45

46
    public function setLogger(LoggerInterface $logger): void
47
    {
48
        $this->logger = $logger;
117✔
49
    }
50

51
    // Pull frame from stream
52
    public function pull(): Frame
53
    {
54
        // Read the frame "header" first, two bytes.
55
        $data = $this->read(2);
37✔
56
        list ($byte_1, $byte_2) = array_values($this->unpack('C*', $data));
28✔
57
        $final = (bool)($byte_1 & 0b10000000); // Final fragment marker.
28✔
58
        $rsv1 = (bool)($byte_1 & 0b01000000);
28✔
59
        $rsv2 = (bool)($byte_1 & 0b00100000);
28✔
60
        $rsv3 = (bool)($byte_1 & 0b00010000);
28✔
61

62
        // Parse opcode
63
        $opcode_int = $byte_1 & 0b00001111;
28✔
64
        $opcode_ints = array_flip(self::$opcodes);
28✔
65
        $opcode = array_key_exists($opcode_int, $opcode_ints) ? $opcode_ints[$opcode_int] : strval($opcode_int);
28✔
66

67
        // Masking bit
68
        $masked = (bool)($byte_2 & 0b10000000);
28✔
69

70
        $payload = '';
28✔
71

72
        // Payload length
73
        $payload_length = $byte_2 & 0b01111111;
28✔
74

75
        if ($payload_length > 125) {
28✔
76
            if ($payload_length === 126) {
4✔
77
                $data = $this->read(2); // 126: Payload length is a 16-bit unsigned int
2✔
78
                $payload_length = current($this->unpack('n', $data));
2✔
79
            } else {
80
                $data = $this->read(8); // 127: Payload length is a 64-bit unsigned int
2✔
81
                $payload_length = current($this->unpack('J', $data));
2✔
82
            }
83
        }
84

85
        // Get masking key.
86
        if ($masked) {
28✔
87
            $masking_key = $this->stream->read(4);
10✔
88
        }
89

90
        // Get the actual payload, if any (might not be for e.g. close frames).
91
        if ($payload_length > 0) {
28✔
92
            $data = $this->read($payload_length);
21✔
93
            if ($masked) {
21✔
94
                // Unmask payload.
95
                for ($i = 0; $i < $payload_length; $i++) {
8✔
96
                    $payload .= ($data[$i] ^ $masking_key[$i % 4]);
8✔
97
                }
98
            } else {
99
                $payload = $data;
14✔
100
            }
101
        }
102

103
        $frame = new Frame($opcode, $payload, $final, $rsv1, $rsv2, $rsv3);
28✔
104
        $this->logger->debug("[frame-handler] Pulled '{$opcode}' frame", [
28✔
105
            'opcode' => $frame->getOpcode(),
28✔
106
            'final' => $frame->isFinal(),
28✔
107
            'content-length' => $frame->getPayloadLength(),
28✔
108
        ]);
28✔
109

110
        if ($this->pullMaskedRequired && !$masked) {
28✔
111
            $this->logger->error("[frame-handler] Masking required, but frame was unmasked");
1✔
112
            throw new CloseException(1002, 'Masking required');
1✔
113
        }
114

115
        return $frame;
27✔
116
    }
117

118
    // Push frame to stream
119
    public function push(Frame $frame): int
120
    {
121
        $payload = $frame->getPayload();
40✔
122
        $payload_length = $frame->getPayloadLength();
40✔
123

124
        $data = '';
40✔
125
        $byte_1 = $frame->isFinal() ? 0b10000000 : 0b00000000; // Final fragment marker.
40✔
126
        $byte_1 |= $frame->getRsv1() ? 0b01000000 : 0b00000000; // RSV1 bit.
40✔
127
        $byte_1 |= $frame->getRsv2() ? 0b00100000 : 0b00000000; // RSV2 bit.
40✔
128
        $byte_1 |= $frame->getRsv3() ? 0b00010000 : 0b00000000; // RSV3 bit.
40✔
129
        $byte_1 |= self::$opcodes[$frame->getOpcode()]; // Set opcode.
40✔
130
        $data .= pack('C', $byte_1);
40✔
131

132
        $byte_2 = $this->pushMasked ? 0b10000000 : 0b00000000; // Masking bit marker.
40✔
133

134
        // 7 bits of payload length
135
        if ($payload_length > 65535) {
40✔
136
            $data .= pack('C', $byte_2 | 0b01111111);
2✔
137
            $data .= pack('J', $payload_length);
2✔
138
        } elseif ($payload_length > 125) {
38✔
139
            $data .= pack('C', $byte_2 | 0b01111110);
2✔
140
            $data .= pack('n', $payload_length);
2✔
141
        } else {
142
            $data .= pack('C', $byte_2 | $payload_length);
36✔
143
        }
144

145
        // Handle masking.
146
        if ($this->pushMasked) {
40✔
147
            // Generate a random mask.
148
            $mask = '';
13✔
149
            for ($i = 0; $i < 4; $i++) {
13✔
150
                $mask .= chr(rand(0, 255));
13✔
151
            }
152
            $data .= $mask;
13✔
153

154
            // Append masked payload to frame.
155
            for ($i = 0; $i < $payload_length; $i++) {
13✔
156
                $data .= $payload[$i] ^ $mask[$i % 4];
13✔
157
            }
158
        } else {
159
            // Append payload as-is to frame.
160
            $data .= $payload;
27✔
161
        }
162

163
        // Write to stream.
164
        $written = $this->write($data);
40✔
165

166
        $this->logger->debug("[frame-handler] Pushed '{opcode}' frame", [
28✔
167
            'opcode' => $frame->getOpcode(),
28✔
168
            'final' => $frame->isFinal(),
28✔
169
            'content-length' => $frame->getPayloadLength(),
28✔
170
        ]);
28✔
171
        return $written;
28✔
172
    }
173

174
    // Secured read op
175
    private function read(int $length): string
176
    {
177
        $data = '';
37✔
178
        $read = 0;
37✔
179
        while ($read < $length) {
37✔
180
            $got = $this->stream->read($length - $read);
37✔
181
            if (empty($got)) {
30✔
182
                throw new RuntimeException('Empty read; connection dead?');
2✔
183
            }
184
            $data .= $got;
28✔
185
            $read = strlen($data);
28✔
186
        }
187
        return $data;
28✔
188
    }
189

190
    // Secured write op
191
    private function write(string $data): int
192
    {
193
        $length = strlen($data);
40✔
194
        $written = $this->stream->write($data);
40✔
195
        if ($written < $length) {
30✔
196
            throw new RuntimeException("Could only write {$written} out of {$length} bytes.");
2✔
197
        }
198
        return $written;
28✔
199
    }
200

201
    /** @return array<int> */
202
    private function unpack(string $format, string $string): array
203
    {
204
        $result = unpack($format, $string);
28✔
205
        if ($result === false) {
28✔
NEW
206
            throw new RuntimeException('Could not parse message header');
×
207
        }
208
        return $result;
28✔
209
    }
210
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc