Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.28% covered (success)
93.28%
125 / 134
64.71% covered (warning)
64.71%
11 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
GeneralUtils
93.28% covered (success)
93.28%
125 / 134
64.71% covered (warning)
64.71%
11 / 17
54.88
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
 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    // Base64
78
79    public function base64EncodeUrl(string $string): string {
80        return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($string));
81    }
82
83    public function base64DecodeUrl(string $string): string {
84        return base64_decode(str_replace(['-', '_'], ['+', '/'], $string));
85    }
86
87    // Crypto
88
89    public function encrypt(string $key, mixed $data): string {
90        $plaintext = json_encode($data);
91        if (!$plaintext) {
92            throw new \Exception("encrypt: json_encode failed");
93        }
94        $algo = 'aes-256-gcm';
95        $iv = $this->getRandomIvForAlgo($algo);
96        $ciphertext = openssl_encrypt($plaintext, $algo, $key, OPENSSL_RAW_DATA, $iv, $tag);
97        if (!$ciphertext) {
98            throw new \Exception("encrypt: openssl_encrypt failed");
99        }
100        $result = json_encode([
101            'algo' => $algo,
102            'iv' => $this->base64EncodeUrl($iv),
103            'tag' => $tag ? $this->base64EncodeUrl($tag) : null,
104            'ciphertext' => $this->base64EncodeUrl($ciphertext),
105        ]);
106        if (!$result) {
107            throw new \Exception("encrypt: this should never happen");
108        }
109        return $this->base64EncodeUrl($result);
110    }
111
112    protected function getRandomIvForAlgo(string $algo): string {
113        $length = openssl_cipher_iv_length($algo);
114        if ($length === false) {
115            throw new \Exception("Unknown openssl_cipher_iv_length({$algo})");
116        }
117        if ($length === 0) {
118            return '';
119        }
120        return openssl_random_pseudo_bytes($length);
121    }
122
123    public function decrypt(string $key, string $token): mixed {
124        $decrypt_data = json_decode($this->base64DecodeUrl($token), true);
125        if (!$decrypt_data) {
126            throw new \Exception("decrypt: json_decode failed");
127        }
128        $ciphertext = $this->base64DecodeUrl($decrypt_data['ciphertext']);
129        $algo = $decrypt_data['algo'] ?? 'aes-256-gcm';
130        $iv = $this->base64DecodeUrl($decrypt_data['iv']);
131        $tag = $this->base64DecodeUrl($decrypt_data['tag']);
132        $plaintext = openssl_decrypt($ciphertext, $algo, $key, OPENSSL_RAW_DATA, $iv, $tag);
133        if (!$plaintext) {
134            throw new \Exception("decrypt: openssl_decrypt failed");
135        }
136        return json_decode($plaintext, true);
137    }
138
139    public function hash(string $key, string $data): string {
140        $result = hash_hmac('sha256', $data, $key, true);
141        return $this->base64EncodeUrl($result);
142    }
143
144    // Algorithms
145
146    /** @return array{0: int, 1: int<-1, 1>} */
147    public function binarySearch(callable $compare_fn, int $start, int $end): array {
148        $search_start = $start;
149        $search_end = $end;
150        if ($search_start === $search_end) {
151            return [0, 0];
152        }
153        while ($search_start < $search_end) {
154            $probe_index = (int) floor(($search_start + $search_end) / 2);
155            $result = $compare_fn($probe_index);
156            if ($result < 0) {
157                $search_end = $probe_index;
158            } elseif ($result > 0) {
159                $search_start = $probe_index + 1;
160            } else {
161                return [$probe_index, 0];
162            }
163        }
164        if ($search_start === $end) {
165            return [$end - 1, 1];
166        }
167        $result = $compare_fn($search_start);
168        if ($result < 0) {
169            return [$search_start, -1];
170        }
171        if ($result > 0) {
172            return [$search_start, 1];
173        }
174        return [$search_start, 0];
175    }
176
177    // Debugging
178
179    /** @param array<array<string, mixed>> $trace */
180    public function getPrettyTrace(array $trace): string {
181        $output = 'Stack trace:'.PHP_EOL;
182
183        $trace_len = count($trace);
184        for ($i = 0; $i < $trace_len; $i++) {
185            $entry = $trace[$i];
186
187            $func = $entry['function'].'(';
188            $args_len = count($entry['args']);
189            for ($j = 0; $j < $args_len; $j++) {
190                $func .= json_encode($entry['args'][$j]);
191                if ($j < $args_len - 1) {
192                    $func .= ', ';
193                }
194            }
195            $func .= ')';
196
197            $output .= "#{$i} {$entry['file']}:{$entry['line']} - {$func}\n";
198        }
199        return $output;
200    }
201
202    /** @param array<array<string, mixed>> $trace */
203    public function getTraceOverview(array $trace, int $first_index = 1): string {
204        $output = '';
205        $trace_len = count($trace);
206        $last_class_name = null;
207        for ($i = $trace_len - 1; $i >= $first_index; $i--) {
208            $entry = $trace[$i];
209
210            $class_name = $entry['class'] ?? '';
211            if (
212                $class_name === ''
213                || $class_name === $last_class_name
214                || substr($class_name, 0, 4) !== 'Olz\\'
215            ) {
216                continue;
217            }
218            $reflection_class = new \ReflectionClass($class_name);
219            if ($reflection_class->isAbstract()) {
220                continue;
221            }
222            $last_class_name = $class_name;
223            $base_class_name = substr($class_name, strrpos($class_name, '\\') + 1);
224
225            if ($output !== '') {
226                $output .= ">";
227            }
228            $output .= "{$base_class_name}";
229        }
230        return $output;
231    }
232
233    /** @return array<mixed> */
234    public function measureLatency(callable $fn): array {
235        $before = microtime(true);
236        $result = $fn();
237        $duration = round((microtime(true) - $before) * 1000, 1);
238        $msg = "took {$duration}ms";
239        return [$result, $msg];
240    }
241
242    // Tools
243
244    public function removeRecursive(string $path): void {
245        if (is_dir($path)) {
246            $entries = scandir($path) ?: [];
247            foreach ($entries as $entry) {
248                if ($entry === '.' || $entry === '..') {
249                    continue;
250                }
251                $entry_path = realpath("{$path}/{$entry}");
252                if (!$entry_path) {
253                    continue;
254                }
255                $this->removeRecursive($entry_path);
256            }
257            rmdir($path);
258        } elseif (is_file($path)) {
259            unlink($path);
260        }
261    }
262
263    public static function fromEnv(): self {
264        return new self();
265    }
266}