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