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