Test Failed
Pull Request — master (#175)
by
unknown
05:56 queued 03:39
created

Ip::__construct()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 166
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 18
dl 0
loc 166
ccs 13
cts 13
cp 1
crap 2
rs 10
c 0
b 0
f 0

How to fix   Long Method    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Validator\Rule;
6
7
use Attribute;
8
use InvalidArgumentException;
9
use RuntimeException;
10
use Yiisoft\NetworkUtilities\IpHelper;
11
use Yiisoft\Validator\FormatterInterface;
12
use Yiisoft\Validator\Result;
13
use Yiisoft\Validator\Rule;
14
use Yiisoft\Validator\ValidationContext;
15
16
use function array_key_exists;
17
use function is_string;
18
use function strlen;
19
20
/**
21
 * The validator checks if a value is a valid IPv4/IPv6 address or subnet.
22
 *
23
 * It also may change a value if normalization of IPv6 expansion is enabled.
24
 *
25
 * The following are examples of validation rules using this validator:
26
 *
27
 * ```php
28
 * ['ip_address', 'ip'], // IPv4 or IPv6 address
29
 * ['ip_address', 'ip', 'ipv6' => false], // IPv4 address (IPv6 is disabled)
30
 * ['ip_address', 'ip', 'subnet' => true], // requires a CIDR prefix (like 10.0.0.1/24) for the IP address
31
 * ['ip_address', 'ip', 'subnet' => null], // CIDR prefix is optional
32
 * ['ip_address', 'ip', 'subnet' => null, 'normalize' => true], // CIDR prefix is optional and will be added when missing
33
 * ['ip_address', 'ip', 'ranges' => ['192.168.0.0/24']], // only IP addresses from the specified subnet are allowed
34
 * ['ip_address', 'ip', 'ranges' => ['!192.168.0.0/24', 'any']], // any IP is allowed except IP in the specified subnet
35
 * ['ip_address', 'ip', 'expandIPv6' => true], // expands IPv6 address to a full notation format
36
 * ```
37
 */
38
#[Attribute(Attribute::TARGET_PROPERTY)]
39
final class Ip extends Rule
40
{
41
    /**
42
     * Negation char.
43
     *
44
     * Used to negate {@see $ranges} or {@see $network} or to negate validating value when {@see $allowNegation}
45
     * is used.
46
     */
47
    private const NEGATION_CHAR = '!';
48
49
    public function __construct(
50
        /**
51
         * @var array The network aliases, that can be used in {@see $ranges}.
52
         *  - key - alias name
53
         *  - value - array of strings. String can be an IP range, IP address or another alias. String can be
54
         *    negated with {@see NEGATION_CHAR} (independent of {@see $allowNegation} option).
55
         *
56
         * The following aliases are defined by default:
57
         *  - `*`: `any`
58
         *  - `any`: `0.0.0.0/0, ::/0`
59
         *  - `private`: `10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fd00::/8`
60
         *  - `multicast`: `224.0.0.0/4, ff00::/8`
61
         *  - `linklocal`: `169.254.0.0/16, fe80::/10`
62
         *  - `localhost`: `127.0.0.0/8', ::1`
63
         *  - `documentation`: `192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32`
64
         *  - `system`: `multicast, linklocal, localhost, documentation`
65
         */
66
        private array $networks = [
67
            '*' => ['any'],
68
            'any' => ['0.0.0.0/0', '::/0'],
69
            'private' => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fd00::/8'],
70
            'multicast' => ['224.0.0.0/4', 'ff00::/8'],
71
            'linklocal' => ['169.254.0.0/16', 'fe80::/10'],
72
            'localhost' => ['127.0.0.0/8', '::1'],
73
            'documentation' => ['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '2001:db8::/32'],
74
            'system' => ['multicast', 'linklocal', 'localhost', 'documentation'],
75
        ],
76
        /**
77
         * @var bool whether the validating value can be an IPv4 address. Defaults to `true`.
78
         */
79
        private bool $allowIpv4 = true,
80
        /**
81
         * @var bool whether the validating value can be an IPv6 address. Defaults to `true`.
82
         */
83
        private bool $allowIpv6 = true,
84
        /**
85
         * @var bool whether the address can be an IP with CIDR subnet, like `192.168.10.0/24`.
86
         * The following values are possible:
87
         *
88
         * - `false` - the address must not have a subnet (default).
89
         * - `true` - specifying a subnet is optional.
90
         */
91
        private bool $allowSubnet = false,
92
        private bool $requireSubnet = false,
93
        /**
94
         * @var bool whether address may have a {@see NEGATION_CHAR} character at the beginning.
95
         * Defaults to `false`.
96
         */
97
        private bool $allowNegation = false,
98
        /**
99
         * @var string user-defined error message is used when validation fails due to the wrong IP address format.
100
         *
101
         * You may use the following placeholders in the message:
102
         *
103
         * - `{attribute}`: the label of the attribute being validated
104
         * - `{value}`: the value of the attribute being validated
105
         */
106
        private string $message = 'Must be a valid IP address.',
107
        /**
108
         * @var string user-defined error message is used when validation fails due to the disabled IPv4 validation.
109
         *
110
         * You may use the following placeholders in the message:
111
         *
112
         * - `{attribute}`: the label of the attribute being validated
113
         * - `{value}`: the value of the attribute being validated
114
         *
115
         * @see $allowIpv4
116
         */
117
        private string $ipv4NotAllowed = 'Must not be an IPv4 address.',
118
        /**
119
         * @var string user-defined error message is used when validation fails due to the disabled IPv6 validation.
120
         *
121
         * You may use the following placeholders in the message:
122
         *
123
         * - `{attribute}`: the label of the attribute being validated
124
         * - `{value}`: the value of the attribute being validated
125
         *
126
         * @see $allowIpv6
127
         */
128
        private string $ipv6NotAllowed = 'Must not be an IPv6 address.',
129
        /**
130
         * @var string user-defined error message is used when validation fails due to the wrong CIDR.
131
         *
132
         * You may use the following placeholders in the message:
133
         *
134
         * - `{attribute}`: the label of the attribute being validated
135
         * - `{value}`: the value of the attribute being validated
136
         *
137
         * @see $allowSubnet
138
         */
139
        private string $wrongCidr = 'Contains wrong subnet mask.',
140
        /**
141
         * @var string user-defined error message is used when validation fails due to subnet {@see $allowSubnet} is
142
         * used, but the CIDR prefix is not set.
143
         *
144
         * You may use the following placeholders in the message:
145
         *
146
         * - `{attribute}`: the label of the attribute being validated
147
         * - `{value}`: the value of the attribute being validated
148
         *
149
         * @see $allowSubnet
150
         */
151
        private string $noSubnet = 'Must be an IP address with specified subnet.',
152
        /**
153
         * @var string user-defined error message is used when validation fails
154
         * due to {@see $allowSubnet} is false, but CIDR prefix is present.
155
         *
156
         * You may use the following placeholders in the message:
157
         *
158
         * - `{attribute}`: the label of the attribute being validated
159
         * - `{value}`: the value of the attribute being validated
160
         *
161
         * @see $allowSubnet
162
         */
163
        private string $hasSubnet = 'Must not be a subnet.',
164
        /**
165
         * @var string user-defined error message is used when validation fails due to IP address
166
         * is not allowed by {@see $ranges} check.
167
         *
168
         * You may use the following placeholders in the message:
169
         *
170
         * - `{attribute}`: the label of the attribute being validated
171
         * - `{value}`: the value of the attribute being validated
172
         *
173
         * @see $ranges
174
         */
175
        private string $notInRange = 'Is not in the allowed range.',
176
        /**
177
         * @var array The IPv4 or IPv6 ranges that are allowed or forbidden.
178
         *
179
         * The following preparation tasks are performed:
180
         *
181
         * - Recursively substitutes aliases (described in {@see $networks}) with their values.
182
         * - Removes duplicates.
183
         *
184
         * When the array is empty, or the option not set, all IP addresses are allowed.
185
         *
186
         * Otherwise, the rules are checked sequentially until the first match is found.
187
         * An IP address is forbidden, when it has not matched any of the rules.
188
         *
189
         * Example:
190
         *
191
         * ```php
192
         * (new Ip(ranges: [
193
         *     '192.168.10.128'
194 22
         *     '!192.168.10.0/24',
195
         *     'any' // allows any other IP addresses
196 22
         * ]);
197
         * ```
198
         *
199 16
         * In this example, access is allowed for all the IPv4 and IPv6 addresses excluding the `192.168.10.0/24`
200
         * subnet. IPv4 address `192.168.10.128` is also allowed, because it is listed before the restriction.
201 16
         */
202 1
        private array $ranges = [],
203
        ?FormatterInterface $formatter = null,
204 15
        bool $skipOnEmpty = false,
205 15
        bool $skipOnError = false,
206 4
        $when = null
207 4
    ) {
208
        parent::__construct(formatter: $formatter, skipOnEmpty: $skipOnEmpty, skipOnError: $skipOnError, when: $when);
209
210 11
        if ($requireSubnet) {
211 6
            $this->allowSubnet = true;
212 6
        }
213
214 8
        $this->ranges = $this->prepareRanges($ranges);
215 8
    }
216 8
217 8
    protected function validateValue($value, ?ValidationContext $context = null): Result
218
    {
219
        if (!$this->allowIpv4 && !$this->allowIpv6) {
220 8
            throw new RuntimeException('Both IPv4 and IPv6 checks can not be disabled at the same time');
221
        }
222
        $result = new Result();
223
        if (!is_string($value)) {
224
            $result->addError($this->formatMessage($this->message));
225
            return $result;
226 8
        }
227 3
228 3
        if (preg_match($this->getIpParsePattern(), $value, $matches) === 0) {
229
            $result->addError($this->formatMessage($this->message));
230 8
            return $result;
231 3
        }
232 3
        $negation = !empty($matches['not'] ?? null);
233
        $ip = $matches['ip'];
234 8
        $cidr = $matches['cidr'] ?? null;
235 3
        $ipCidr = $matches['ipCidr'];
236 3
237
        try {
238 8
            $ipVersion = IpHelper::getIpVersion($ip, false);
239 1
        } catch (InvalidArgumentException $e) {
240 1
            $result->addError($this->formatMessage($this->message));
241
            return $result;
242 8
        }
243 2
244 2
        if ($this->requireSubnet === true && $cidr === null) {
245
            $result->addError($this->formatMessage($this->noSubnet));
246 8
            return $result;
247
        }
248
        if ($this->allowSubnet === false && $cidr !== null) {
249 8
            $result->addError($this->formatMessage($this->hasSubnet));
250
            return $result;
251 5
        }
252 2
        if ($this->allowNegation === false && $negation) {
253 2
            $result->addError($this->formatMessage($this->message));
254 2
            return $result;
255
        }
256
        if ($ipVersion === IpHelper::IPV6 && !$this->allowIpv6) {
257 8
            $result->addError($this->formatMessage($this->ipv6NotAllowed));
258 4
            return $result;
259 4
        }
260
        if ($ipVersion === IpHelper::IPV4 && !$this->allowIpv4) {
261
            $result->addError($this->formatMessage($this->ipv4NotAllowed));
262 8
            return $result;
263
        }
264
        if (!$result->isValid()) {
265
            return $result;
266
        }
267
        if ($cidr !== null) {
268
            try {
269
                IpHelper::getCidrBits($ipCidr);
270
            } catch (InvalidArgumentException $e) {
271
                $result->addError($this->formatMessage($this->wrongCidr));
272
                return $result;
273
            }
274
        }
275
        if (!$this->isAllowed($ipCidr)) {
276
            $result->addError($this->formatMessage($this->notInRange));
277
            return $result;
278
        }
279
280
        return $result;
281
    }
282
283
    /**
284
     * Define network alias to be used in {@see $ranges}
285
     *
286
     * @param string $name name of the network
287
     * @param array $ranges ranges
288
     *
289
     * @return self
290
     */
291
    public function network(string $name, array $ranges): self
292
    {
293 10
        if (array_key_exists($name, $this->networks)) {
294
            throw new \RuntimeException("Network alias \"{$name}\" already set");
295 10
        }
296 10
297 10
        $new = clone $this;
298
        $new->networks[$name] = $ranges;
299
        return $new;
300
    }
301
302
    public function getRanges(): array
303
    {
304
        return $this->ranges;
305
    }
306
307
    /**
308 2
     * The method checks whether the IP address with specified CIDR is allowed according to the {@see $ranges} list.
309
     */
310 2
    private function isAllowed(string $ip): bool
311 1
    {
312
        if (empty($this->ranges)) {
313
            return true;
314 2
        }
315 2
316 2
        foreach ($this->ranges as $string) {
317
            [$isNegated, $range] = $this->parseNegatedRange($string);
318
            if (IpHelper::inRange($ip, $range)) {
319 5
                return !$isNegated;
320
            }
321 5
        }
322
323
        return false;
324 2
    }
325
326 2
    /**
327 2
     * Parses IP address/range for the negation with {@see NEGATION_CHAR}.
328 2
     *
329
     * @param $string
330
     *
331 4
     * @return array `[0 => bool, 1 => string]`
332
     *  - boolean: whether the string is negated
333 4
     *  - string: the string without negation (when the negation were present)
334 4
     */
335 4
    private function parseNegatedRange($string): array
336
    {
337
        $isNegated = strpos($string, self::NEGATION_CHAR) === 0;
338 2
        return [$isNegated, $isNegated ? substr($string, strlen(self::NEGATION_CHAR)) : $string];
339
    }
340 2
341 2
    /**
342 2
     * Prepares array to fill in {@see $ranges}.
343
     *
344
     *  - Recursively substitutes aliases, described in {@see $networks} with their values,
345 2
     *  - Removes duplicates.
346
     *
347 2
     * @see $networks
348 2
     */
349 2
    private function prepareRanges(array $ranges): array
350
    {
351
        $result = [];
352 3
        foreach ($ranges as $string) {
353
            [$isRangeNegated, $range] = $this->parseNegatedRange($string);
354 3
            if (isset($this->networks[$range])) {
355 3
                $replacements = $this->prepareRanges($this->networks[$range]);
356 3
                foreach ($replacements as &$replacement) {
357
                    [$isReplacementNegated, $replacement] = $this->parseNegatedRange($replacement);
358
                    $result[] = ($isRangeNegated && !$isReplacementNegated ? self::NEGATION_CHAR : '') . $replacement;
359
                }
360
            } else {
361
                $result[] = $string;
362
            }
363
        }
364
365
        return array_unique($result);
366 5
    }
367
368 5
    /**
369 5
     * Used to get the Regexp pattern for initial IP address parsing.
370 5
     */
371 5
    public function getIpParsePattern(): string
372
    {
373
        return '/^(?<not>' . preg_quote(
374 3
            self::NEGATION_CHAR,
375
            '/'
376 3
        ) . ')?(?<ipCidr>(?<ip>(?:' . IpHelper::IPV4_PATTERN . ')|(?:' . IpHelper::IPV6_PATTERN . '))(?:\/(?<cidr>-?\d+))?)$/';
377 3
    }
378 3
379 3
    public function getOptions(): array
380
    {
381
        return array_merge(parent::getOptions(), [
382
            'allowIpv4' => $this->allowIpv4,
383
            'allowIpv6' => $this->allowIpv6,
384
            'allowSubnet' => $this->allowSubnet,
385
            'requireSubnet' => $this->requireSubnet,
386
            'allowNegation' => $this->allowNegation,
387
            'message' => $this->formatMessage($this->message),
388
            'ipv4NotAllowedMessage' => $this->formatMessage($this->ipv4NotAllowed),
389
            'ipv6NotAllowedMessage' => $this->formatMessage($this->ipv6NotAllowed),
390
            'wrongCidrMessage' => $this->formatMessage($this->wrongCidr),
391
            'noSubnetMessage' => $this->formatMessage($this->noSubnet),
392
            'hasSubnetMessage' => $this->formatMessage($this->hasSubnet),
393 8
            'notInRangeMessage' => $this->formatMessage($this->notInRange),
394
            'ranges' => $this->ranges,
395 8
        ]);
396 3
    }
397
}
398