Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.91% covered (warning)
59.91%
272 / 454
28.57% covered (danger)
28.57%
12 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
FSFileBackend
60.04% covered (warning)
60.04%
272 / 453
28.57% covered (danger)
28.57%
12 / 42
2594.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
5.25
 getFeatures
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveContainerPath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 isLegalRelPath
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 containerFSRoot
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 resolveToFSPath
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 isPathUsableInternal
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
10.54
 doCreateInternal
51.52% covered (warning)
51.52%
17 / 33
0.00% covered (danger)
0.00%
0 / 1
24.79
 doStoreInternal
62.86% covered (warning)
62.86%
22 / 35
0.00% covered (danger)
0.00%
0 / 1
21.66
 doCopyInternal
64.10% covered (warning)
64.10%
25 / 39
0.00% covered (danger)
0.00%
0 / 1
27.84
 doMoveInternal
48.15% covered (danger)
48.15%
13 / 27
0.00% covered (danger)
0.00%
0 / 1
27.87
 doDeleteInternal
47.62% covered (danger)
47.62%
10 / 21
0.00% covered (danger)
0.00%
0 / 1
20.64
 doPrepareInternal
77.27% covered (warning)
77.27%
17 / 22
0.00% covered (danger)
0.00%
0 / 1
11.17
 doSecureInternal
43.75% covered (danger)
43.75%
7 / 16
0.00% covered (danger)
0.00%
0 / 1
19.39
 doPublishInternal
50.00% covered (danger)
50.00%
7 / 14
0.00% covered (danger)
0.00%
0 / 1
22.50
 doCleanInternal
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 doGetFileStat
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 doClearCache
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 doDirectoryExists
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getDirectoryListInternal
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
5.93
 getFileListInternal
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
6.75
 doGetLocalReferenceMulti
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 doGetLocalCopyMulti
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
7.50
 addShellboxInputFile
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 directoriesAreVirtual
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doExecuteOpHandlesInternal
50.00% covered (danger)
50.00%
7 / 14
0.00% covered (danger)
0.00%
0 / 1
6.00
 makeStagingPath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 makeCopyCommand
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 makeMoveCommand
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 makeUnlinkCommand
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 chmod
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 unlink
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 rmdir
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 newTempFileWithContent
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 indexHtmlPrivate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 htaccessPrivate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 cleanPathSlashes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 trapWarnings
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 trapWarningsIgnoringNotFound
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 untrapWarnings
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFileNotFoundRegex
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
19.85
 isFileNotFoundError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 * @ingroup FileBackend
6 */
7
8/**
9 * File system based backend.
10 *
11 * @license GPL-2.0-or-later
12 * @file
13 * @ingroup FileBackend
14 */
15
16// @phan-file-suppress UnusedPluginSuppression,UnusedPluginFileSuppression False positive on Windows only
17
18namespace Wikimedia\FileBackend;
19
20use Shellbox\Command\BoxedCommand;
21use Shellbox\Shellbox;
22use StatusValue;
23use Wikimedia\FileBackend\FileIteration\FSFileBackendDirList;
24use Wikimedia\FileBackend\FileIteration\FSFileBackendFileList;
25use Wikimedia\FileBackend\FileOpHandle\FSFileOpHandle;
26use Wikimedia\FileBackend\FSFile\FSFile;
27use Wikimedia\FileBackend\FSFile\TempFSFile;
28use Wikimedia\ObjectCache\MapCacheLRU;
29use Wikimedia\Timestamp\ConvertibleTimestamp;
30use Wikimedia\Timestamp\TimestampFormat as TS;
31
32/**
33 * @brief Class for a file system (FS) based file backend.
34 *
35 * All "containers" each map to a directory under the backend's base directory.
36 * For backwards-compatibility, some container paths can be set to custom paths.
37 * The domain ID will not be used in any custom paths, so this should be avoided.
38 *
39 * Having directories with thousands of files will diminish performance.
40 * Sharding can be accomplished by using FileRepo-style hash paths.
41 *
42 * StatusValue messages should avoid mentioning the internal FS paths.
43 * PHP warnings are assumed to be logged rather than output.
44 *
45 * @ingroup FileBackend
46 * @since 1.19
47 */
48class FSFileBackend extends FileBackendStore {
49    /** @var MapCacheLRU Cache for known prepared/usable directories */
50    protected $usableDirCache;
51
52    /** @var string|null Directory holding the container directories */
53    protected $basePath;
54
55    /** @var array<string,string> Map of container names to root paths for custom container paths */
56    protected $containerPaths;
57
58    /** @var int Directory permission mode */
59    protected $dirMode;
60    /** @var int File permission mode */
61    protected $fileMode;
62    /** @var string Required OS username to own files */
63    protected $fileOwner;
64    /** @var string OS username running this script */
65    protected $currentUser;
66
67    /** @var bool[] Map of (stack index => whether a warning happened) */
68    private $warningTrapStack = [];
69    /** @var bool Simpler version of PHP_OS_FAMILY */
70    private $isWindows;
71
72    /**
73     * @see FileBackendStore::__construct()
74     * Additional $config params include:
75     *   - basePath       : File system directory that holds containers.
76     *   - containerPaths : Map of container names to custom file system directories.
77     *                      This should only be used for backwards-compatibility.
78     *   - fileMode       : Octal UNIX file permissions to use on files stored.
79     *   - directoryMode  : Octal UNIX file permissions to use on directories created.
80     * @param array $config
81     */
82    public function __construct( array $config ) {
83        parent::__construct( $config );
84
85        // Remove any possible trailing slash from directories
86        if ( isset( $config['basePath'] ) ) {
87            $this->basePath = rtrim( $config['basePath'], '/' ); // remove trailing slash
88        } else {
89            $this->basePath = null; // none; containers must have explicit paths
90        }
91
92        $this->containerPaths = [];
93        foreach ( ( $config['containerPaths'] ?? [] ) as $container => $fsPath ) {
94            $this->containerPaths[$container] = rtrim( $fsPath, '/' ); // remove trailing slash
95        }
96
97        $this->fileMode = $config['fileMode'] ?? 0o644;
98        $this->dirMode = $config['directoryMode'] ?? 0o777;
99        if ( isset( $config['fileOwner'] ) && function_exists( 'posix_getuid' ) ) {
100            $this->fileOwner = $config['fileOwner'];
101            // Cache this, assuming it doesn't change
102            $this->currentUser = posix_getpwuid( posix_getuid() )['name'];
103        }
104
105        $this->usableDirCache = new MapCacheLRU( self::CACHE_CHEAP_SIZE );
106        $this->isWindows = ( PHP_OS_FAMILY === 'Windows' );
107    }
108
109    /** @inheritDoc */
110    public function getFeatures() {
111        return self::ATTR_UNICODE_PATHS;
112    }
113
114    /** @inheritDoc */
115    protected function resolveContainerPath( $container, $relStoragePath ) {
116        // Check that container has a root directory
117        if ( isset( $this->containerPaths[$container] ) || $this->basePath !== null ) {
118            // Check for sensible relative paths (assume the base paths are OK)
119            if ( $this->isLegalRelPath( $relStoragePath ) ) {
120                return $relStoragePath;
121            }
122        }
123
124        return null; // invalid
125    }
126
127    /**
128     * Check a relative file system path for validity
129     *
130     * @param string $fsPath Normalized relative path
131     * @return bool
132     */
133    protected function isLegalRelPath( $fsPath ) {
134        // Check for file names longer than 255 chars
135        if ( preg_match( '![^/]{256}!', $fsPath ) ) { // ext3/NTFS
136            return false;
137        }
138        if ( $this->isWindows ) { // NTFS
139            return !preg_match( '![:*?"<>|]!', $fsPath );
140        } else {
141            return true;
142        }
143    }
144
145    /**
146     * Given the short (unresolved) and full (resolved) name of
147     * a container, return the file system path of the container.
148     *
149     * @param string $shortCont
150     * @param string $fullCont
151     * @return string|null
152     */
153    protected function containerFSRoot( $shortCont, $fullCont ) {
154        if ( isset( $this->containerPaths[$shortCont] ) ) {
155            return $this->containerPaths[$shortCont];
156        } elseif ( $this->basePath !== null ) {
157            return "{$this->basePath}/{$fullCont}";
158        }
159
160        return null; // no container base path defined
161    }
162
163    /**
164     * Get the absolute file system path for a storage path
165     *
166     * @param string $storagePath
167     * @return string|null
168     */
169    protected function resolveToFSPath( $storagePath ) {
170        [ $fullCont, $relPath ] = $this->resolveStoragePathReal( $storagePath );
171        if ( $relPath === null ) {
172            return null; // invalid
173        }
174        [ , $shortCont, ] = FileBackend::splitStoragePath( $storagePath );
175        $fsPath = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
176        if ( $relPath != '' ) {
177            $fsPath .= "/{$relPath}";
178        }
179
180        return $fsPath;
181    }
182
183    /** @inheritDoc */
184    public function isPathUsableInternal( $storagePath ) {
185        $fsPath = $this->resolveToFSPath( $storagePath );
186        if ( $fsPath === null ) {
187            return false; // invalid
188        }
189
190        if ( $this->fileOwner !== null && $this->currentUser !== $this->fileOwner ) {
191            trigger_error( __METHOD__ . ": PHP process owner is not '{$this->fileOwner}'." );
192            return false;
193        }
194
195        $fsDirectory = dirname( $fsPath );
196        $usable = $this->usableDirCache->get( $fsDirectory, MapCacheLRU::TTL_PROC_SHORT );
197        if ( $usable === null ) {
198            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
199            $usable = @is_dir( $fsDirectory ) && @is_writable( $fsDirectory );
200            $this->usableDirCache->set( $fsDirectory, $usable ? 1 : 0 );
201        }
202
203        return $usable;
204    }
205
206    /** @inheritDoc */
207    protected function doCreateInternal( array $params ) {
208        $status = $this->newStatus();
209
210        $fsDstPath = $this->resolveToFSPath( $params['dst'] );
211        if ( $fsDstPath === null ) {
212            $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
213
214            return $status;
215        }
216
217        if ( !empty( $params['async'] ) ) { // deferred
218            $tempFile = $this->newTempFileWithContent( $params );
219            if ( !$tempFile ) {
220                $status->fatal( 'backend-fail-create', $params['dst'] );
221
222                return $status;
223            }
224            $cmd = $this->makeCopyCommand( $tempFile->getPath(), $fsDstPath, false );
225            $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
226                if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
227                    $status->fatal( 'backend-fail-create', $params['dst'] );
228                    trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
229                }
230            };
231            $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
232            $tempFile->bind( $status->value );
233        } else { // immediate write
234            $created = false;
235            // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old
236            // inode are unaffected since it writes to a new inode, and (c) new threads reading
237            // the file will either totally see the old version or totally see the new version
238            $fsStagePath = $this->makeStagingPath( $fsDstPath );
239            $this->trapWarningsIgnoringNotFound();
240            $stageHandle = fopen( $fsStagePath, 'xb' );
241            if ( $stageHandle ) {
242                $bytes = fwrite( $stageHandle, $params['content'] );
243                $created = ( $bytes === strlen( $params['content'] ) );
244                fclose( $stageHandle );
245                $created = $created ? rename( $fsStagePath, $fsDstPath ) : false;
246            }
247            $hadError = $this->untrapWarnings();
248            if ( $hadError || !$created ) {
249                $status->fatal( 'backend-fail-create', $params['dst'] );
250
251                return $status;
252            }
253            $this->chmod( $fsDstPath );
254        }
255
256        return $status;
257    }
258
259    /** @inheritDoc */
260    protected function doStoreInternal( array $params ) {
261        $status = $this->newStatus();
262
263        $fsSrcPath = $params['src']; // file system path
264        $fsDstPath = $this->resolveToFSPath( $params['dst'] );
265        if ( $fsDstPath === null ) {
266            $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
267
268            return $status;
269        }
270
271        if ( $fsSrcPath === $fsDstPath ) {
272            $status->fatal( 'backend-fail-internal', $this->name );
273
274            return $status;
275        }
276
277        if ( !empty( $params['async'] ) ) { // deferred
278            $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath, false );
279            $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
280                if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
281                    $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
282                    trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
283                }
284            };
285            $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
286        } else { // immediate write
287            $stored = false;
288            // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old
289            // inode are unaffected since it writes to a new inode, and (c) new threads reading
290            // the file will either totally see the old version or totally see the new version
291            $fsStagePath = $this->makeStagingPath( $fsDstPath );
292            $this->trapWarningsIgnoringNotFound();
293            $srcHandle = fopen( $fsSrcPath, 'rb' );
294            if ( $srcHandle ) {
295                $stageHandle = fopen( $fsStagePath, 'xb' );
296                if ( $stageHandle ) {
297                    $bytes = stream_copy_to_stream( $srcHandle, $stageHandle );
298                    $stored = ( $bytes !== false && $bytes === fstat( $srcHandle )['size'] );
299                    fclose( $stageHandle );
300                    $stored = $stored ? rename( $fsStagePath, $fsDstPath ) : false;
301                }
302                fclose( $srcHandle );
303            }
304            $hadError = $this->untrapWarnings();
305            if ( $hadError || !$stored ) {
306                $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
307
308                return $status;
309            }
310            $this->chmod( $fsDstPath );
311        }
312
313        return $status;
314    }
315
316    /** @inheritDoc */
317    protected function doCopyInternal( array $params ) {
318        $status = $this->newStatus();
319
320        $fsSrcPath = $this->resolveToFSPath( $params['src'] );
321        if ( $fsSrcPath === null ) {
322            $status->fatal( 'backend-fail-invalidpath', $params['src'] );
323
324            return $status;
325        }
326
327        $fsDstPath = $this->resolveToFSPath( $params['dst'] );
328        if ( $fsDstPath === null ) {
329            $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
330
331            return $status;
332        }
333
334        if ( $fsSrcPath === $fsDstPath ) {
335            return $status; // no-op
336        }
337
338        $ignoreMissing = !empty( $params['ignoreMissingSource'] );
339
340        if ( !empty( $params['async'] ) ) { // deferred
341            $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing );
342            $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
343                if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
344                    $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
345                    trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
346                }
347            };
348            $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
349        } else { // immediate write
350            $copied = false;
351            // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old
352            // inode are unaffected since it writes to a new inode, and (c) new threads reading
353            // the file will either totally see the old version or totally see the new version
354            $fsStagePath = $this->makeStagingPath( $fsDstPath );
355            $this->trapWarningsIgnoringNotFound();
356            $srcHandle = fopen( $fsSrcPath, 'rb' );
357            if ( $srcHandle ) {
358                $stageHandle = fopen( $fsStagePath, 'xb' );
359                if ( $stageHandle ) {
360                    $bytes = stream_copy_to_stream( $srcHandle, $stageHandle );
361                    $copied = ( $bytes !== false && $bytes === fstat( $srcHandle )['size'] );
362                    fclose( $stageHandle );
363                    $copied = $copied ? rename( $fsStagePath, $fsDstPath ) : false;
364                }
365                fclose( $srcHandle );
366            }
367            $hadError = $this->untrapWarnings();
368            if ( $hadError || ( !$copied && !$ignoreMissing ) ) {
369                $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
370
371                return $status;
372            }
373            if ( $copied ) {
374                $this->chmod( $fsDstPath );
375            }
376        }
377
378        return $status;
379    }
380
381    /** @inheritDoc */
382    protected function doMoveInternal( array $params ) {
383        $status = $this->newStatus();
384
385        $fsSrcPath = $this->resolveToFSPath( $params['src'] );
386        if ( $fsSrcPath === null ) {
387            $status->fatal( 'backend-fail-invalidpath', $params['src'] );
388
389            return $status;
390        }
391
392        $fsDstPath = $this->resolveToFSPath( $params['dst'] );
393        if ( $fsDstPath === null ) {
394            $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
395
396            return $status;
397        }
398
399        if ( $fsSrcPath === $fsDstPath ) {
400            return $status; // no-op
401        }
402
403        $ignoreMissing = !empty( $params['ignoreMissingSource'] );
404
405        if ( !empty( $params['async'] ) ) { // deferred
406            $cmd = $this->makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing );
407            $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
408                if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
409                    $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
410                    trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
411                }
412            };
413            $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
414        } else { // immediate write
415            // Use rename() here since (a) this clears xattrs, (b) any threads still reading the
416            // old inode are unaffected since it writes to a new inode, and (c) this is fast and
417            // atomic within a file system volume (as is normally the case)
418            $this->trapWarningsIgnoringNotFound();
419            $moved = rename( $fsSrcPath, $fsDstPath );
420            $hadError = $this->untrapWarnings();
421            if ( $hadError || ( !$moved && !$ignoreMissing ) ) {
422                $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
423
424                return $status;
425            }
426        }
427
428        return $status;
429    }
430
431    /** @inheritDoc */
432    protected function doDeleteInternal( array $params ) {
433        $status = $this->newStatus();
434
435        $fsSrcPath = $this->resolveToFSPath( $params['src'] );
436        if ( $fsSrcPath === null ) {
437            $status->fatal( 'backend-fail-invalidpath', $params['src'] );
438
439            return $status;
440        }
441
442        $ignoreMissing = !empty( $params['ignoreMissingSource'] );
443
444        if ( !empty( $params['async'] ) ) { // deferred
445            $cmd = $this->makeUnlinkCommand( $fsSrcPath, $ignoreMissing );
446            $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
447                if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
448                    $status->fatal( 'backend-fail-delete', $params['src'] );
449                    trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
450                }
451            };
452            $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
453        } else { // immediate write
454            $this->trapWarningsIgnoringNotFound();
455            $deleted = unlink( $fsSrcPath );
456            $hadError = $this->untrapWarnings();
457            if ( $hadError || ( !$deleted && !$ignoreMissing ) ) {
458                $status->fatal( 'backend-fail-delete', $params['src'] );
459
460                return $status;
461            }
462        }
463
464        return $status;
465    }
466
467    /**
468     * @inheritDoc
469     */
470    protected function doPrepareInternal( $fullCont, $dirRel, array $params ) {
471        $status = $this->newStatus();
472        [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
473        $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
474        $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
475        // Create the directory and its parents as needed...
476        $created = false;
477        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
478        $alreadyExisted = @is_dir( $fsDirectory ); // already there?
479        if ( !$alreadyExisted ) {
480            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
481            $created = @mkdir( $fsDirectory, $this->dirMode, true );
482            if ( !$created ) {
483                // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
484                $alreadyExisted = @is_dir( $fsDirectory ); // another thread made it?
485            }
486        }
487        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
488        $isWritable = $created ?: @is_writable( $fsDirectory ); // assume writable if created here
489        if ( !$alreadyExisted && !$created ) {
490            $this->logger->error( __METHOD__ . ": cannot create directory $fsDirectory" );
491            $status->fatal( 'directorycreateerror', $params['dir'] ); // fails on races
492        } elseif ( !$isWritable ) {
493            $this->logger->error( __METHOD__ . ": directory $fsDirectory is read-only" );
494            $status->fatal( 'directoryreadonlyerror', $params['dir'] );
495        }
496        // Respect any 'noAccess' or 'noListing' flags...
497        if ( $created ) {
498            $status->merge( $this->doSecureInternal( $fullCont, $dirRel, $params ) );
499        }
500
501        if ( $status->isGood() ) {
502            $this->usableDirCache->set( $fsDirectory, 1 );
503        }
504
505        return $status;
506    }
507
508    /** @inheritDoc */
509    protected function doSecureInternal( $fullCont, $dirRel, array $params ) {
510        $status = $this->newStatus();
511        [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
512        $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
513        $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
514        // Seed new directories with a blank index.html, to prevent crawling...
515        if ( !empty( $params['noListing'] ) && !is_file( "{$fsDirectory}/index.html" ) ) {
516            $this->trapWarnings();
517            $bytes = file_put_contents( "{$fsDirectory}/index.html", $this->indexHtmlPrivate() );
518            $this->untrapWarnings();
519            if ( $bytes === false ) {
520                $status->fatal( 'backend-fail-create', $params['dir'] . '/index.html' );
521            }
522        }
523        // Add a .htaccess file to the root of the container...
524        if ( !empty( $params['noAccess'] ) && !is_file( "{$contRoot}/.htaccess" ) ) {
525            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
526            $bytes = @file_put_contents( "{$contRoot}/.htaccess", $this->htaccessPrivate() );
527            if ( $bytes === false ) {
528                $storeDir = "mwstore://{$this->name}/{$shortCont}";
529                $status->fatal( 'backend-fail-create', "{$storeDir}/.htaccess" );
530            }
531        }
532
533        return $status;
534    }
535
536    /** @inheritDoc */
537    protected function doPublishInternal( $fullCont, $dirRel, array $params ) {
538        $status = $this->newStatus();
539        [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
540        $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
541        $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
542        // Unseed new directories with a blank index.html, to allow crawling...
543        if ( !empty( $params['listing'] ) && is_file( "{$fsDirectory}/index.html" ) ) {
544            $exists = ( file_get_contents( "{$fsDirectory}/index.html" ) === $this->indexHtmlPrivate() );
545            if ( $exists && !$this->unlink( "{$fsDirectory}/index.html" ) ) { // reverse secure()
546                $status->fatal( 'backend-fail-delete', $params['dir'] . '/index.html' );
547            }
548        }
549        // Remove the .htaccess file from the root of the container...
550        if ( !empty( $params['access'] ) && is_file( "{$contRoot}/.htaccess" ) ) {
551            $exists = ( file_get_contents( "{$contRoot}/.htaccess" ) === $this->htaccessPrivate() );
552            if ( $exists && !$this->unlink( "{$contRoot}/.htaccess" ) ) { // reverse secure()
553                $storeDir = "mwstore://{$this->name}/{$shortCont}";
554                $status->fatal( 'backend-fail-delete', "{$storeDir}/.htaccess" );
555            }
556        }
557
558        return $status;
559    }
560
561    /** @inheritDoc */
562    protected function doCleanInternal( $fullCont, $dirRel, array $params ) {
563        $status = $this->newStatus();
564        [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
565        $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
566        $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
567
568        $this->rmdir( $fsDirectory );
569
570        return $status;
571    }
572
573    /** @inheritDoc */
574    protected function doGetFileStat( array $params ) {
575        $fsSrcPath = $this->resolveToFSPath( $params['src'] );
576        if ( $fsSrcPath === null ) {
577            return self::RES_ERROR; // invalid storage path
578        }
579
580        $this->trapWarnings(); // don't trust 'false' if there were errors
581        $stat = is_file( $fsSrcPath ) ? stat( $fsSrcPath ) : false; // regular files only
582        $hadError = $this->untrapWarnings();
583
584        if ( is_array( $stat ) ) {
585            $ct = new ConvertibleTimestamp( $stat['mtime'] );
586
587            return [
588                'mtime' => $ct->getTimestamp( TS::MW ),
589                'size' => $stat['size']
590            ];
591        }
592
593        return $hadError ? self::RES_ERROR : self::RES_ABSENT;
594    }
595
596    /** @inheritDoc */
597    protected function doClearCache( ?array $paths = null ) {
598        if ( is_array( $paths ) ) {
599            foreach ( $paths as $path ) {
600                $fsPath = $this->resolveToFSPath( $path );
601                if ( $fsPath !== null ) {
602                    clearstatcache( true, $fsPath );
603                    $this->usableDirCache->clear( $fsPath );
604                }
605            }
606        } else {
607            clearstatcache( true ); // clear the PHP file stat cache
608            $this->usableDirCache->clear();
609        }
610    }
611
612    /** @inheritDoc */
613    protected function doDirectoryExists( $fullCont, $dirRel, array $params ) {
614        [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
615        $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
616        $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
617
618        $this->trapWarnings(); // don't trust 'false' if there were errors
619        $exists = is_dir( $fsDirectory );
620        $hadError = $this->untrapWarnings();
621
622        return $hadError ? self::RES_ERROR : $exists;
623    }
624
625    /**
626     * @see FileBackendStore::getDirectoryListInternal()
627     * @param string $fullCont
628     * @param string $dirRel
629     * @param array $params
630     * @return array|FSFileBackendDirList|null
631     */
632    public function getDirectoryListInternal( $fullCont, $dirRel, array $params ) {
633        [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
634        $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
635        $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
636
637        $list = new FSFileBackendDirList( $fsDirectory, $params );
638        $error = $list->getLastError();
639        if ( $error !== null ) {
640            if ( $this->isFileNotFoundError( $error ) ) {
641                $this->logger->info( __METHOD__ . ": non-existant directory: '$fsDirectory'" );
642
643                return []; // nothing under this dir
644            } elseif ( is_dir( $fsDirectory ) ) {
645                $this->logger->warning( __METHOD__ . ": unreadable directory: '$fsDirectory'" );
646
647                return self::RES_ERROR; // bad permissions?
648            } else {
649                $this->logger->warning( __METHOD__ . ": unreachable directory: '$fsDirectory'" );
650
651                return self::RES_ERROR;
652            }
653        }
654
655        return $list;
656    }
657
658    /**
659     * @see FileBackendStore::getFileListInternal()
660     * @param string $fullCont
661     * @param string $dirRel
662     * @param array $params
663     * @return array|FSFileBackendFileList|null
664     */
665    public function getFileListInternal( $fullCont, $dirRel, array $params ) {
666        [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
667        $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
668        $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
669
670        $list = new FSFileBackendFileList( $fsDirectory, $params );
671        $error = $list->getLastError();
672        if ( $error !== null ) {
673            if ( $this->isFileNotFoundError( $error ) ) {
674                $this->logger->info( __METHOD__ . ": non-existent directory: '$fsDirectory'" );
675
676                return []; // nothing under this dir
677            } elseif ( is_dir( $fsDirectory ) ) {
678                $this->logger->warning( __METHOD__ .
679                    ": unreadable directory: '$fsDirectory': $error" );
680
681                return self::RES_ERROR; // bad permissions?
682            } else {
683                $this->logger->warning( __METHOD__ .
684                    ": unreachable directory: '$fsDirectory': $error" );
685
686                return self::RES_ERROR;
687            }
688        }
689
690        return $list;
691    }
692
693    /** @inheritDoc */
694    protected function doGetLocalReferenceMulti( array $params ) {
695        $fsFiles = []; // (path => FSFile)
696
697        foreach ( $params['srcs'] as $src ) {
698            $source = $this->resolveToFSPath( $src );
699            if ( $source === null ) {
700                $fsFiles[$src] = self::RES_ERROR; // invalid path
701                continue;
702            }
703
704            $this->trapWarnings(); // don't trust 'false' if there were errors
705            $isFile = is_file( $source ); // regular files only
706            $hadError = $this->untrapWarnings();
707
708            if ( $isFile ) {
709                $fsFiles[$src] = new FSFile( $source );
710            } elseif ( $hadError ) {
711                $fsFiles[$src] = self::RES_ERROR;
712            } else {
713                $fsFiles[$src] = self::RES_ABSENT;
714            }
715        }
716
717        return $fsFiles;
718    }
719
720    /** @inheritDoc */
721    protected function doGetLocalCopyMulti( array $params ) {
722        $tmpFiles = []; // (path => TempFSFile)
723
724        foreach ( $params['srcs'] as $src ) {
725            $source = $this->resolveToFSPath( $src );
726            if ( $source === null ) {
727                $tmpFiles[$src] = self::RES_ERROR; // invalid path
728                continue;
729            }
730            // Create a new temporary file with the same extension...
731            $ext = FileBackend::extensionFromPath( $src );
732            $tmpFile = $this->tmpFileFactory->newTempFSFile( 'localcopy_', $ext );
733            if ( !$tmpFile ) {
734                $tmpFiles[$src] = self::RES_ERROR;
735                continue;
736            }
737
738            $tmpPath = $tmpFile->getPath();
739            // Copy the source file over the temp file
740            $this->trapWarnings(); // don't trust 'false' if there were errors
741            $isFile = is_file( $source ); // regular files only
742            $copySuccess = $isFile ? copy( $source, $tmpPath ) : false;
743            $hadError = $this->untrapWarnings();
744
745            if ( $copySuccess ) {
746                $this->chmod( $tmpPath );
747                $tmpFiles[$src] = $tmpFile;
748            } elseif ( $hadError ) {
749                $tmpFiles[$src] = self::RES_ERROR; // copy failed
750            } else {
751                $tmpFiles[$src] = self::RES_ABSENT;
752            }
753        }
754
755        return $tmpFiles;
756    }
757
758    /** @inheritDoc */
759    public function addShellboxInputFile( BoxedCommand $command, string $boxedName,
760        array $params
761    ) {
762        $path = $this->resolveToFSPath( $params['src'] );
763        if ( $path === null ) {
764            return $this->newStatus( 'backend-fail-invalidpath', $params['src'] );
765        }
766        $command->inputFileFromFile( $boxedName, $path );
767        return $this->newStatus();
768    }
769
770    /** @inheritDoc */
771    protected function directoriesAreVirtual() {
772        return false;
773    }
774
775    /**
776     * @param FSFileOpHandle[] $fileOpHandles
777     *
778     * @return StatusValue[]
779     */
780    protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
781        $statuses = [];
782
783        $pipes = [];
784        foreach ( $fileOpHandles as $index => $fileOpHandle ) {
785            $pipes[$index] = popen( $fileOpHandle->cmd, 'r' );
786        }
787
788        $errs = [];
789        foreach ( $pipes as $index => $pipe ) {
790            // Result will be empty on success in *NIX. On Windows,
791            // it may be something like "        1 file(s) [copied|moved].".
792            $errs[$index] = stream_get_contents( $pipe );
793            fclose( $pipe );
794        }
795
796        foreach ( $fileOpHandles as $index => $fileOpHandle ) {
797            $status = $this->newStatus();
798            $function = $fileOpHandle->callback;
799            $function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd );
800            $statuses[$index] = $status;
801        }
802
803        return $statuses;
804    }
805
806    /**
807     * @param string $fsPath Absolute file system path
808     * @return string Absolute file system path on the same device
809     */
810    private function makeStagingPath( $fsPath ) {
811        $time = dechex( time() ); // make it easy to find old orphans
812        $hash = \Wikimedia\base_convert( md5( basename( $fsPath ) ), 16, 36, 25 );
813        $unique = \Wikimedia\base_convert( bin2hex( random_bytes( 16 ) ), 16, 36, 25 );
814
815        return dirname( $fsPath ) . "/.{$time}_{$hash}_{$unique}.tmpfsfile";
816    }
817
818    /**
819     * @param string $fsSrcPath Absolute file system path
820     * @param string $fsDstPath Absolute file system path
821     * @param bool $ignoreMissing Whether to no-op if the source file is non-existent
822     * @return string Command
823     */
824    private function makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing ) {
825        // Use copy+rename since (a) this clears xattrs, (b) threads still reading the old
826        // inode are unaffected since it writes to a new inode, and (c) new threads reading
827        // the file will either totally see the old version or totally see the new version
828        $fsStagePath = $this->makeStagingPath( $fsDstPath );
829        $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsSrcPath ) );
830        $encStage = Shellbox::escape( $this->cleanPathSlashes( $fsStagePath ) );
831        $encDst = Shellbox::escape( $this->cleanPathSlashes( $fsDstPath ) );
832        if ( $this->isWindows ) {
833            // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/copy
834            // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/move
835            $cmdWrite = "COPY /B /Y $encSrc $encStage 2>&1 && MOVE /Y $encStage $encDst 2>&1";
836            $cmd = $ignoreMissing ? "IF EXIST $encSrc $cmdWrite" : $cmdWrite;
837        } else {
838            // https://manpages.debian.org/buster/coreutils/cp.1.en.html
839            // https://manpages.debian.org/buster/coreutils/mv.1.en.html
840            $cmdWrite = "cp $encSrc $encStage 2>&1 && mv $encStage $encDst 2>&1";
841            $cmd = $ignoreMissing ? "test -f $encSrc && $cmdWrite" : $cmdWrite;
842            // Clean up permissions on any newly created destination file
843            $octalPermissions = '0' . decoct( $this->fileMode );
844            if ( strlen( $octalPermissions ) == 4 ) {
845                $cmd .= " && chmod $octalPermissions $encDst 2>/dev/null";
846            }
847        }
848
849        return $cmd;
850    }
851
852    /**
853     * @param string $fsSrcPath Absolute file system path
854     * @param string $fsDstPath Absolute file system path
855     * @param bool $ignoreMissing Whether to no-op if the source file is non-existent
856     * @return string Command
857     */
858    private function makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing = false ) {
859        // https://manpages.debian.org/buster/coreutils/mv.1.en.html
860        // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/move
861        $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsSrcPath ) );
862        $encDst = Shellbox::escape( $this->cleanPathSlashes( $fsDstPath ) );
863        if ( $this->isWindows ) {
864            $writeCmd = "MOVE /Y $encSrc $encDst 2>&1";
865            $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd;
866        } else {
867            $writeCmd = "mv -f $encSrc $encDst 2>&1";
868            $cmd = $ignoreMissing ? "test -f $encSrc && $writeCmd" : $writeCmd;
869        }
870
871        return $cmd;
872    }
873
874    /**
875     * @param string $fsPath Absolute file system path
876     * @param bool $ignoreMissing Whether to no-op if the file is non-existent
877     * @return string Command
878     */
879    private function makeUnlinkCommand( $fsPath, $ignoreMissing = false ) {
880        // https://manpages.debian.org/buster/coreutils/rm.1.en.html
881        // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/del
882        $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsPath ) );
883        if ( $this->isWindows ) {
884            $writeCmd = "DEL /Q $encSrc 2>&1";
885            $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd;
886        } else {
887            $cmd = $ignoreMissing ? "rm -f $encSrc 2>&1" : "rm $encSrc 2>&1";
888        }
889
890        return $cmd;
891    }
892
893    /**
894     * Chmod a file, suppressing the warnings
895     *
896     * @param string $fsPath Absolute file system path
897     * @return bool Success
898     */
899    protected function chmod( $fsPath ) {
900        if ( $this->isWindows ) {
901            return true;
902        }
903
904        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
905        $ok = @chmod( $fsPath, $this->fileMode );
906
907        return $ok;
908    }
909
910    /**
911     * Unlink a file, suppressing the warnings
912     *
913     * @param string $fsPath Absolute file system path
914     * @return bool Success
915     */
916    protected function unlink( $fsPath ) {
917        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
918        $ok = @unlink( $fsPath );
919        clearstatcache( true, $fsPath );
920
921        return $ok;
922    }
923
924    /**
925     * Remove an empty directory, suppressing the warnings
926     *
927     * @param string $fsDirectory Absolute file system path
928     * @return bool Success
929     */
930    protected function rmdir( $fsDirectory ) {
931        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
932        $ok = @rmdir( $fsDirectory ); // remove directory if empty
933        clearstatcache( true, $fsDirectory );
934
935        return $ok;
936    }
937
938    /**
939     * @param array $params Parameters for FileBackend 'create' operation
940     * @return TempFSFile|null
941     */
942    protected function newTempFileWithContent( array $params ) {
943        $tempFile = $this->tmpFileFactory->newTempFSFile( 'create_', 'tmp' );
944        if ( !$tempFile ) {
945            return null;
946        }
947
948        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
949        if ( @file_put_contents( $tempFile->getPath(), $params['content'] ) === false ) {
950            $tempFile = null;
951        }
952
953        return $tempFile;
954    }
955
956    /**
957     * Return the text of an index.html file to hide directory listings
958     *
959     * @return string
960     */
961    protected function indexHtmlPrivate() {
962        return '';
963    }
964
965    /**
966     * Return the text of a .htaccess file to make a directory private
967     *
968     * @return string
969     */
970    protected function htaccessPrivate() {
971        return "Require all denied\n" .
972            "Satisfy All\n";
973    }
974
975    /**
976     * Clean up directory separators for the given OS
977     *
978     * @param string $fsPath
979     * @return string
980     */
981    protected function cleanPathSlashes( $fsPath ) {
982        return ( $this->isWindows ) ? strtr( $fsPath, '/', '\\' ) : $fsPath;
983    }
984
985    /**
986     * Listen for E_WARNING errors and track whether any that happen
987     *
988     * @param string|null $regexIgnore Optional regex of errors to ignore
989     */
990    protected function trapWarnings( $regexIgnore = null ) {
991        $this->warningTrapStack[] = false;
992        set_error_handler( function ( $errno, $errstr ) use ( $regexIgnore ) {
993            if ( $regexIgnore === null || !preg_match( $regexIgnore, $errstr ) ) {
994                $this->logger->error( $errstr );
995                $this->warningTrapStack[count( $this->warningTrapStack ) - 1] = true;
996            }
997            return true; // suppress from PHP handler
998        }, E_WARNING );
999    }
1000
1001    /**
1002     * Track E_WARNING errors but ignore any that correspond to ENOENT "No such file or directory"
1003     */
1004    protected function trapWarningsIgnoringNotFound() {
1005        $this->trapWarnings( $this->getFileNotFoundRegex() );
1006    }
1007
1008    /**
1009     * Stop listening for E_WARNING errors and get whether any happened
1010     *
1011     * @return bool Whether any warnings happened
1012     */
1013    protected function untrapWarnings() {
1014        restore_error_handler();
1015
1016        return array_pop( $this->warningTrapStack );
1017    }
1018
1019    /**
1020     * Get a regex matching file not found errors
1021     *
1022     * @return string
1023     */
1024    protected function getFileNotFoundRegex() {
1025        static $regex;
1026        if ( $regex === null ) {
1027            // "No such file or directory": string literal in spl_directory.c etc.
1028            $alternatives = [ ': No such file or directory' ];
1029            if ( $this->isWindows ) {
1030                // 2 = The system cannot find the file specified.
1031                // 3 = The system cannot find the path specified.
1032                $alternatives[] = ' \(code: [23]\)';
1033            }
1034            if ( function_exists( 'pcntl_strerror' ) ) {
1035                $alternatives[] = preg_quote( ': ' . pcntl_strerror( 2 ), '/' );
1036            } elseif ( function_exists( 'socket_strerror' ) && defined( 'SOCKET_ENOENT' ) ) {
1037                // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal False positive on Windows only
1038                $alternatives[] = preg_quote( ': ' . socket_strerror( SOCKET_ENOENT ), '/' );
1039            }
1040            $regex = '/(' . implode( '|', $alternatives ) . ')$/';
1041        }
1042        return $regex;
1043    }
1044
1045    /**
1046     * Determine whether a given error message is a file not found error.
1047     *
1048     * @param string $error
1049     * @return bool
1050     */
1051    protected function isFileNotFoundError( $error ) {
1052        return (bool)preg_match( $this->getFileNotFoundRegex(), $error );
1053    }
1054}
1055
1056/** @deprecated class alias since 1.43 */
1057class_alias( FSFileBackend::class, 'FSFileBackend' );