Passed
Push — develop ( 9a09e8...d94677 )
by Vasil
08:01 queued 04:40
created

Script::prepare()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 2
dl 0
loc 8
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * ~~summary~~
5
 *
6
 * ~~description~~
7
 *
8
 * PHP version 5
9
 *
10
 * @category  Net
11
 * @package   PEAR2_Net_RouterOS
12
 * @author    Vasil Rangelov <[email protected]>
13
 * @copyright 2011 Vasil Rangelov
14
 * @license   http://www.gnu.org/copyleft/lesser.html LGPL License 2.1
15
 * @version   GIT: $Id$
16
 * @link      http://pear2.php.net/PEAR2_Net_RouterOS
17
 */
18
/**
19
 * The namespace declaration.
20
 */
21
namespace PEAR2\Net\RouterOS;
22
23
/**
24
 * Values at {@link Script::escapeValue()} can be casted from this type.
25
 */
26
use DateTime;
27
28
/**
29
 * Values at {@link Script::escapeValue()} can be casted from this type.
30
 */
31
use DateInterval;
32
33
/**
34
 * Used at {@link Script::escapeValue()} to get the proper time.
35
 */
36
use DateTimeZone;
37
38
/**
39
 * Used to reliably write to streams at {@link Script::prepare()}.
40
 */
41
use PEAR2\Net\Transmitter\Stream;
42
43
/**
44
 * Used to catch DateTime and DateInterval exceptions at
45
 * {@link Script::parseValue()}.
46
 */
47
use Exception as E;
48
49
/**
50
 * Scripting class.
51
 *
52
 * Provides functionality related to parsing and composing RouterOS scripts and
53
 * values.
54
 *
55
 * @category Net
56
 * @package  PEAR2_Net_RouterOS
57
 * @author   Vasil Rangelov <[email protected]>
58
 * @license  http://www.gnu.org/copyleft/lesser.html LGPL License 2.1
59
 * @link     http://pear2.php.net/PEAR2_Net_RouterOS
60
 */
61
class Script
62
{
63
    /**
64
     * Parses a value from a RouterOS scripting context.
65
     *
66
     * Turns a value from RouterOS into an equivalent PHP value, based on
67
     * determining the type in the same way RouterOS would determine it for a
68
     * literal.
69
     *
70
     * This method is intended to be the very opposite of
71
     * {@link static::escapeValue()}. That is, results from that method, if
72
     * given to this method, should produce equivalent results.
73
     *
74
     * @param string            $value    The value to be parsed.
75
     *     Must be a literal of a value,
76
     *     e.g. what {@link static::escapeValue()} will give you.
77
     * @param DateTimeZone|null $timezone The timezone which any resulting
78
     *     DateTime object (either the main value, or values within an array)
79
     *     will use. Defaults to UTC.
80
     *
81
     * @return mixed Depending on RouterOS type detected:
82
     *     - "nil" (the string "[]") or "nothing" (empty string) - NULL.
83
     *     - "num" - int or double for large values.
84
     *     - "bool" - a boolean.
85
     *     - "array" - an array, with the keys and values processed recursively.
86
     *     - "time" - a {@link DateInterval} object.
87
     *     - "date" (pseudo type; string in the form "M/j/Y") - a DateTime
88
     *         object with the specified date, at midnight.
89
     *     - "datetime" (pseudo type; string in the form "M/j/Y H:i:s") - a
90
     *         DateTime object with the specified date and time.
91
     *     - "str" (a quoted string) - a string, with the contents escaped.
92
     *     - Unrecognized type - casted to a string, unmodified.
93
     */
94
    public static function parseValue($value, DateTimeZone $timezone = null)
95
    {
96
        $value = static::parseValueToSimple($value);
97
        if (!is_string($value)) {
98
            return $value;
99
        }
100
101
        try {
102
            return static::parseValueToArray($value, $timezone);
103
        } catch (ParserException $e) {
104
            try {
105
                return static::parseValueToDateInterval($value);
106
            } catch (ParserException $e) {
107
                try {
108
                    return static::parseValueToDateTime($value, $timezone);
109
                } catch (ParserException $e) {
110
                    return static::parseValueToString($value);
111
                }
112
            }
113
        }
114
    }
115
116
    /**
117
     * Parses a RouterOS value into a PHP string.
118
     *
119
     * @param string $value The value to be parsed.
120
     *     Must be a literal of a value,
121
     *     e.g. what {@link static::escapeValue()} will give you.
122
     *
123
     * @return string If a quoted string is provided, it would be parsed.
124
     *     Otherwise, the value is casted to a string, and returned unmodified.
125
     */
126
    public static function parseValueToString($value)
127
    {
128
        $value = (string)$value;
129
        if ('"' === $value[0] && '"' === $value[strlen($value) - 1]) {
130
            return str_replace(
131
                array('\"', '\\\\', "\\\n", "\\\r\n", "\\\r"),
132
                array('"', '\\'),
133
                substr($value, 1, -1)
134
            );
135
        }
136
        return $value;
137
    }
138
139
    /**
140
     * Parses a RouterOS value into a PHP simple type.
141
     *
142
     * Parses a RouterOS value into a PHP simple type. "Simple" types being
143
     * scalar types, plus NULL.
144
     *
145
     * @param string $value The value to be parsed. Must be a literal of a
146
     *     value, e.g. what {@link static::escapeValue()} will give you.
147
     *
148
     * @return string|bool|int|double|null Depending on RouterOS type detected:
149
     *     - "nil" (the string "[]") or "nothing" (empty string) - NULL.
150
     *     - "num" - int or double for large values.
151
     *     - "bool" - a boolean.
152
     *     - Unrecognized type - casted to a string, unmodified.
153
     */
154
    public static function parseValueToSimple($value)
155
    {
156
        $value = (string)$value;
157
158
        if (in_array($value, array('', '[]'), true)) {
159
            return null;
160
        } elseif (in_array($value, array('true', 'false', 'yes', 'no'), true)) {
161
            return $value === 'true' || $value === 'yes';
162
        } elseif ($value === (string)($num = (int)$value)
163
            || $value === (string)($num = (double)$value)
164
        ) {
165
            return $num;
166
        }
167
        return $value;
168
    }
169
170
    /**
171
     * Parses a RouterOS value into a PHP DateTime object
172
     *
173
     * Parses a RouterOS value into a PHP DateTime object.
174
     *
175
     * @param string            $value    The value to be parsed.
176
     *     Must be a literal of a value,
177
     *     e.g. what {@link static::escapeValue()} will give you.
178
     * @param DateTimeZone|null $timezone The timezone which the resulting
179
     *     DateTime object will use. Defaults to UTC.
180
     *
181
     * @return DateTime Depending on RouterOS type detected:
182
     *     - "date" (pseudo type; string in the form "M/j/Y") - a DateTime
183
     *         object with the specified date, at midnight UTC time (regardless
184
     *         of timezone provided).
185
     *     - "datetime" (pseudo type; string in the form "M/j/Y H:i:s") - a
186
     *         DateTime object with the specified date and time,
187
     *         with the specified timezone.
188
     *
189
     * @throws ParserException When the value is not of a recognized type.
190
     */
191
    public static function parseValueToDateTime(
192
        $value,
193
        DateTimeZone $timezone = null
194
    ) {
195
        $previous = null;
196
        $value = (string)$value;
197
        if ('' !== $value && preg_match(
198
            '#^
199
                (?<mon>jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)
200
                /
201
                (?<day>\d\d?)
202
                /
203
                (?<year>\d{4})
204
                (?:
205
                    \s+(?<time>\d{2}\:\d{2}:\d{2})
206
                )?
207
            $#uix',
208
            $value,
209
            $date
210
        )) {
211
            if (!isset($date['time'])) {
212
                $date['time'] = '00:00:00';
213
                $timezone = new DateTimeZone('UTC');
214
            } elseif (null === $timezone) {
215
                $timezone = new DateTimeZone('UTC');
216
            }
217
            try {
218
                return new DateTime(
219
                    $date['year'] .
220
                    '-' . ucfirst($date['mon']) .
221
                    "-{$date['day']} {$date['time']}",
222
                    $timezone
223
                );
224
            } catch (E $e) {
225
                $previous = $e;
226
            }
227
        }
228
        throw new ParserException(
229
            'The supplied value can not be converted to a DateTime',
230
            ParserException::CODE_DATETIME,
231
            $previous
232
        );
233
    }
234
235
    /**
236
     * Parses a RouterOS value into a PHP DateInterval.
237
     *
238
     * Parses a RouterOS value into a PHP DateInterval.
239
     *
240
     * @param string $value The value to be parsed. Must be a literal of a
241
     *     value, e.g. what {@link static::escapeValue()} will give you.
242
     *
243
     * @return DateInterval The value as a DateInterval object.
244
     *
245
     * @throws ParserException When the value is not of a recognized type.
246
     */
247
    public static function parseValueToDateInterval($value)
248
    {
249
        $value = (string)$value;
250
        if ('' !== $value && preg_match(
251
            '/^
252
                (?:(\d+)w)?
253
                (?:(\d+)d)?
254
                (?:(\d+)(?:\:|h))?
255
                (?|
256
                    (\d+)\:
257
                    (\d*(?:\.\d{1,9})?)
258
                |
259
                    (?:(\d+)m)?
260
                    (?:(\d+|\d*\.\d{1,9})s)?
261
                    (?:((?5))ms)?
262
                    (?:((?5))us)?
263
                    (?:((?5))ns)?
264
                )
265
            $/x',
266
            $value,
267
            $time
268
        )) {
269
            $days = isset($time[2]) ? (int)$time[2] : 0;
270
            if (isset($time[1])) {
271
                $days += 7 * (int)$time[1];
272
            }
273
            if (empty($time[3])) {
274
                $time[3] = 0;
275
            }
276
            if (empty($time[4])) {
277
                $time[4] = 0;
278
            }
279
            if (empty($time[5])) {
280
                $time[5] = 0;
281
            }
282
283
            $subsecondTime = 0.0;
284
            //@codeCoverageIgnoreStart
285
            // No PHP version currently supports sub-second DateIntervals,
286
            // meaning this section is untestable, since no version constraints
287
            // can be specified for test inputs.
288
            // All inputs currently use integer seconds only, making this
289
            // section unreachable during tests.
290
            // Nevertheless, this section exists right now, in order to provide
291
            // such support as soon as PHP has it.
292
            if (!empty($time[6])) {
293
                $subsecondTime += ((double)$time[6]) / 1000;
294
            }
295
            if (!empty($time[7])) {
296
                $subsecondTime += ((double)$time[7]) / 1000000;
297
            }
298
            if (!empty($time[8])) {
299
                $subsecondTime += ((double)$time[8]) / 1000000000;
300
            }
301
            //@codeCoverageIgnoreEnd
302
303
            $secondsSpec = $time[5] + $subsecondTime;
304
            try {
305
                return new DateInterval(
306
                    "P{$days}DT{$time[3]}H{$time[4]}M{$secondsSpec}S"
307
                );
308
                //@codeCoverageIgnoreStart
309
                // See previous ignored section's note.
310
                //
311
                // This section is added for backwards compatibility with current
312
                // PHP versions, when in the future sub-second support is added.
313
                // In that event, the test inputs for older versions will be
314
                // expected to get a rounded up result of the sub-second data.
315
            } catch (E $e) {
316
                $secondsSpec = (int)round($secondsSpec);
317
                return new DateInterval(
318
                    "P{$days}DT{$time[3]}H{$time[4]}M{$secondsSpec}S"
319
                );
320
            }
321
            //@codeCoverageIgnoreEnd
322
        }
323
        throw new ParserException(
324
            'The supplied value can not be converted to DateInterval',
325
            ParserException::CODE_DATEINTERVAL
326
        );
327
    }
328
329
    /**
330
     * Parses a RouterOS value into a PHP array.
331
     *
332
     * Parses a RouterOS value into a PHP array.
333
     *
334
     * @param string            $value    The value to be parsed.
335
     *     Must be a literal of a value,
336
     *     e.g. what {@link static::escapeValue()} will give you.
337
     * @param DateTimeZone|null $timezone The timezone which any resulting
338
     *     DateTime object within the array will use. Defaults to UTC.
339
     *
340
     * @return array An array, with the keys and values processed recursively,
341
     *         the keys with {@link static::parseValueToSimple()},
342
     *         and the values with {@link static::parseValue()}.
343
     *
344
     * @throws ParserException When the value is not of a recognized type.
345
     */
346
    public static function parseValueToArray(
347
        $value,
348
        DateTimeZone $timezone = null
349
    ) {
350
        $value = (string)$value;
351
        if ('{' === $value[0] && '}' === $value[strlen($value) - 1]) {
352
            $value = substr($value, 1, -1);
353
            if ('' === $value) {
354
                return array();
355
            }
356
            $parsedValue = preg_split(
357
                '/
358
                    (\"(?:\\\\\\\\|\\\\"|[^"])*\")
359
                    |
360
                    (\{[^{}]*(?2)?\})
361
                    |
362
                    ([^;=]+)
363
                /sx',
364
                $value,
365
                null,
366
                PREG_SPLIT_DELIM_CAPTURE
367
            );
368
            $result = array();
369
            $newVal = null;
370
            $newKey = null;
371
            for ($i = 0, $l = count($parsedValue); $i < $l; ++$i) {
0 ignored issues
show
Bug introduced by
It seems like $parsedValue can also be of type false; however, parameter $var of count() does only seem to accept Countable|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

371
            for ($i = 0, $l = count(/** @scrutinizer ignore-type */ $parsedValue); $i < $l; ++$i) {
Loading history...
372
                switch ($parsedValue[$i]) {
373
                case '':
374
                    break;
375
                case ';':
376
                    if (null === $newKey) {
377
                        $result[] = $newVal;
378
                    } else {
379
                        $result[$newKey] = $newVal;
380
                    }
381
                    $newKey = $newVal = null;
382
                    break;
383
                case '=':
384
                    $newKey = static::parseValueToSimple($parsedValue[$i - 1]);
385
                    $newVal = static::parseValue($parsedValue[++$i], $timezone);
386
                    break;
387
                default:
388
                    $newVal = static::parseValue($parsedValue[$i], $timezone);
389
                }
390
            }
391
            if (null === $newKey) {
392
                $result[] = $newVal;
393
            } else {
394
                $result[$newKey] = $newVal;
395
            }
396
            return $result;
397
        }
398
        throw new ParserException(
399
            'The supplied value can not be converted to an array',
400
            ParserException::CODE_ARRAY
401
        );
402
    }
403
404
    /**
405
     * Prepares a script.
406
     *
407
     * Prepares a script for eventual execution by prepending parameters as
408
     * variables to it.
409
     *
410
     * This is particularly useful when you're creating scripts that you don't
411
     * want to execute right now (as with {@link Util::exec()}, but instead
412
     * you want to store it for later execution, perhaps by supplying it to
413
     * "/system scheduler".
414
     *
415
     * @param string|resource         $source The source of the script,
416
     *     as a string or stream. If a stream is provided, reading starts from
417
     *     the current position to the end of the stream, and the pointer stays
418
     *     at the end after reading is done.
419
     * @param array<string|int,mixed> $params An array of parameters to make
420
     *     available in the script as local variables.
421
     *     Variable names are array keys, and variable values are array values.
422
     *     Array values are automatically processed with
423
     *     {@link static::escapeValue()}. Streams are also supported, and are
424
     *     processed in chunks, each with
425
     *     {@link static::escapeString()} with all bytes being escaped.
426
     *     Processing starts from the current position to the end of the stream,
427
     *     and the stream's pointer is left untouched after the reading is done.
428
     *     Variables with a value of type "nothing" can be declared with a
429
     *     numeric array key and the variable name as the array value
430
     *     (that is casted to a string).
431
     *
432
     * @return resource A new PHP temporary stream with the script as contents,
433
     *     with the pointer back at the start.
434
     *
435
     * @see static::append()
436
     */
437
    public static function prepare(
438
        $source,
439
        array $params = array()
440
    ) {
441
        $resultStream = fopen('php://temp', 'r+b');
442
        static::append($resultStream, $source, $params);
0 ignored issues
show
Bug introduced by
It seems like $resultStream can also be of type false; however, parameter $stream of PEAR2\Net\RouterOS\Script::append() does only seem to accept resource, 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

442
        static::append(/** @scrutinizer ignore-type */ $resultStream, $source, $params);
Loading history...
443
        rewind($resultStream);
0 ignored issues
show
Bug introduced by
It seems like $resultStream can also be of type false; however, parameter $handle of rewind() does only seem to accept resource, 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

443
        rewind(/** @scrutinizer ignore-type */ $resultStream);
Loading history...
444
        return $resultStream;
445
    }
446
447
    /**
448
     * Appends a script.
449
     *
450
     * Appends a script to an existing stream.
451
     *
452
     * @param resource                $stream An existing stream to write the
453
     *     resulting script to.
454
     * @param string|resource         $source The source of the script,
455
     *     as a string or stream. If a stream is provided, reading starts from
456
     *     the current position to the end of the stream, and the pointer stays
457
     *     at the end after reading is done.
458
     * @param array<string|int,mixed> $params An array of parameters to make
459
     *     available in the script as local variables.
460
     *     Variable names are array keys, and variable values are array values.
461
     *     Array values are automatically processed with
462
     *     {@link static::escapeValue()}. Streams are also supported, and are
463
     *     processed in chunks, each with
464
     *     {@link static::escapeString()} with all bytes being escaped.
465
     *     Processing starts from the current position to the end of the stream,
466
     *     and the stream's pointer is left untouched after the reading is done.
467
     *     Variables with a value of type "nothing" can be declared with a
468
     *     numeric array key and the variable name as the array value
469
     *     (that is casted to a string).
470
     *
471
     * @return int The number of bytes written to $stream is returned,
472
     *     and the pointer remains where it was after the write
473
     *     (i.e. it is not seeked back, even if seeking is supported).
474
     */
475
    public static function append(
476
        $stream,
477
        $source,
478
        array $params = array()
479
    ) {
480
        $writer = new Stream($stream, false);
481
        $bytes = 0;
482
483
        foreach ($params as $pname => $pvalue) {
484
            if (is_int($pname)) {
485
                $pvalue = static::escapeString((string)$pvalue);
486
                $bytes += $writer->send(":local \"{$pvalue}\";\n");
487
                continue;
488
            }
489
            $pname = static::escapeString($pname);
490
            $bytes += $writer->send(":local \"{$pname}\" ");
491
            if (Stream::isStream($pvalue)) {
492
                $reader = new Stream($pvalue, false);
493
                $chunkSize = $reader->getChunk(Stream::DIRECTION_RECEIVE);
494
                $bytes += $writer->send('"');
495
                while ($reader->isAvailable() && $reader->isDataAwaiting()) {
496
                    $bytes += $writer->send(
497
                        static::escapeString(fread($pvalue, $chunkSize), true)
498
                    );
499
                }
500
                $bytes += $writer->send("\";\n");
501
            } else {
502
                $bytes += $writer->send(static::escapeValue($pvalue) . ";\n");
503
            }
504
        }
505
506
        $bytes += $writer->send($source);
507
        return $bytes;
508
    }
509
510
    /**
511
     * Escapes a value for a RouterOS scripting context.
512
     *
513
     * Turns any native PHP value into an equivalent whole value that can be
514
     * inserted as part of a RouterOS script.
515
     *
516
     * DateInterval objects will be casted to RouterOS' "time" type.
517
     *
518
     * DateTime objects will be casted to a string following the "M/d/Y H:i:s"
519
     * format. If the time is exactly midnight (including microseconds), and
520
     * the timezone is UTC, the string will include only the "M/d/Y" date.
521
     *
522
     * Unrecognized types (i.e. resources and other objects) are casted to
523
     * strings, and those strings are then escaped.
524
     *
525
     * @param mixed $value The value to be escaped.
526
     *
527
     * @return string A string representation that can be directly inserted in a
528
     *     script as a whole value.
529
     */
530
    public static function escapeValue($value)
531
    {
532
        switch(gettype($value)) {
533
        case 'NULL':
534
            $value = '[]';
535
            break;
536
        case 'integer':
537
            $value = (string)$value;
538
            break;
539
        case 'boolean':
540
            $value = $value ? 'true' : 'false';
541
            break;
542
        case 'array':
543
            if (0 === count($value)) {
544
                $value = '({})';
545
                break;
546
            }
547
            $result = '';
548
            foreach ($value as $key => $val) {
549
                $result .= ';';
550
                if (!is_int($key)) {
551
                    $result .= static::escapeValue($key) . '=';
552
                }
553
                $result .= static::escapeValue($val);
554
            }
555
            $value = '{' . substr($result, 1) . '}';
556
            break;
557
        case 'object':
558
            if ($value instanceof DateTime) {
559
                $usec = $value->format('u');
560
                $usec = '000000' === $usec ? '' : '.' . $usec;
561
                $value = '00:00:00.000000 UTC' === $value->format('H:i:s.u e')
562
                    ? $value->format('M/d/Y')
563
                    : $value->format('M/d/Y H:i:s') . $usec;
564
            }
565
            if ($value instanceof DateInterval) {
566
                if (false === $value->days || $value->days < 0) {
567
                    $value = $value->format('%r%dd%H:%I:%S');
568
                } else {
569
                    $value = $value->format('%r%ad%H:%I:%S');
570
                }
571
                break;
572
            }
573
            //break; intentionally omitted
574
        default:
575
            $value = '"' . static::escapeString((string)$value) . '"';
576
            break;
577
        }
578
        return $value;
579
    }
580
581
    /**
582
     * Escapes a string for a RouterOS scripting context.
583
     *
584
     * Escapes a string for a RouterOS scripting context. The value can then be
585
     * surrounded with quotes at a RouterOS script (or concatenated onto a
586
     * larger string first), and you can be sure there won't be any code
587
     * injections coming from it.
588
     *
589
     * By default, for the sake of brevity of the output, ASCII alphanumeric
590
     * characters and underscores are left untouched. And for the sake of
591
     * character conversion, bytes above 0x7F are also left untouched.
592
     *
593
     * @param string $value Value to be escaped.
594
     * @param bool   $full  Whether to escape all bytes in the string, including
595
     *     ASCII alphanumeric characters, underscores and bytes above 0x7F.
596
     *
597
     * @return string The escaped value.
598
     *
599
     * @internal Why leave ONLY those ASCII characters and not also others?
600
     *     Because those can't in any way be mistaken for language constructs,
601
     *     unlike many other "safe inside strings, but not outside" ASCII
602
     *     characters, like ",", ".", "+", "-", "~", etc.
603
     */
604
    public static function escapeString($value, $full = false)
605
    {
606
        if ($full) {
607
            return self::_escapeCharacters(array($value));
608
        }
609
        return preg_replace_callback(
610
            '/[^\\_A-Za-z0-9\\x80-\\xFF]+/S',
611
            array(__CLASS__, '_escapeCharacters'),
612
            $value
613
        );
614
    }
615
616
    /**
617
     * Escapes a character for a RouterOS scripting context.
618
     *
619
     * Escapes a character for a RouterOS scripting context.
620
     * Intended to only be called by {@link self::escapeString()} for the
621
     * matching strings.
622
     *
623
     * @param array $chars The matches array, expected to contain exactly one
624
     *     member, in which is the whole string to be escaped.
625
     *
626
     * @return string The escaped characters.
627
     */
628
    private static function _escapeCharacters(array $chars)
629
    {
630
        $result = '';
631
        for ($i = 0, $l = strlen($chars[0]); $i < $l; ++$i) {
632
            $result .= '\\' . str_pad(
633
                strtoupper(dechex(ord($chars[0][$i]))),
634
                2,
635
                '0',
636
                STR_PAD_LEFT
637
            );
638
        }
639
        return $result;
640
    }
641
}
642