Passed
Push — master ( a91d54...875978 )
by y
02:08
created

FrameReader::parse_2_length()   B

Complexity

Conditions 8
Paths 21

Size

Total Lines 28
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 18
nc 21
nop 0
dl 0
loc 28
rs 8.4444
c 0
b 0
f 0
1
<?php
2
3
namespace Helix\Socket\WebSocket;
4
5
use Generator;
6
7
/**
8
 * Reads frames from the peer.
9
 */
10
class FrameReader {
11
12
    /**
13
     * @var string
14
     */
15
    protected $buffer = '';
16
17
    /**
18
     * @var WebSocketClient
19
     */
20
    protected $client;
21
22
    /**
23
     * @var array
24
     */
25
    protected $current = [];
26
27
    /**
28
     * Payload size limit imposed on large frames.
29
     *
30
     * @var int
31
     */
32
    protected $sizeLimit = 10 * 1024 * 1024; // 10 MiB
33
34
    public function __construct (WebSocketClient $client) {
35
        $this->client = $client;
36
    }
37
38
    /**
39
     * @return int
40
     */
41
    public function getSizeLimit (): int {
42
        return $this->sizeLimit;
43
    }
44
45
    /**
46
     * @return Frame|null
47
     */
48
    protected function parse () {
49
        if (
50
            $this->parse_1_opCode()
51
            and $this->parse_2_length()
52
            and $this->parse_3_mask()
53
            and $this->parse_4_payload()
54
        ) {
55
            $frame = new Frame(
56
                $this->current['final'],
57
                $this->current['rsv'],
58
                $this->current['opCode'],
59
                $this->current['payload']
60
            );
61
            $this->current = [];
62
            $frame->validate();
63
            return $frame;
64
        }
65
        return null;
66
    }
67
68
    /**
69
     * @return bool
70
     */
71
    protected function parse_1_opCode (): bool {
72
        if (!isset($this->current['opCode'])) {
73
            if (null === $char = $this->shift(1)) {
74
                return false;
75
            }
76
            $char = ord($char);
77
            $final = (bool)($char & 0x80);
78
            $opCode = $char & 0x0f;
79
            $this->current = [
80
                'final' => $final,
81
                'rsv' => ($char & 0x70) >> 4,
82
                'opCode' => $opCode
83
            ];
84
        }
85
        return true;
86
    }
87
88
    /**
89
     * The protocol unfortunately uses different field sizes to specify the payload length.
90
     *
91
     * The high bit of the initial length specifier is also used for something completely unrelated (masking).
92
     *
93
     * On top of the added complexity, because the dynamic field is for the payload length,
94
     * this requires a variable named something akin to "lengthLength".
95
     *
96
     * Was a constant unpolluted 32-bit field too much to ask?
97
     *
98
     * Who is sending 2^63 bytes in a single frame?
99
     *
100
     * @return bool
101
     */
102
    protected function parse_2_length (): bool {
103
        // first pass
104
        if (!isset($this->current['lengthSize'])) {
105
            if (null === $byte = $this->shift(1)) {
106
                return false;
107
            }
108
            $byte = ord($byte);
109
            if (!$this->current['masked'] = (bool)($byte & 0x80)) {
110
                $this->current['mask'] = [0, 0, 0, 0]; // xor identity
111
            }
112
            $sizeIndicator = $byte & 0x7f;
113
            if (1 === $this->current['lengthSize'] = [126 => 2, 127 => 8][$sizeIndicator] ?? 1) {
114
                $this->current['length'] = $sizeIndicator;
115
            }
116
        }
117
        // second pass
118
        if (!isset($this->current['length'])) {
119
            $lengthSize = $this->current['lengthSize'];
120
            if (null === $length = $this->shift($lengthSize)) {
121
                return false;
122
            }
123
            $length = unpack([2 => 'n', 8 => 'J'][$lengthSize], $length)[1];
124
            if ($length > $this->sizeLimit) {
125
                throw new WebSocketError(Frame::CLOSE_TOO_LARGE, "Payload size limit is {$this->sizeLimit} bytes.");
126
            }
127
            $this->current['length'] = $length;
128
        }
129
        return true;
130
    }
131
132
    /**
133
     * The protocol requires clients to XOR flip the payload with a high-entropy mask,
134
     * which adds lots of arbitrary complexity without gaining any bonus whatsoever.
135
     *
136
     * The mask is sent with the frame itself, so any sophisticated eavesdropper can decode the message.
137
     *
138
     * @link https://tools.ietf.org/html/rfc6455#section-5.2
139
     *  Page 29: "All frames sent from client to server have this bit set to 1."
140
     *
141
     * @return bool
142
     */
143
    protected function parse_3_mask (): bool {
144
        if (!isset($this->current['mask'])) {
145
            if (null === $mask = $this->shift(4)) {
146
                return false;
147
            }
148
            $this->current['mask'] = array_values(unpack('C*', $mask));
0 ignored issues
show
Bug introduced by
It seems like unpack('C*', $mask) can also be of type false; however, parameter $input of array_values() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

148
            $this->current['mask'] = array_values(/** @scrutinizer ignore-type */ unpack('C*', $mask));
Loading history...
149
        }
150
        return true;
151
    }
152
153
    /**
154
     * @return bool
155
     */
156
    protected function parse_4_payload (): bool {
157
        if (!isset($this->current['payload'])) {
158
            $length = $this->current['length'];
159
            if (null === $payload = $this->shift($length)) {
160
                return false;
161
            }
162
            if ($this->current['masked']) {
163
                $mask = $this->current['mask'];
164
                for ($i = 0; $i < $length; $i++) {
165
                    $payload[$i] = chr(ord($payload[$i]) ^ $mask[$i % 4]);
166
                }
167
            }
168
            $this->current['payload'] = $payload;
169
        }
170
        return true;
171
    }
172
173
    /**
174
     * Constructs and yields all available frames from the peer.
175
     *
176
     * @return Generator|Frame[]
177
     */
178
    public function recvAll () {
179
        $this->buffer .= $this->client->recvAll();
180
        while ($frame = $this->parse()) {
181
            yield $frame;
182
        }
183
    }
184
185
    /**
186
     * @param int $bytes
187
     * @return $this
188
     */
189
    public function setSizeLimit (int $bytes) {
190
        $this->sizeLimit = $bytes;
191
        return $this;
192
    }
193
194
    /**
195
     * Shifts off and returns bytes from the buffer, but only if they're all there.
196
     *
197
     * @param int $bytes
198
     * @return null|string
199
     */
200
    protected function shift (int $bytes): ?string {
201
        if (strlen($this->buffer) < $bytes) {
202
            return null;
203
        }
204
        $chunk = substr($this->buffer, 0, $bytes);
205
        $this->buffer = substr($this->buffer, $bytes);
206
        return $chunk;
207
    }
208
}