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