Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
93.28% |
125 / 134 |
|
64.71% |
11 / 17 |
CRAP | |
0.00% |
0 / 1 |
GeneralUtils | |
93.28% |
125 / 134 |
|
64.71% |
11 / 17 |
54.88 | |
0.00% |
0 / 1 |
checkNotNull | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
checkNotFalse | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
checkNotBool | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
checkNotEmpty | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getCheckErrorMessage | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
base64EncodeUrl | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
base64DecodeUrl | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
encrypt | |
88.24% |
15 / 17 |
|
0.00% |
0 / 1 |
5.04 | |||
getRandomIvForAlgo | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
decrypt | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
3.01 | |||
hash | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
binarySearch | |
85.00% |
17 / 20 |
|
0.00% |
0 / 1 |
8.22 | |||
getPrettyTrace | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
4 | |||
getTraceOverview | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
7.01 | |||
measureLatency | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
removeRecursive | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
8.04 | |||
fromEnv | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace Olz\Utils; |
4 | |
5 | class 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 | } |