Passed
Push — master ( 2110db...aa42ed )
by Maurizio
03:43
created

_getCompressionFootprintAndSanitizePayload()   D

Complexity

Conditions 18
Paths 30

Size

Total Lines 55
Code Lines 33

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 33
c 1
b 0
f 0
dl 0
loc 55
rs 4.8666
cc 18
nc 30
nop 1

How to fix   Long Method    Complexity   

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:

1
<?php
2
3
namespace Mfonte\Base62x;
4
5
use Exception;
6
use Mfonte\Base62x\Exception\InvalidParam;
7
use Mfonte\Base62x\Exception\CryptException;
8
use Mfonte\Base62x\Exception\DecodeException;
9
use Mfonte\Base62x\Exception\EncodeException;
10
use Mfonte\Base62x\Encoding\Base62x as Encoder;
11
use Mfonte\Base62x\Encryption\Crypt as Crypter;
12
use Mfonte\Base62x\Compression\Gzip\GzipCompression as GzipCompressor;
13
use Mfonte\Base62x\Compression\Huffman\HuffmanCoding as HuffmanCompressor;
14
15
class Base62x
16
{
17
    const MODE_ENCODE = 1;
18
    const MODE_DECODE = 2;
19
20
    protected $_validCompressionAlgorithms = ['gzip' => ['zlib', 'deflate', 'gzip'], 'huffman'];
21
22
    /**
23
     * The mode: encode or decode.
24
     *
25
     * @var int
26
     */
27
    protected $mode;
28
29
    /**
30
     * The payload to be encoded or decoded.
31
     *
32
     * @var mixed
33
     */
34
    protected $payload;
35
36
    /**
37
     * Wheter the payload needs to be compressed prior of encoding. Defaults to null.
38
     *
39
     * @var string
40
     */
41
    protected $compressAlgorithm = null;
42
43
    /**
44
     * The compression mode related to the compression algo. Defaults to null.
45
     *
46
     * @var string
47
     */
48
    protected $compressEncoding = null;
49
50
    /**
51
     * The encryption method (algorithm) to be used in case of password-protected encoding.
52
     * This variable *must* be a valid method supported in openssl_get_cipher_methods().
53
     *
54
     * @var string
55
     */
56
    protected $cryptMethod;
57
58
    /**
59
     * The encrypt/decrypt key (password) to be used to protect/unprotect the encoding.
60
     *
61
     * @var mixed
62
     */
63
    protected $cryptKey;
64
65
    /**
66
     * Wheter the payload needs to be decompressed after the decoding. Defaults to false.
67
     *
68
     * @var string
69
     */
70
    protected $decompressAlgorithm = null;
71
72
    /**
73
     * The compression mode related to the compression algo. Defaults to null.
74
     *
75
     * @var string
76
     */
77
    protected $decompressEncoding = null;
78
79
    /**
80
     * @param mixed $payload
81
     *
82
     * @return \Mfonte\Base62x\Base62x
83
     */
84
    public static function encode($payload): self
85
    {
86
        return new self(self::MODE_ENCODE, $payload);
87
    }
88
89
    /**
90
     * @param mixed $payload
91
     *
92
     * @return \Mfonte\Base62x\Base62x
93
     */
94
    public static function decode($payload): self
95
    {
96
        return new self(self::MODE_DECODE, $payload);
97
    }
98
99
    public function __construct($mode, $payload)
100
    {
101
        $this->mode = $mode;
102
103
        if (empty($payload)) {
104
            throw new InvalidParam('payload', __FUNCTION__, __CLASS__, 'The payload cannot be empty');
105
        } elseif (\is_resource($payload)) {
106
            throw new InvalidParam('payload', __FUNCTION__, __CLASS__, 'The payload cannot be a resource');
107
        } elseif (\is_object($payload)) {
108
            throw new InvalidParam('payload', __FUNCTION__, __CLASS__, 'The payload cannot be an object');
109
        } elseif (\is_array($payload)) {
110
            // if the payload is an array, perform here and now the translation to a serialized string
111
            $payload = serialize($payload);
112
        }
113
114
        $this->payload = $payload;
115
    }
116
117
    /**
118
     * Sets the compression type and encoding.
119
     *
120
     * @param string $algo     A valid compression algorithm as seen on $_validCompressionAlgorithms
121
     * @param string $encoding A valid compression encoding as seen on $_validCompressionAlgorithms
122
     *
123
     * @return \Mfonte\Base62x\Base62x
124
     */
125
    public function compress($algo = 'gzip', $encoding = 'gzip'): self
126
    {
127
        // sanity check for bad $algo
128
        if (
129
            !\array_key_exists($algo, $this->_validCompressionAlgorithms) &&
130
            !\in_array($algo, $this->_validCompressionAlgorithms, true)
131
        ) {
132
            throw new InvalidParam('algo', __FUNCTION__, __CLASS__);
133
        }
134
135
        // sanity check for bad $encoding
136
        if (
137
            \array_key_exists($algo, $this->_validCompressionAlgorithms) &&
138
            \is_array($this->_validCompressionAlgorithms[$algo]) &&
139
            !\in_array($encoding, $this->_validCompressionAlgorithms[$algo], true)
140
        ) {
141
            throw new InvalidParam('encoding', __FUNCTION__, __CLASS__);
142
        }
143
144
        // make sure we nullify the $encoding if we don't have one
145
        if (
146
            !\array_key_exists($algo, $this->_validCompressionAlgorithms) ||
147
            !\is_array($this->_validCompressionAlgorithms[$algo])
148
        ) {
149
            $encoding = null;
150
        }
151
152
        $this->compressAlgorithm = $algo;
153
        $this->compressEncoding = $encoding;
154
155
        return $this;
156
    }
157
158
    /**
159
     * As the decompression is done automagically via the "magic string" at the beginning of the
160
     * encoded payload, this method is pointless.
161
     * It is present only as a reference.
162
     *
163
     * @return \Mfonte\Base62x\Base62x
164
     */
165
    public function decompress(): self
166
    {
167
        return $this;
168
    }
169
170
    /**
171
     * Sets the encryption key (password) and method (algorithm).
172
     *
173
     * @param string $key    A password for your encoded base62x output string
174
     * @param string $method A valid openssl cypher method as supported in your environment (openssl_get_cipher_methods)
175
     *
176
     * @return \Mfonte\Base62x\Base62x
177
     */
178
    public function encrypt(string $key, string $method = 'aes-128-ctr'): self
179
    {
180
        if (!\function_exists('openssl_get_cipher_methods')) {
181
            throw new CryptException('openssl_get_cipher_methods unsupported in your PHP installation');
182
        }
183
        if (!\in_array(mb_strtolower($method), openssl_get_cipher_methods(), true)) {
184
            throw new CryptException('Encryption method "'.$method.'" is either unsupported in your PHP installation or not a valid encryption algorithm.');
185
        }
186
187
        $this->cryptMethod = mb_strtolower($method);
188
        $this->cryptKey = $key;
189
190
        return $this;
191
    }
192
193
    /**
194
     * Sets the encryption key (password) and method (algorithm).
195
     *
196
     * @see self::encrypt
197
     */
198
    public function decrypt(string $key, string $method = 'aes-128-ctr'): self
199
    {
200
        return $this->encrypt($key, $method);
201
    }
202
203
    /**
204
     * Gets the encoded or decoded mixed variable originally passed as $payload to instance.
205
     *
206
     * @return mixed
207
     */
208
    public function get()
209
    {
210
        $retval = null;
211
        switch ($this->mode) {
212
            case self::MODE_ENCODE:
213
                $retval = $this->_encode($this->payload);
214
            break;
215
216
            case self::MODE_DECODE:
217
                $retval = $this->_decode($this->payload);
218
219
                // decoded payload can be a serialized array: if so, we return the original representation
220
                if ($this->_isSerializedString($retval) && ($unserialized = @unserialize($retval)) !== false) {
221
                    $retval = $unserialized;
222
                }
223
            break;
224
        }
225
226
        return $retval;
227
    }
228
229
    /**
230
     * Performs the actual Base62x encoding.
231
     */
232
    private function _encode(string $payload): string
233
    {
234
        if ($this->cryptKey && $this->cryptMethod) {
235
            $payload = $this->_performEncryption($payload);
236
        }
237
        if ($this->compressAlgorithm) {
238
            $payload = $this->_performCompress($payload);
239
        }
240
241
        $encoded = Encoder::encode($payload);
242
        if (empty($encoded)) {
243
            throw new EncodeException();
244
        }
245
246
        return $encoded;
247
    }
248
249
    /**
250
     * Performs the actual Base62x decoding.
251
     */
252
    private function _decode(string $payload): string
253
    {
254
        $decoded = Encoder::decode($payload);
255
        if (empty($decoded)) {
256
            throw new DecodeException();
257
        }
258
259
        // remove the magic string for Compression
260
        $data = $this->_getCompressionFootprintAndSanitizePayload($decoded);
261
262
        if ($data['compression_algo']) {
263
            $decoded = $this->_performUncompress($data['payload'], $data['compression_algo'], $data['compression_encoding']);
264
        }
265
266
        // eventually perform decryption
267
        if ($this->cryptKey && $this->cryptMethod) {
268
            $decoded = $this->_performDecryption($decoded);
269
        }
270
271
        return $decoded;
272
    }
273
274
    /**
275
     * Performs the actual compress before chaining it into the Base62x encoder.
276
     */
277
    private function _performCompress(string $payload): string
278
    {
279
        $compressed = null;
280
        switch ($this->compressAlgorithm) {
281
            case 'gzip':
282
                $compressed = GzipCompressor::encode($payload, $this->compressEncoding);
283
            break;
284
            case 'huffman':
285
                $compressed = HuffmanCompressor::encode($payload, HuffmanCompressor::createCodeTree($payload));
286
            break;
287
        }
288
289
        if (empty($compressed)) {
290
            throw new EncodeException();
291
        }
292
293
        // create the compression footprint, to avoid the decompress() on Base62x::decode()
294
        $footprint = $this->_createCompressionFootprint();
295
296
        return $footprint.$compressed;
297
    }
298
299
    /**
300
     * Decompresses the payload, that was prior compressed using one of the available compression types.
301
     */
302
    private function _performUncompress(string $compressed_payload, string $compression_algo, ?string $compression_encoding): string
303
    {
304
        switch ($compression_algo) {
305
            case 'gzip':
306
                $payload = GzipCompressor::decode($compressed_payload, $compression_encoding);
307
            break;
308
            case 'huffman':
309
                $payload = HuffmanCompressor::decode($compressed_payload);
310
            break;
311
            default:
312
                $payload = '';
313
        }
314
315
        return (string) $payload;
316
    }
317
318
    /**
319
     * Performs the actual encryption before chaining it into the Base62x encoder.
320
     */
321
    private function _performEncryption(string $payload): ?string
322
    {
323
        try {
324
            $crypt = new Crypter([
325
                'key' => $this->cryptKey,
326
                'method' => $this->cryptMethod,
327
            ]);
328
329
            return $crypt->cipher($payload)->encrypt();
330
        } catch (Exception $ex) {
331
            throw new CryptException('Cannot encrypt the payload: '.$ex->getMessage());
332
        }
333
    }
334
335
    /**
336
     * Decrypts the payload, that was prior encrypted using the on-board encrypter.
337
     */
338
    private function _performDecryption(string $payload): string
339
    {
340
        if (empty($this->cryptKey)) {
341
            throw new CryptException('Cannot decrypt the payload without a valid cryptKey');
342
        }
343
        if (empty($this->cryptMethod)) {
344
            throw new CryptException('Cannot decrypt the payload without a valid cryptMethod');
345
        }
346
347
        try {
348
            $crypt = new Crypter([
349
                'key' => $this->cryptKey,
350
                'method' => $this->cryptMethod,
351
            ]);
352
353
            $decrypted = $crypt->cipher($payload)->decrypt();
354
            if ($decrypted === false) {
355
                throw new CryptException('Cannot decrypt the payload: result from cipher()->decrypt() is false');
356
            }
357
358
            return $decrypted;
359
        } catch (Exception $ex) {
360
            throw new CryptException('Cannot decrypt the payload: '.$ex->getMessage());
361
        }
362
    }
363
364
    /**
365
     * Prepares a "magic string" that will be appendend at beginning of the compressed payload,
366
     * prior of chaining it into the Base62x encoder.
367
     * Doing so, the decode method will automagically uncompress the encoded payload, so the subsequent "decode"
368
     * can understand which compression algo+encoding was originally used.
369
     */
370
    private function _createCompressionFootprint(): string
371
    {
372
        return '[MFB62X.COMPRESS.'.base64_encode(implode(',', [$this->compressAlgorithm, $this->compressEncoding])).']';
373
    }
374
375
    /**
376
     * Gets the decoded Base26x string, and checks if it needs decompression,
377
     * by analyzing its "compression footprint" placed at the very beginning of the payload.
378
     */
379
    private function _getCompressionFootprintAndSanitizePayload(string $payload): array
380
    {
381
        $compression_algo = $compression_encoding = null;
382
        $pos_start = mb_strpos($payload, '[MFB62X.COMPRESS.');
383
        $pos_end = mb_strpos($payload, ']');
384
385
        if ($pos_start === 0 && $pos_end > 0) {
386
            $footprint = mb_substr($payload, 0, $pos_end + 1);
387
            $compression_footprint = str_replace(['[', 'MFB62X.COMPRESS.', ']'], '', $footprint);
388
389
            $compression_params = @base64_decode($compression_footprint, true);
390
            if ($compression_params && \count(explode(',', $compression_params)) === 2) {
391
                $compression_params = explode(',', $compression_params);
392
                $compression_algo = $compression_params[0];
393
                $compression_encoding = $compression_params[1];
394
            }
395
396
            // clean the payload, removing the compression footprint
397
            $payload = mb_substr($payload, $pos_end + 1);
398
        }
399
400
        // some sanity checks to avoid tampering with the payload and cause bad behaviour or worse
401
        // sanity check for bad $algo
402
        if (
403
            $compression_algo &&
404
            !\array_key_exists($compression_algo, $this->_validCompressionAlgorithms) &&
405
            !\in_array($compression_algo, $this->_validCompressionAlgorithms, true)
406
        ) {
407
            throw new DecodeException();
408
        }
409
410
        // sanity check for bad $encoding
411
        if (
412
            $compression_algo &&
413
            $compression_encoding &&
414
            \array_key_exists($compression_algo, $this->_validCompressionAlgorithms) &&
415
            \is_array($this->_validCompressionAlgorithms[$compression_algo]) &&
416
            !\in_array($compression_encoding, $this->_validCompressionAlgorithms[$compression_algo], true)
417
        ) {
418
            throw new DecodeException();
419
        }
420
421
        // make sure we nullify the $encoding if we don't have one
422
        if (
423
            $compression_algo &&
424
            (!\array_key_exists($compression_algo, $this->_validCompressionAlgorithms) ||
425
            !\is_array($this->_validCompressionAlgorithms[$compression_algo]))
426
        ) {
427
            $compression_encoding = null;
428
        }
429
430
        return [
431
            'payload' => $payload,
432
            'compression_algo' => $compression_algo,
433
            'compression_encoding' => ($compression_encoding && mb_strlen($compression_encoding) > 0) ? $compression_encoding : null,
434
        ];
435
    }
436
437
    /**
438
     * Checks whether the $data argument is a serialized string, i.e. an array serialized with native PHP's serialize().
439
     *
440
     * @param mixed $data   (should always be a string)
441
     * @param bool  $strict Whether to perform a strict analysis or not
442
     */
443
    private function _isSerializedString($data, $strict = true): bool
444
    {
445
        // If it isn't a string, it isn't serialized.
446
        if (!\is_string($data)) {
447
            return false;
448
        }
449
        $data = trim($data);
450
        if ($data == 'N;') {
451
            return true;
452
        }
453
        if (mb_strlen($data) < 4) {
454
            return false;
455
        }
456
        if ($data[1] !== ':') {
457
            return false;
458
        }
459
        if ($strict) {
460
            $lastc = mb_substr($data, -1);
461
            if ($lastc !== ';' && $lastc !== '}') {
462
                return false;
463
            }
464
        } else {
465
            $semicolon = mb_strpos($data, ';');
466
            $brace = mb_strpos($data, '}');
467
            // Either ; or } must exist.
468
            if ($semicolon === false && $brace === false) {
469
                return false;
470
            }
471
            // But neither must be in the first X characters.
472
            if ($semicolon !== false && $semicolon < 3) {
473
                return false;
474
            }
475
            if ($brace !== false && $brace < 4) {
476
                return false;
477
            }
478
        }
479
        $token = $data[0];
480
        switch ($token) {
481
            case 's':
482
                if ($strict) {
483
                    if (mb_substr($data, -2, 1) !== '"') {
484
                        return false;
485
                    }
486
                } elseif (mb_strpos($data, '"') === false) {
487
                    return false;
488
                }
489
                // Or else fall through.
490
                // no break
491
            case 'a':
492
            case 'O':
493
                return (bool) preg_match("/^{$token}:[0-9]+:/s", $data);
494
            case 'b':
495
            case 'i':
496
            case 'd':
497
                $end = $strict ? '$' : '';
498
499
                return (bool) preg_match("/^{$token}:[0-9.E+-]+;$end/", $data);
500
        }
501
502
        return false;
503
    }
504
}