Passed
Push — master ( f0b67b...ec9c11 )
by y
01:29
created

FrameReader::getFrame()   B

Complexity

Conditions 7
Paths 11

Size

Total Lines 35
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 27
c 0
b 0
f 0
nc 11
nop 0
dl 0
loc 35
rs 8.5546
1
<?php
2
3
namespace Helix\Socket\WebSocket;
4
5
use Generator;
6
use InvalidArgumentException;
7
8
/**
9
 * Reads frames from the peer.
10
 */
11
class FrameReader {
12
13
    // todo? doesn't allow unmasked frames.
14
    //                         op((char     )|(short     )|(bigint      ))(mask       )
15
    protected const REGEXP = '/^.([\x80-\xfd]|\xfe(?<n>..)|\xff(?<J>.{8}))(?<mask>.{4})/s';
16
17
    const MAX_LENGTH_RANGE = [125, 2 ** 63 - 1];
18
19
    /**
20
     * @var string
21
     */
22
    protected $buffer = '';
23
24
    /**
25
     * @var WebSocketClient
26
     */
27
    protected $client;
28
29
    /**
30
     * @var array
31
     */
32
    protected $head = [];
33
34
    /**
35
     * Payload size limit.
36
     *
37
     * Must fall within {@see MAX_LENGTH_RANGE} (inclusive).
38
     *
39
     * Defaults to 10 MiB.
40
     *
41
     * https://tools.ietf.org/html/rfc6455#section-5.2
42
     * > ... interpreted as a 64-bit unsigned integer (the
43
     * > most significant bit MUST be 0) ...
44
     *
45
     * @var int
46
     */
47
    protected $maxLength = 10 * 1024 * 1024;
48
49
    public function __construct (WebSocketClient $client) {
50
        $this->client = $client;
51
    }
52
53
    /**
54
     * @return Frame|null
55
     */
56
    protected function getFrame () {
57
        if (!$this->head) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->head of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
58
            if (preg_match(self::REGEXP, $this->buffer, $head)) {
59
                [, $op, $len] = unpack('C2', $head[0]);
60
                $len = [0xfe => 'n', 0xff => 'J'][$len] ?? ($len & 0x7f);
61
                $this->head = [
62
                    'final' => $op & 0x80,
63
                    'rsv' => $op & Frame::RSV123,
64
                    'opCode' => $op & 0x0f,
65
                    'length' => is_int($len) ? $len : unpack($len, $head[$len])[1],
66
                    'mask' => array_values(unpack('C*', $head['mask'])),
0 ignored issues
show
Bug introduced by
It seems like unpack('C*', $head['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

66
                    'mask' => array_values(/** @scrutinizer ignore-type */ unpack('C*', $head['mask'])),
Loading history...
67
                ];
68
                $this->buffer = substr($this->buffer, strlen($head[0]));
69
                $this->validate();
70
            }
71
            elseif (strlen($this->buffer) >= 14) { // max head room
72
                throw new WebSocketError(Frame::CLOSE_PROTOCOL_ERROR, 'Bad frame.');
73
            }
74
            else {
75
                return null;
76
            }
77
        }
78
        $length = $this->head['length'];
79
        if (strlen($this->buffer) >= $length) {
80
            $payload = substr($this->buffer, 0, $length);
81
            $this->buffer = substr($this->buffer, $length);
82
            $mask = $this->head['mask'];
83
            for ($i = 0; $i < $length; $i++) {
84
                $payload[$i] = chr(ord($payload[$i]) ^ $mask[$i % 4]);
85
            }
86
            $frame = new Frame($this->head['final'], $this->head['rsv'], $this->head['opCode'], $payload);
87
            $this->head = [];
88
            return $frame;
89
        }
90
        return null;
91
    }
92
93
    /**
94
     * Constructs and yields all available frames from the peer.
95
     *
96
     * @return Generator|Frame[]
97
     */
98
    public function getFrames () {
99
        $this->buffer .= $this->client->recvAll();
100
        while ($frame = $this->getFrame()) {
101
            yield $frame;
102
        }
103
    }
104
105
    /**
106
     * @return int
107
     */
108
    public function getMaxLength (): int {
109
        return $this->maxLength;
110
    }
111
112
    /**
113
     * @param int $bytes
114
     * @return $this
115
     */
116
    public function setMaxLength (int $bytes) {
117
        if ($bytes < self::MAX_LENGTH_RANGE[0] or $bytes > self::MAX_LENGTH_RANGE[1]) {
118
            throw new InvalidArgumentException('Max length must be within range [125,2^63-1]');
119
        }
120
        $this->maxLength = $bytes;
121
        return $this;
122
    }
123
124
    /**
125
     * Validates the current head by not throwing.
126
     *
127
     * @throws WebSocketError
128
     */
129
    protected function validate (): void {
130
        if ($this->head['length'] > $this->maxLength) {
131
            throw new WebSocketError(Frame::CLOSE_TOO_LARGE, "Payload would exceed {$this->maxLength} bytes");
132
        }
133
        $opCode = $this->head['opCode'];
134
        $name = Frame::NAMES[$opCode];
135
        if ($opCode & 0x08) { // control
136
            if ($opCode > Frame::OP_PONG) {
137
                throw new WebSocketError(Frame::CLOSE_PROTOCOL_ERROR, "Received {$name}");
138
            }
139
            if (!$this->head['final']) {
140
                throw new WebSocketError(Frame::CLOSE_PROTOCOL_ERROR, "Received fragmented {$name}");
141
            }
142
        }
143
        elseif ($opCode > Frame::OP_BINARY) { // data
144
            throw new WebSocketError(Frame::CLOSE_PROTOCOL_ERROR, "Received {$name}");
145
        }
146
    }
147
}