Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.51% covered (success)
94.51%
155 / 164
73.91% covered (warning)
73.91%
17 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
GeneralUtils
94.51% covered (success)
94.51%
155 / 164
73.91% covered (warning)
73.91%
17 / 23
70.81
0.00% covered (danger)
0.00%
0 / 1
 checkNotNull
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 checkNotFalse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 checkNotBool
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 checkNotEmpty
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getCheckErrorMessage
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 isOneEmoji
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 escape
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 unescape
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 internalSqlEscape
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 internalNullableSqlEscape
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 base64EncodeUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 base64DecodeUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 serializeTokenBitMap
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 deserializeTokenBitMap
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 encrypt
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
5.04
 getRandomIvForAlgo
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 decrypt
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 hash
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 binarySearch
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
8.22
 getPrettyTrace
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 getTraceOverview
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
7.01
 measureLatency
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 removeRecursive
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
8.04
1<?php
2
3namespace Olz\Utils;
4
5use Incenteev\EmojiPattern\EmojiPattern;
6
7class GeneralUtils {
8    use WithUtilsTrait;
9
10    // Error handling
11
12    /**
13     * @phpstan-assert !null $value
14     *
15     * @param string|callable(): string $error_message
16     */
17    public function checkNotNull(mixed $value, string|callable $error_message): void {
18        if ($value === null) {
19            $message = $this->getCheckErrorMessage($error_message);
20            $this->log()->error($message);
21            throw new \Exception($message);
22        }
23    }
24
25    /**
26     * @phpstan-assert !false $value
27     *
28     * @param string|callable(): string $error_message
29     */
30    public function checkNotFalse(mixed $value, string|callable $error_message): void {
31        if ($value === false) {
32            $message = $this->getCheckErrorMessage($error_message);
33            $this->log()->error($message);
34            throw new \Exception($message);
35        }
36    }
37
38    /**
39     * @phpstan-assert !bool $value
40     *
41     * @param string|callable(): string $error_message
42     */
43    public function checkNotBool(mixed $value, string|callable $error_message): void {
44        if (is_bool($value)) {
45            $message = $this->getCheckErrorMessage($error_message);
46            $this->log()->error($message);
47            throw new \Exception($message);
48        }
49    }
50
51    /**
52     * @phpstan-assert !'' $value
53     *
54     * @param string|callable(): string $error_message
55     */
56    public function checkNotEmpty(mixed $value, string|callable $error_message): void {
57        if ($value === '') {
58            $message = $this->getCheckErrorMessage($error_message);
59            $this->log()->error($message);
60            throw new \Exception($message);
61        }
62    }
63
64    /** @param string|callable(): string $error_message */
65    protected function getCheckErrorMessage(string|callable $error_message): string {
66        $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
67        $app_env = $this->envUtils()->getAppEnv();
68        $is_test = $app_env === 'test';
69        $file = basename($trace[1]['file'] ?? '');
70        $line = $is_test ? "***" : $trace[1]['line'] ?? '';
71        $callsite = "{$file}:{$line}";
72        if (is_string($error_message)) {
73            return "{$callsite} {$error_message}";
74        }
75        $computed_error_message = $error_message();
76        return "{$callsite} {$computed_error_message}";
77    }
78
79    // Emoji
80
81    public function isOneEmoji(string $text): bool {
82        return (bool) preg_match('/^'.EmojiPattern::getEmojiPattern().'$/u', $text);
83    }
84
85    // Escape
86
87    /** @param array<string> $tokens */
88    public function escape(string $string, array $tokens): string {
89        $esc_tokens = array_map(fn ($token) => "\\{$token}", $tokens);
90        return str_replace($tokens, $esc_tokens, $string);
91    }
92
93    /** @param array<string> $tokens */
94    public function unescape(string $string, array $tokens): string {
95        $esc_tokens = array_map(fn ($token) => "\\{$token}", $tokens);
96        return str_replace($esc_tokens, $tokens, $string);
97    }
98
99    public function internalSqlEscape(string $value): string {
100        $escaped = '';
101        $value_len = strlen($value);
102        for ($i = 0; $i < $value_len; $i++) {
103            $char = $value[$i];
104            $ord = ord($char);
105            if ($char !== "'" && $char !== "\"" && $char !== '\\') {
106                $escaped .= $char;
107            } else {
108                $escaped .= '\x'.dechex($ord);
109            }
110        }
111        return $escaped;
112    }
113
114    public function internalNullableSqlEscape(?string $value): string {
115        if ($value === null) {
116            return 'NULL';
117        }
118        $escaped = $this->internalSqlEscape($value);
119        return "'{$escaped}'";
120    }
121
122    // Base64
123
124    public function base64EncodeUrl(string $string): string {
125        return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($string));
126    }
127
128    public function base64DecodeUrl(string $string): string {
129        return base64_decode(str_replace(['-', '_'], ['+', '/'], $string));
130    }
131
132    // Data transformation
133
134    /** @param array<string, bool> $token_bitmap */
135    public function serializeTokenBitMap(array $token_bitmap): string {
136        $token_list = [];
137        foreach ($token_bitmap as $key => $value) {
138            if ($value) {
139                $token_list[] = $key;
140            }
141        }
142        return ' '.implode(' ', $token_list).' ';
143    }
144
145    /** @return array<string, true> */
146    public function deserializeTokenBitMap(string $serialized): array {
147        $token_list = preg_split('/[ ]+/', $serialized);
148        if (!is_array($token_list)) {
149            return [];
150        }
151        $token_bitmap = [];
152        foreach ($token_list as $token) {
153            if (strlen($token) > 0) {
154                $token_bitmap[$token] = true;
155            }
156        }
157        return $token_bitmap;
158    }
159
160    // Crypto
161
162    public function encrypt(string $key, mixed $data): string {
163        $plaintext = json_encode($data);
164        if (!$plaintext) {
165            throw new \Exception("encrypt: json_encode failed");
166        }
167        $algo = 'aes-256-gcm';
168        $iv = $this->getRandomIvForAlgo($algo);
169        $ciphertext = openssl_encrypt($plaintext, $algo, $key, OPENSSL_RAW_DATA, $iv, $tag);
170        if (!$ciphertext) {
171            throw new \Exception("encrypt: openssl_encrypt failed");
172        }
173        $result = json_encode([
174            'algo' => $algo,
175            'iv' => $this->base64EncodeUrl($iv),
176            'tag' => $tag ? $this->base64EncodeUrl($tag) : null,
177            'ciphertext' => $this->base64EncodeUrl($ciphertext),
178        ]);
179        if (!$result) {
180            throw new \Exception("encrypt: this should never happen");
181        }
182        return $this->base64EncodeUrl($result);
183    }
184
185    protected function getRandomIvForAlgo(string $algo): string {
186        $length = openssl_cipher_iv_length($algo);
187        if ($length === false) {
188            throw new \Exception("Unknown openssl_cipher_iv_length({$algo})");
189        }
190        if ($length === 0) {
191            return '';
192        }
193        return openssl_random_pseudo_bytes($length);
194    }
195
196    public function decrypt(string $key, string $token): mixed {
197        $decrypt_data = json_decode($this->base64DecodeUrl($token), true);
198        if (!$decrypt_data) {
199            throw new \Exception("decrypt: json_decode failed");
200        }
201        $ciphertext = $this->base64DecodeUrl($decrypt_data['ciphertext']);
202        $algo = $decrypt_data['algo'] ?? 'aes-256-gcm';
203        $iv = $this->base64DecodeUrl($decrypt_data['iv']);
204        $tag = $this->base64DecodeUrl($decrypt_data['tag']);
205        $plaintext = openssl_decrypt($ciphertext, $algo, $key, OPENSSL_RAW_DATA, $iv, $tag);
206        if (!$plaintext) {
207            throw new \Exception("decrypt: openssl_decrypt failed");
208        }
209        return json_decode($plaintext, true);
210    }
211
212    public function hash(string $key, string $data): string {
213        $result = hash_hmac('sha256', $data, $key, true);
214        return $this->base64EncodeUrl($result);
215    }
216
217    // Algorithms
218
219    /** @return array{0: int, 1: int<-1, 1>} */
220    public function binarySearch(callable $compare_fn, int $start, int $end): array {
221        $search_start = $start;
222        $search_end = $end;
223        if ($search_start === $search_end) {
224            return [0, 0];
225        }
226        while ($search_start < $search_end) {
227            $probe_index = (int) floor(($search_start + $search_end) / 2);
228            $result = $compare_fn($probe_index);
229            if ($result < 0) {
230                $search_end = $probe_index;
231            } elseif ($result > 0) {
232                $search_start = $probe_index + 1;
233            } else {
234                return [$probe_index, 0];
235            }
236        }
237        if ($search_start === $end) {
238            return [$end - 1, 1];
239        }
240        $result = $compare_fn($search_start);
241        if ($result < 0) {
242            return [$search_start, -1];
243        }
244        if ($result > 0) {
245            return [$search_start, 1];
246        }
247        return [$search_start, 0];
248    }
249
250    // Debugging
251
252    /** @param array<array<string, mixed>> $trace */
253    public function getPrettyTrace(array $trace): string {
254        $output = 'Stack trace:'.PHP_EOL;
255
256        $trace_len = count($trace);
257        for ($i = 0; $i < $trace_len; $i++) {
258            $entry = $trace[$i];
259
260            $func = $entry['function'].'(';
261            $args_len = count($entry['args']);
262            for ($j = 0; $j < $args_len; $j++) {
263                $func .= json_encode($entry['args'][$j]);
264                if ($j < $args_len - 1) {
265                    $func .= ', ';
266                }
267            }
268            $func .= ')';
269
270            $output .= "#{$i} {$entry['file']}:{$entry['line']} - {$func}\n";
271        }
272        return $output;
273    }
274
275    /** @param array<array<string, mixed>> $trace */
276    public function getTraceOverview(array $trace, int $first_index = 1): string {
277        $output = '';
278        $trace_len = count($trace);
279        $last_class_name = null;
280        for ($i = $trace_len - 1; $i >= $first_index; $i--) {
281            $entry = $trace[$i];
282
283            $class_name = $entry['class'] ?? '';
284            if (
285                $class_name === ''
286                || $class_name === $last_class_name
287                || substr($class_name, 0, 4) !== 'Olz\\'
288            ) {
289                continue;
290            }
291            $reflection_class = new \ReflectionClass($class_name);
292            if ($reflection_class->isAbstract()) {
293                continue;
294            }
295            $last_class_name = $class_name;
296            $base_class_name = substr($class_name, strrpos($class_name, '\\') + 1);
297
298            if ($output !== '') {
299                $output .= ">";
300            }
301            $output .= "{$base_class_name}";
302        }
303        return $output;
304    }
305
306    /** @return array<mixed> */
307    public function measureLatency(callable $fn): array {
308        $before = microtime(true);
309        $result = $fn();
310        $duration = round((microtime(true) - $before) * 1000, 1);
311        $msg = "took {$duration}ms";
312        return [$result, $msg];
313    }
314
315    // Tools
316
317    public function removeRecursive(string $path): void {
318        if (is_dir($path)) {
319            $entries = scandir($path) ?: [];
320            foreach ($entries as $entry) {
321                if ($entry === '.' || $entry === '..') {
322                    continue;
323                }
324                $entry_path = realpath("{$path}/{$entry}");
325                if (!$entry_path) {
326                    continue;
327                }
328                $this->removeRecursive($entry_path);
329            }
330            rmdir($path);
331        } elseif (is_file($path)) {
332            unlink($path);
333        }
334    }
335}