PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / core / FileSystemService.php

FileSystemService.php in 404 Solution trunk, at includes/core/FileSystemService.php

422 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * File and directory I/O for the plugin: reading SQL templates and HTML
10 * fragments, writing remote-downloaded payloads, and removing temp dirs.
11 *
12 * Extracted from ABJ_404_Solution_Functions per design-audit-2026-06-02
13 * M201 (Functions.php grab-bag split). All methods are static and have
14 * no dependency on the polymorphic mbstring adapter base class.
15 *
16 * Survival of transient stream-wrapper warnings (under Patchwork during
17 * heavy ParaTest parallelism) is the responsibility of
18 * fileGetContentsWithTransientRetry(), which retries up to
19 * FILE_READ_MAX_ATTEMPTS on EINTR-style "Interrupted system call"
20 * warnings before giving up and falling through to the curl fallback.
21 */
22 class ABJ_404_Solution_FileSystemService {
23
24 private const FILE_READ_MAX_ATTEMPTS = 3;
25 private const FILE_READ_RETRY_BASE_US = 10000;
26 public const CURL_FILE_READ_TIMEOUT_SECONDS = 5;
27
28 /** @var callable(string,string,array<string,int|string|bool|null>,callable): mixed|null */
29 private static $operationTracer = null;
30
31 /** @param callable(string,string,array<string,int|string|bool|null>,callable): mixed|null $tracer */
32 public static function setOperationTracer($tracer): void {
33 self::$operationTracer = is_callable($tracer) ? $tracer : null;
34 }
35
36 /** Returns true if the file does not exist after calling this method.
37 * @param string $path
38 * @return boolean
39 */
40 static function safeUnlink($path) {
41 if (!file_exists($path)) {
42 return true;
43 }
44
45 $unlinkError = null;
46 set_error_handler(static function($errno, $errstr) use (&$unlinkError) {
47 $unlinkError = (string)$errstr;
48 return true;
49 });
50 try {
51 $result = unlink($path);
52 } finally {
53 restore_error_handler();
54 }
55 if ($result !== false) {
56 return true;
57 }
58
59 clearstatcache(true, (string)$path);
60 if (!file_exists($path)) {
61 return true;
62 }
63
64 $reason = $unlinkError !== null
65 ? $unlinkError
66 : 'unknown unlink failure';
67 self::logWarning('Unable to unlink file: ' . (string)$path . ' (' . $reason . ')');
68 return false;
69 }
70
71 /** Recursively delete a directory.
72 * @param string $dir
73 * @throws Exception
74 * @return boolean
75 */
76 static function deleteDirectoryRecursively($dir) {
77 // if the directory isn't a part of our plugin then don't do it.
78 if (strpos($dir, ABJ404_PATH) === false) {
79 throw new Exception("Can't delete " . esc_html($dir));
80 }
81
82 // if it's already gone then we're done.
83 if (!file_exists($dir)) {
84 return true;
85 }
86
87 // if it's not a directory then delete the file.
88 if (!is_dir($dir)) {
89 return unlink($dir);
90 }
91
92 // get a list of all files (and directories) in the directory.
93 $items = scandir($dir);
94 if (!is_array($items)) { $items = array(); }
95 foreach ($items as $item) {
96 if ($item == '.' || $item == '..') {
97 continue;
98 }
99
100 // call self to delete the file/directory.
101 if (!self::deleteDirectoryRecursively($dir . DIRECTORY_SEPARATOR . $item)) {
102 return false;
103 }
104
105 }
106
107 // remove the original directory.
108 return rmdir($dir);
109 }
110
111 /**
112 * Create a directory (recursively), logging error_get_last() reasons on
113 * failure. Returns true if the directory exists when this method
114 * returns, false otherwise.
115 *
116 * @param string $directory
117 * @return boolean
118 */
119 static function createDirectoryWithErrorMessages($directory) {
120 if (!is_dir($directory)) {
121 if (file_exists($directory) || file_exists(rtrim($directory, '/'))) {
122 $unlinkErr = null;
123 if (!@unlink($directory)) {
124 $lastErr = error_get_last();
125 $unlinkErr = is_array($lastErr) ? $lastErr['message'] : 'unknown';
126 }
127
128 if (file_exists($directory) || file_exists(rtrim($directory, '/'))) {
129 self::logWarning("Error creating the directory " .
130 $directory . ". A file with that name already exists" .
131 ($unlinkErr !== null ? " and unlink() failed: " . $unlinkErr : "") .
132 ". Action: aborting directory creation, returning false.");
133 return false;
134 }
135
136 } else if (!@mkdir($directory, 0755, true)) {
137 $lastErr = error_get_last();
138 $mkdirErr = is_array($lastErr) ? $lastErr['message'] : 'unknown';
139 self::logWarning("Error creating the directory " .
140 $directory . ". mkdir() failed: " . $mkdirErr .
141 ". Action: aborting directory creation, returning false.");
142 return false;
143 }
144 }
145 return true;
146 }
147
148 /** Reads an entire file at once into a string and return it.
149 * @param string $path
150 * @param boolean $appendExtraData
151 * @throws Exception
152 * @return string
153 */
154 static function readFileContents($path, $appendExtraData = true) {
155 // modify what's returned to make debugging easier.
156 $dataSupplement = self::getDataSupplement($path, $appendExtraData);
157
158 $exists = self::traceFileOperation(
159 'stat',
160 (string)$path,
161 array(),
162 static fn(): bool => file_exists($path)
163 );
164 if (!$exists) {
165 throw new Exception("Error: Can't find file: " . esc_html($path));
166 }
167
168 $readResult = self::fileGetContentsWithTransientRetry($path);
169 $fileContents = $readResult['contents'];
170 if ($fileContents !== false) {
171 if (!empty($readResult['warnings'])) {
172 self::traceFileOperation(
173 'warning_log',
174 (string)$path,
175 array('warning_count' => count($readResult['warnings'])),
176 static function () use ($path, $readResult): void {
177 self::logWarning(
178 'readFileContents recovered after transient file-open failure for '
179 . $path . '. ' . self::formatFileReadWarnings($readResult['warnings'])
180 );
181 }
182 );
183 }
184 return $dataSupplement['prefix'] . $fileContents . $dataSupplement['suffix'];
185 }
186
187 $warningDetails = self::formatFileReadWarnings($readResult['warnings']);
188
189 // if we can't read the file that way then try curl.
190 if (!function_exists('curl_init')) {
191 throw new Exception("Error: Can't read file: " . esc_html($path) .
192 "\n file_get_contents didn't work and curl is not installed." . $warningDetails);
193 }
194 $output = self::traceFileOperation(
195 'curl_fallback',
196 (string)$path,
197 array('timeout_seconds' => self::CURL_FILE_READ_TIMEOUT_SECONDS),
198 static function () use ($path) {
199 $ch = curl_init();
200 if ($ch === false) {
201 return false;
202 }
203 try {
204 curl_setopt($ch, CURLOPT_URL, 'file://' . $path);
205 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
206 curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::CURL_FILE_READ_TIMEOUT_SECONDS);
207 curl_setopt($ch, CURLOPT_TIMEOUT, self::CURL_FILE_READ_TIMEOUT_SECONDS);
208 if (defined('CURLOPT_NOSIGNAL')) {
209 curl_setopt($ch, CURLOPT_NOSIGNAL, true);
210 }
211 return curl_exec($ch);
212 } finally {
213 curl_close($ch);
214 }
215 }
216 );
217
218 if (!is_string($output)) {
219 throw new Exception("Error: Can't read file, even with cURL: " . esc_html($path) . $warningDetails);
220 }
221
222 if ($warningDetails !== '') {
223 self::traceFileOperation(
224 'warning_log',
225 (string)$path,
226 array('warning_count' => count($readResult['warnings'])),
227 static function () use ($path, $warningDetails): void {
228 self::logWarning(
229 'readFileContents used cURL fallback after file_get_contents failed for '
230 . $path . '. ' . $warningDetails
231 );
232 }
233 );
234 }
235
236 return $dataSupplement['prefix'] . $output . $dataSupplement['suffix'];
237 }
238
239 /**
240 * Reads a file while retrying only the transient EINTR-style failures
241 * emitted by Patchwork's stream wrapper under high ParaTest load.
242 *
243 * @param string $path
244 * @return array{contents:string|false,warnings:array<int,array{errno:int,message:string,file:string,line:int}>}
245 */
246 private static function fileGetContentsWithTransientRetry($path): array {
247 $warnings = array();
248
249 for ($attempt = 1; $attempt <= self::FILE_READ_MAX_ATTEMPTS; $attempt++) {
250 $attemptWarnings = array();
251
252 set_error_handler(
253 static function(int $errno, string $errstr, string $errfile = '', int $errline = 0) use (&$attemptWarnings): bool {
254 if (($errno & (E_WARNING | E_USER_WARNING)) === 0) {
255 return false;
256 }
257 $attemptWarnings[] = array(
258 'errno' => $errno,
259 'message' => $errstr,
260 'file' => $errfile,
261 'line' => $errline,
262 );
263 return true;
264 },
265 E_WARNING | E_USER_WARNING
266 );
267 try {
268 $contents = self::traceFileOperation(
269 'read_attempt',
270 (string)$path,
271 array('attempt' => $attempt),
272 static fn() => file_get_contents($path)
273 );
274 } finally {
275 restore_error_handler();
276 }
277
278 if ($contents !== false) {
279 return array(
280 'contents' => $contents,
281 'warnings' => array_merge($warnings, $attemptWarnings),
282 );
283 }
284
285 $warnings = array_merge($warnings, $attemptWarnings);
286 if (!self::isTransientFileReadWarning($attemptWarnings)) {
287 break;
288 }
289
290 if ($attempt < self::FILE_READ_MAX_ATTEMPTS) {
291 $delayUs = self::FILE_READ_RETRY_BASE_US * $attempt;
292 self::traceFileOperation(
293 'retry_wait',
294 (string)$path,
295 array('attempt' => $attempt, 'delay_us' => $delayUs),
296 static fn() => usleep($delayUs)
297 );
298 }
299 }
300
301 return array(
302 'contents' => false,
303 'warnings' => $warnings,
304 );
305 }
306
307 /**
308 * @param array<int,array{errno:int,message:string,file:string,line:int}> $warnings
309 * @return bool
310 */
311 private static function isTransientFileReadWarning(array $warnings): bool {
312 foreach ($warnings as $warning) {
313 if (strpos(strtolower($warning['message']), 'interrupted system call') !== false) {
314 return true;
315 }
316 }
317 return false;
318 }
319
320 /**
321 * @param array<int,array{errno:int,message:string,file:string,line:int}> $warnings
322 * @return string
323 */
324 private static function formatFileReadWarnings(array $warnings): string {
325 if (empty($warnings)) {
326 return '';
327 }
328
329 $parts = array();
330 foreach ($warnings as $warning) {
331 $parts[] = sprintf(
332 '[%d] %s in %s:%d',
333 $warning['errno'],
334 $warning['message'],
335 $warning['file'],
336 $warning['line']
337 );
338 }
339
340 return "\n file_get_contents warnings: " . implode(' | ', $parts);
341 }
342
343 /**
344 * Build the BEGIN/END banner pair that brackets file contents in the
345 * debug-log dump so an admin reading a debug bundle can see where each
346 * embedded artifact starts and ends. SQL gets /* ... *\/ comments;
347 * HTML gets HTML comments; other types get a generic marker.
348 *
349 * @param string $filePath
350 * @param bool $appendExtraData
351 * @return array<string, string>
352 */
353 private static function getDataSupplement(string $filePath, bool $appendExtraData = true): array {
354 $path = strtolower($filePath);
355
356 // remove the first part of the path because some people don't want to see
357 // it in the log file.
358 $homepath = (string) dirname(ABSPATH);
359 $beginningOfPath = (string) substr($path, 0, strlen($homepath));
360 if (strtolower($beginningOfPath) === strtolower($homepath)) {
361 $path = (string) substr($path, strlen($homepath));
362 }
363
364 $supplement = array();
365
366 if (!$appendExtraData) {
367 $supplement['prefix'] = '';
368 $supplement['suffix'] = '';
369
370 } else if (self::endsWithCaseInsensitive($path, '.sql')) {
371 $supplement['prefix'] = "\n/* ------------------ " . $filePath . " BEGIN ----- */ \n";
372 $supplement['suffix'] = "\n/* ------------------ " . $filePath . " END ----- */ \n";
373
374 } else if (self::endsWithCaseInsensitive($path, '.html')) {
375 $supplement['prefix'] = "\n<!-- ------------------ " . $filePath . " BEGIN ----- --> \n";
376 $supplement['suffix'] = "\n<!-- ------------------ " . $filePath . " END ----- --> \n";
377
378 } else {
379 $supplement['prefix'] = "\n/* ------------------ " . $filePath . " BEGIN unknown file type in "
380 . __CLASS__ . '::' . __FUNCTION__ . "() ----- */ \n";
381 $supplement['suffix'] = "\n/* ------------------ " . $filePath . " END unknown file type in "
382 . __CLASS__ . '::' . __FUNCTION__ . "() ----- */ \n";
383 }
384
385 return $supplement;
386 }
387
388 private static function endsWithCaseInsensitive(string $haystack, string $needle): bool {
389 $length = strlen($needle);
390 if (strlen($haystack) < $length) {
391 return false;
392 }
393 return strtolower(substr($haystack, -$length)) === strtolower($needle);
394 }
395
396 private static function logWarning(string $message): void {
397 $logger = function_exists('abj_service_optional') ? abj_service_optional('logging') : null;
398 if (is_object($logger) && method_exists($logger, 'warn')) {
399 $logger->warn($message);
400 return;
401 }
402
403 abj404_logPhpFallback('service-resolution-fallback', $message);
404 }
405
406 /** @template T
407 * @param array<string, int|string|bool|null> $fields
408 * @param callable(): T $work
409 * @return T */
410 private static function traceFileOperation(
411 string $operation,
412 string $path,
413 array $fields,
414 callable $work
415 ) {
416 if (!is_callable(self::$operationTracer)) {
417 return $work();
418 }
419 return call_user_func(self::$operationTracer, $operation, $path, $fields, $work);
420 }
421 }
422