Merge "SpecialBlock: Scroll to the error's fieldset instead of field"
[mediawiki.git] / includes / libs / filebackend / FSFileBackend.php
blob1d76f2bf9f5ef2bf25fe920d84d85409ce6a13a4
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.
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.
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
18 * @file
19 * @ingroup FileBackend
22 /**
23 * File system based backend.
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.
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.
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
40 * @file
41 * @ingroup FileBackend
44 namespace Wikimedia\FileBackend;
46 use MapCacheLRU;
47 use Shellbox\Command\BoxedCommand;
48 use Shellbox\Shellbox;
49 use StatusValue;
50 use Wikimedia\AtEase\AtEase;
51 use Wikimedia\FileBackend\FileIteration\FSFileBackendDirList;
52 use Wikimedia\FileBackend\FileIteration\FSFileBackendFileList;
53 use Wikimedia\FileBackend\FileOpHandle\FSFileOpHandle;
54 use Wikimedia\FileBackend\FSFile\FSFile;
55 use Wikimedia\FileBackend\FSFile\TempFSFile;
56 use Wikimedia\Timestamp\ConvertibleTimestamp;
58 /**
59 * @brief Class for a file system (FS) based file backend.
61 * All "containers" each map to a directory under the backend's base directory.
62 * For backwards-compatibility, some container paths can be set to custom paths.
63 * The domain ID will not be used in any custom paths, so this should be avoided.
65 * Having directories with thousands of files will diminish performance.
66 * Sharding can be accomplished by using FileRepo-style hash paths.
68 * StatusValue messages should avoid mentioning the internal FS paths.
69 * PHP warnings are assumed to be logged rather than output.
71 * @ingroup FileBackend
72 * @since 1.19
74 class FSFileBackend extends FileBackendStore {
75 /** @var MapCacheLRU Cache for known prepared/usable directories */
76 protected $usableDirCache;
78 /** @var string|null Directory holding the container directories */
79 protected $basePath;
81 /** @var array<string,string> Map of container names to root paths for custom container paths */
82 protected $containerPaths;
84 /** @var int Directory permission mode */
85 protected $dirMode;
86 /** @var int File permission mode */
87 protected $fileMode;
88 /** @var string Required OS username to own files */
89 protected $fileOwner;
91 /** @var string Simpler version of PHP_OS_FAMILY */
92 protected $os;
93 /** @var string OS username running this script */
94 protected $currentUser;
96 /** @var bool[] Map of (stack index => whether a warning happened) */
97 private $warningTrapStack = [];
99 /**
100 * @see FileBackendStore::__construct()
101 * Additional $config params include:
102 * - basePath : File system directory that holds containers.
103 * - containerPaths : Map of container names to custom file system directories.
104 * This should only be used for backwards-compatibility.
105 * - fileMode : Octal UNIX file permissions to use on files stored.
106 * - directoryMode : Octal UNIX file permissions to use on directories created.
107 * @param array $config
109 public function __construct( array $config ) {
110 parent::__construct( $config );
112 if ( PHP_OS_FAMILY === 'Windows' ) {
113 $this->os = 'Windows';
114 } elseif ( PHP_OS_FAMILY === 'BSD' || PHP_OS_FAMILY === 'Darwin' ) {
115 $this->os = 'BSD';
116 } else {
117 $this->os = 'Linux';
119 // Remove any possible trailing slash from directories
120 if ( isset( $config['basePath'] ) ) {
121 $this->basePath = rtrim( $config['basePath'], '/' ); // remove trailing slash
122 } else {
123 $this->basePath = null; // none; containers must have explicit paths
126 $this->containerPaths = [];
127 foreach ( ( $config['containerPaths'] ?? [] ) as $container => $fsPath ) {
128 $this->containerPaths[$container] = rtrim( $fsPath, '/' ); // remove trailing slash
131 $this->fileMode = $config['fileMode'] ?? 0644;
132 $this->dirMode = $config['directoryMode'] ?? 0777;
133 if ( isset( $config['fileOwner'] ) && function_exists( 'posix_getuid' ) ) {
134 $this->fileOwner = $config['fileOwner'];
135 // Cache this, assuming it doesn't change
136 $this->currentUser = posix_getpwuid( posix_getuid() )['name'];
139 $this->usableDirCache = new MapCacheLRU( self::CACHE_CHEAP_SIZE );
142 public function getFeatures() {
143 return self::ATTR_UNICODE_PATHS;
146 protected function resolveContainerPath( $container, $relStoragePath ) {
147 // Check that container has a root directory
148 if ( isset( $this->containerPaths[$container] ) || $this->basePath !== null ) {
149 // Check for sensible relative paths (assume the base paths are OK)
150 if ( $this->isLegalRelPath( $relStoragePath ) ) {
151 return $relStoragePath;
155 return null; // invalid
159 * Check a relative file system path for validity
161 * @param string $fsPath Normalized relative path
162 * @return bool
164 protected function isLegalRelPath( $fsPath ) {
165 // Check for file names longer than 255 chars
166 if ( preg_match( '![^/]{256}!', $fsPath ) ) { // ext3/NTFS
167 return false;
169 if ( $this->os === 'Windows' ) { // NTFS
170 return !preg_match( '![:*?"<>|]!', $fsPath );
171 } else {
172 return true;
177 * Given the short (unresolved) and full (resolved) name of
178 * a container, return the file system path of the container.
180 * @param string $shortCont
181 * @param string $fullCont
182 * @return string|null
184 protected function containerFSRoot( $shortCont, $fullCont ) {
185 if ( isset( $this->containerPaths[$shortCont] ) ) {
186 return $this->containerPaths[$shortCont];
187 } elseif ( $this->basePath !== null ) {
188 return "{$this->basePath}/{$fullCont}";
191 return null; // no container base path defined
195 * Get the absolute file system path for a storage path
197 * @param string $storagePath
198 * @return string|null
200 protected function resolveToFSPath( $storagePath ) {
201 [ $fullCont, $relPath ] = $this->resolveStoragePathReal( $storagePath );
202 if ( $relPath === null ) {
203 return null; // invalid
205 [ , $shortCont, ] = FileBackend::splitStoragePath( $storagePath );
206 $fsPath = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
207 if ( $relPath != '' ) {
208 $fsPath .= "/{$relPath}";
211 return $fsPath;
214 public function isPathUsableInternal( $storagePath ) {
215 $fsPath = $this->resolveToFSPath( $storagePath );
216 if ( $fsPath === null ) {
217 return false; // invalid
220 if ( $this->fileOwner !== null && $this->currentUser !== $this->fileOwner ) {
221 trigger_error( __METHOD__ . ": PHP process owner is not '{$this->fileOwner}'." );
222 return false;
225 $fsDirectory = dirname( $fsPath );
226 $usable = $this->usableDirCache->get( $fsDirectory, MapCacheLRU::TTL_PROC_SHORT );
227 if ( $usable === null ) {
228 AtEase::suppressWarnings();
229 $usable = is_dir( $fsDirectory ) && is_writable( $fsDirectory );
230 AtEase::restoreWarnings();
231 $this->usableDirCache->set( $fsDirectory, $usable ? 1 : 0 );
234 return $usable;
237 protected function doCreateInternal( array $params ) {
238 $status = $this->newStatus();
240 $fsDstPath = $this->resolveToFSPath( $params['dst'] );
241 if ( $fsDstPath === null ) {
242 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
244 return $status;
247 if ( !empty( $params['async'] ) ) { // deferred
248 $tempFile = $this->newTempFileWithContent( $params );
249 if ( !$tempFile ) {
250 $status->fatal( 'backend-fail-create', $params['dst'] );
252 return $status;
254 $cmd = $this->makeCopyCommand( $tempFile->getPath(), $fsDstPath, false );
255 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
256 if ( $errors !== '' && !( $this->os === 'Windows' && $errors[0] === " " ) ) {
257 $status->fatal( 'backend-fail-create', $params['dst'] );
258 trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
261 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
262 $tempFile->bind( $status->value );
263 } else { // immediate write
264 $created = false;
265 // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old
266 // inode are unaffected since it writes to a new inode, and (c) new threads reading
267 // the file will either totally see the old version or totally see the new version
268 $fsStagePath = $this->makeStagingPath( $fsDstPath );
269 $this->trapWarningsIgnoringNotFound();
270 $stageHandle = fopen( $fsStagePath, 'xb' );
271 if ( $stageHandle ) {
272 $bytes = fwrite( $stageHandle, $params['content'] );
273 $created = ( $bytes === strlen( $params['content'] ) );
274 fclose( $stageHandle );
275 $created = $created ? rename( $fsStagePath, $fsDstPath ) : false;
277 $hadError = $this->untrapWarnings();
278 if ( $hadError || !$created ) {
279 $status->fatal( 'backend-fail-create', $params['dst'] );
281 return $status;
283 $this->chmod( $fsDstPath );
286 return $status;
289 protected function doStoreInternal( array $params ) {
290 $status = $this->newStatus();
292 $fsSrcPath = $params['src']; // file system path
293 $fsDstPath = $this->resolveToFSPath( $params['dst'] );
294 if ( $fsDstPath === null ) {
295 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
297 return $status;
300 if ( $fsSrcPath === $fsDstPath ) {
301 $status->fatal( 'backend-fail-internal', $this->name );
303 return $status;
306 if ( !empty( $params['async'] ) ) { // deferred
307 $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath, false );
308 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
309 if ( $errors !== '' && !( $this->os === 'Windows' && $errors[0] === " " ) ) {
310 $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
311 trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
314 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
315 } else { // immediate write
316 $stored = false;
317 // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old
318 // inode are unaffected since it writes to a new inode, and (c) new threads reading
319 // the file will either totally see the old version or totally see the new version
320 $fsStagePath = $this->makeStagingPath( $fsDstPath );
321 $this->trapWarningsIgnoringNotFound();
322 $srcHandle = fopen( $fsSrcPath, 'rb' );
323 if ( $srcHandle ) {
324 $stageHandle = fopen( $fsStagePath, 'xb' );
325 if ( $stageHandle ) {
326 $bytes = stream_copy_to_stream( $srcHandle, $stageHandle );
327 $stored = ( $bytes !== false && $bytes === fstat( $srcHandle )['size'] );
328 fclose( $stageHandle );
329 $stored = $stored ? rename( $fsStagePath, $fsDstPath ) : false;
331 fclose( $srcHandle );
333 $hadError = $this->untrapWarnings();
334 if ( $hadError || !$stored ) {
335 $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
337 return $status;
339 $this->chmod( $fsDstPath );
342 return $status;
345 protected function doCopyInternal( array $params ) {
346 $status = $this->newStatus();
348 $fsSrcPath = $this->resolveToFSPath( $params['src'] );
349 if ( $fsSrcPath === null ) {
350 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
352 return $status;
355 $fsDstPath = $this->resolveToFSPath( $params['dst'] );
356 if ( $fsDstPath === null ) {
357 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
359 return $status;
362 if ( $fsSrcPath === $fsDstPath ) {
363 return $status; // no-op
366 $ignoreMissing = !empty( $params['ignoreMissingSource'] );
368 if ( !empty( $params['async'] ) ) { // deferred
369 $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing );
370 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
371 if ( $errors !== '' && !( $this->os === 'Windows' && $errors[0] === " " ) ) {
372 $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
373 trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
376 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
377 } else { // immediate write
378 $copied = false;
379 // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old
380 // inode are unaffected since it writes to a new inode, and (c) new threads reading
381 // the file will either totally see the old version or totally see the new version
382 $fsStagePath = $this->makeStagingPath( $fsDstPath );
383 $this->trapWarningsIgnoringNotFound();
384 $srcHandle = fopen( $fsSrcPath, 'rb' );
385 if ( $srcHandle ) {
386 $stageHandle = fopen( $fsStagePath, 'xb' );
387 if ( $stageHandle ) {
388 $bytes = stream_copy_to_stream( $srcHandle, $stageHandle );
389 $copied = ( $bytes !== false && $bytes === fstat( $srcHandle )['size'] );
390 fclose( $stageHandle );
391 $copied = $copied ? rename( $fsStagePath, $fsDstPath ) : false;
393 fclose( $srcHandle );
395 $hadError = $this->untrapWarnings();
396 if ( $hadError || ( !$copied && !$ignoreMissing ) ) {
397 $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
399 return $status;
401 if ( $copied ) {
402 $this->chmod( $fsDstPath );
406 return $status;
409 protected function doMoveInternal( array $params ) {
410 $status = $this->newStatus();
412 $fsSrcPath = $this->resolveToFSPath( $params['src'] );
413 if ( $fsSrcPath === null ) {
414 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
416 return $status;
419 $fsDstPath = $this->resolveToFSPath( $params['dst'] );
420 if ( $fsDstPath === null ) {
421 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
423 return $status;
426 if ( $fsSrcPath === $fsDstPath ) {
427 return $status; // no-op
430 $ignoreMissing = !empty( $params['ignoreMissingSource'] );
432 if ( !empty( $params['async'] ) ) { // deferred
433 $cmd = $this->makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing );
434 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
435 if ( $errors !== '' && !( $this->os === 'Windows' && $errors[0] === " " ) ) {
436 $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
437 trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
440 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
441 } else { // immediate write
442 // Use rename() here since (a) this clears xattrs, (b) any threads still reading the
443 // old inode are unaffected since it writes to a new inode, and (c) this is fast and
444 // atomic within a file system volume (as is normally the case)
445 $this->trapWarningsIgnoringNotFound();
446 $moved = rename( $fsSrcPath, $fsDstPath );
447 $hadError = $this->untrapWarnings();
448 if ( $hadError || ( !$moved && !$ignoreMissing ) ) {
449 $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
451 return $status;
455 return $status;
458 protected function doDeleteInternal( array $params ) {
459 $status = $this->newStatus();
461 $fsSrcPath = $this->resolveToFSPath( $params['src'] );
462 if ( $fsSrcPath === null ) {
463 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
465 return $status;
468 $ignoreMissing = !empty( $params['ignoreMissingSource'] );
470 if ( !empty( $params['async'] ) ) { // deferred
471 $cmd = $this->makeUnlinkCommand( $fsSrcPath, $ignoreMissing );
472 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
473 if ( $errors !== '' && !( $this->os === 'Windows' && $errors[0] === " " ) ) {
474 $status->fatal( 'backend-fail-delete', $params['src'] );
475 trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
478 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
479 } else { // immediate write
480 $this->trapWarningsIgnoringNotFound();
481 $deleted = unlink( $fsSrcPath );
482 $hadError = $this->untrapWarnings();
483 if ( $hadError || ( !$deleted && !$ignoreMissing ) ) {
484 $status->fatal( 'backend-fail-delete', $params['src'] );
486 return $status;
490 return $status;
494 * @inheritDoc
496 protected function doPrepareInternal( $fullCont, $dirRel, array $params ) {
497 $status = $this->newStatus();
498 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
499 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
500 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
501 // Create the directory and its parents as needed...
502 $created = false;
503 AtEase::suppressWarnings();
504 $alreadyExisted = is_dir( $fsDirectory ); // already there?
505 if ( !$alreadyExisted ) {
506 $created = mkdir( $fsDirectory, $this->dirMode, true );
507 if ( !$created ) {
508 $alreadyExisted = is_dir( $fsDirectory ); // another thread made it?
511 $isWritable = $created ?: is_writable( $fsDirectory ); // assume writable if created here
512 AtEase::restoreWarnings();
513 if ( !$alreadyExisted && !$created ) {
514 $this->logger->error( __METHOD__ . ": cannot create directory $fsDirectory" );
515 $status->fatal( 'directorycreateerror', $params['dir'] ); // fails on races
516 } elseif ( !$isWritable ) {
517 $this->logger->error( __METHOD__ . ": directory $fsDirectory is read-only" );
518 $status->fatal( 'directoryreadonlyerror', $params['dir'] );
520 // Respect any 'noAccess' or 'noListing' flags...
521 if ( $created ) {
522 $status->merge( $this->doSecureInternal( $fullCont, $dirRel, $params ) );
525 if ( $status->isGood() ) {
526 $this->usableDirCache->set( $fsDirectory, 1 );
529 return $status;
532 protected function doSecureInternal( $fullCont, $dirRel, array $params ) {
533 $status = $this->newStatus();
534 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
535 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
536 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
537 // Seed new directories with a blank index.html, to prevent crawling...
538 if ( !empty( $params['noListing'] ) && !is_file( "{$fsDirectory}/index.html" ) ) {
539 $this->trapWarnings();
540 $bytes = file_put_contents( "{$fsDirectory}/index.html", $this->indexHtmlPrivate() );
541 $this->untrapWarnings();
542 if ( $bytes === false ) {
543 $status->fatal( 'backend-fail-create', $params['dir'] . '/index.html' );
546 // Add a .htaccess file to the root of the container...
547 if ( !empty( $params['noAccess'] ) && !is_file( "{$contRoot}/.htaccess" ) ) {
548 AtEase::suppressWarnings();
549 $bytes = file_put_contents( "{$contRoot}/.htaccess", $this->htaccessPrivate() );
550 AtEase::restoreWarnings();
551 if ( $bytes === false ) {
552 $storeDir = "mwstore://{$this->name}/{$shortCont}";
553 $status->fatal( 'backend-fail-create', "{$storeDir}/.htaccess" );
557 return $status;
560 protected function doPublishInternal( $fullCont, $dirRel, array $params ) {
561 $status = $this->newStatus();
562 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
563 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
564 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
565 // Unseed new directories with a blank index.html, to allow crawling...
566 if ( !empty( $params['listing'] ) && is_file( "{$fsDirectory}/index.html" ) ) {
567 $exists = ( file_get_contents( "{$fsDirectory}/index.html" ) === $this->indexHtmlPrivate() );
568 if ( $exists && !$this->unlink( "{$fsDirectory}/index.html" ) ) { // reverse secure()
569 $status->fatal( 'backend-fail-delete', $params['dir'] . '/index.html' );
572 // Remove the .htaccess file from the root of the container...
573 if ( !empty( $params['access'] ) && is_file( "{$contRoot}/.htaccess" ) ) {
574 $exists = ( file_get_contents( "{$contRoot}/.htaccess" ) === $this->htaccessPrivate() );
575 if ( $exists && !$this->unlink( "{$contRoot}/.htaccess" ) ) { // reverse secure()
576 $storeDir = "mwstore://{$this->name}/{$shortCont}";
577 $status->fatal( 'backend-fail-delete', "{$storeDir}/.htaccess" );
581 return $status;
584 protected function doCleanInternal( $fullCont, $dirRel, array $params ) {
585 $status = $this->newStatus();
586 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
587 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
588 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
590 $this->rmdir( $fsDirectory );
592 return $status;
595 protected function doGetFileStat( array $params ) {
596 $fsSrcPath = $this->resolveToFSPath( $params['src'] );
597 if ( $fsSrcPath === null ) {
598 return self::RES_ERROR; // invalid storage path
601 $this->trapWarnings(); // don't trust 'false' if there were errors
602 $stat = is_file( $fsSrcPath ) ? stat( $fsSrcPath ) : false; // regular files only
603 $hadError = $this->untrapWarnings();
605 if ( is_array( $stat ) ) {
606 $ct = new ConvertibleTimestamp( $stat['mtime'] );
608 return [
609 'mtime' => $ct->getTimestamp( TS_MW ),
610 'size' => $stat['size']
614 return $hadError ? self::RES_ERROR : self::RES_ABSENT;
617 protected function doClearCache( ?array $paths = null ) {
618 if ( is_array( $paths ) ) {
619 foreach ( $paths as $path ) {
620 $fsPath = $this->resolveToFSPath( $path );
621 if ( $fsPath !== null ) {
622 clearstatcache( true, $fsPath );
623 $this->usableDirCache->clear( $fsPath );
626 } else {
627 clearstatcache( true ); // clear the PHP file stat cache
628 $this->usableDirCache->clear();
632 protected function doDirectoryExists( $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;
637 $this->trapWarnings(); // don't trust 'false' if there were errors
638 $exists = is_dir( $fsDirectory );
639 $hadError = $this->untrapWarnings();
641 return $hadError ? self::RES_ERROR : $exists;
645 * @see FileBackendStore::getDirectoryListInternal()
646 * @param string $fullCont
647 * @param string $dirRel
648 * @param array $params
649 * @return array|FSFileBackendDirList|null
651 public function getDirectoryListInternal( $fullCont, $dirRel, array $params ) {
652 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
653 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
654 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
656 $list = new FSFileBackendDirList( $fsDirectory, $params );
657 $error = $list->getLastError();
658 if ( $error !== null ) {
659 if ( $this->isFileNotFoundError( $error ) ) {
660 $this->logger->info( __METHOD__ . ": non-existant directory: '$fsDirectory'" );
662 return []; // nothing under this dir
663 } elseif ( is_dir( $fsDirectory ) ) {
664 $this->logger->warning( __METHOD__ . ": unreadable directory: '$fsDirectory'" );
666 return self::RES_ERROR; // bad permissions?
667 } else {
668 $this->logger->warning( __METHOD__ . ": unreachable directory: '$fsDirectory'" );
670 return self::RES_ERROR;
674 return $list;
678 * @see FileBackendStore::getFileListInternal()
679 * @param string $fullCont
680 * @param string $dirRel
681 * @param array $params
682 * @return array|FSFileBackendFileList|null
684 public function getFileListInternal( $fullCont, $dirRel, array $params ) {
685 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
686 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
687 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
689 $list = new FSFileBackendFileList( $fsDirectory, $params );
690 $error = $list->getLastError();
691 if ( $error !== null ) {
692 if ( $this->isFileNotFoundError( $error ) ) {
693 $this->logger->info( __METHOD__ . ": non-existent directory: '$fsDirectory'" );
695 return []; // nothing under this dir
696 } elseif ( is_dir( $fsDirectory ) ) {
697 $this->logger->warning( __METHOD__ .
698 ": unreadable directory: '$fsDirectory': $error" );
700 return self::RES_ERROR; // bad permissions?
701 } else {
702 $this->logger->warning( __METHOD__ .
703 ": unreachable directory: '$fsDirectory': $error" );
705 return self::RES_ERROR;
709 return $list;
712 protected function doGetLocalReferenceMulti( array $params ) {
713 $fsFiles = []; // (path => FSFile)
715 foreach ( $params['srcs'] as $src ) {
716 $source = $this->resolveToFSPath( $src );
717 if ( $source === null ) {
718 $fsFiles[$src] = self::RES_ERROR; // invalid path
719 continue;
722 $this->trapWarnings(); // don't trust 'false' if there were errors
723 $isFile = is_file( $source ); // regular files only
724 $hadError = $this->untrapWarnings();
726 if ( $isFile ) {
727 $fsFiles[$src] = new FSFile( $source );
728 } elseif ( $hadError ) {
729 $fsFiles[$src] = self::RES_ERROR;
730 } else {
731 $fsFiles[$src] = self::RES_ABSENT;
735 return $fsFiles;
738 protected function doGetLocalCopyMulti( array $params ) {
739 $tmpFiles = []; // (path => TempFSFile)
741 foreach ( $params['srcs'] as $src ) {
742 $source = $this->resolveToFSPath( $src );
743 if ( $source === null ) {
744 $tmpFiles[$src] = self::RES_ERROR; // invalid path
745 continue;
747 // Create a new temporary file with the same extension...
748 $ext = FileBackend::extensionFromPath( $src );
749 $tmpFile = $this->tmpFileFactory->newTempFSFile( 'localcopy_', $ext );
750 if ( !$tmpFile ) {
751 $tmpFiles[$src] = self::RES_ERROR;
752 continue;
755 $tmpPath = $tmpFile->getPath();
756 // Copy the source file over the temp file
757 $this->trapWarnings(); // don't trust 'false' if there were errors
758 $isFile = is_file( $source ); // regular files only
759 $copySuccess = $isFile ? copy( $source, $tmpPath ) : false;
760 $hadError = $this->untrapWarnings();
762 if ( $copySuccess ) {
763 $this->chmod( $tmpPath );
764 $tmpFiles[$src] = $tmpFile;
765 } elseif ( $hadError ) {
766 $tmpFiles[$src] = self::RES_ERROR; // copy failed
767 } else {
768 $tmpFiles[$src] = self::RES_ABSENT;
772 return $tmpFiles;
775 public function addShellboxInputFile( BoxedCommand $command, string $boxedName,
776 array $params
778 $path = $this->resolveToFSPath( $params['src'] );
779 if ( $path === null ) {
780 return $this->newStatus( 'backend-fail-invalidpath', $params['src'] );
782 $command->inputFileFromFile( $boxedName, $path );
783 return $this->newStatus();
786 protected function directoriesAreVirtual() {
787 return false;
791 * @param FSFileOpHandle[] $fileOpHandles
793 * @return StatusValue[]
795 protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
796 $statuses = [];
798 $pipes = [];
799 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
800 $pipes[$index] = popen( $fileOpHandle->cmd, 'r' );
803 $errs = [];
804 foreach ( $pipes as $index => $pipe ) {
805 // Result will be empty on success in *NIX. On Windows,
806 // it may be something like " 1 file(s) [copied|moved].".
807 $errs[$index] = stream_get_contents( $pipe );
808 fclose( $pipe );
811 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
812 $status = $this->newStatus();
813 $function = $fileOpHandle->callback;
814 $function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd );
815 $statuses[$index] = $status;
818 return $statuses;
822 * @param string $fsPath Absolute file system path
823 * @return string Absolute file system path on the same device
825 private function makeStagingPath( $fsPath ) {
826 $time = dechex( time() ); // make it easy to find old orphans
827 $hash = \Wikimedia\base_convert( md5( basename( $fsPath ) ), 16, 36, 25 );
828 $unique = \Wikimedia\base_convert( bin2hex( random_bytes( 16 ) ), 16, 36, 25 );
830 return dirname( $fsPath ) . "/.{$time}_{$hash}_{$unique}.tmpfsfile";
834 * @param string $fsSrcPath Absolute file system path
835 * @param string $fsDstPath Absolute file system path
836 * @param bool $ignoreMissing Whether to no-op if the source file is non-existent
837 * @return string Command
839 private function makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing ) {
840 // Use copy+rename since (a) this clears xattrs, (b) threads still reading the old
841 // inode are unaffected since it writes to a new inode, and (c) new threads reading
842 // the file will either totally see the old version or totally see the new version
843 $fsStagePath = $this->makeStagingPath( $fsDstPath );
844 $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsSrcPath ) );
845 $encStage = Shellbox::escape( $this->cleanPathSlashes( $fsStagePath ) );
846 $encDst = Shellbox::escape( $this->cleanPathSlashes( $fsDstPath ) );
847 if ( $this->os === 'Windows' ) {
848 // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/copy
849 // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/move
850 $cmdWrite = "COPY /B /Y $encSrc $encStage 2>&1 && MOVE /Y $encStage $encDst 2>&1";
851 $cmd = $ignoreMissing ? "IF EXIST $encSrc $cmdWrite" : $cmdWrite;
852 } else {
853 // https://manpages.debian.org/buster/coreutils/cp.1.en.html
854 // https://manpages.debian.org/buster/coreutils/mv.1.en.html
855 $cmdWrite = "cp $encSrc $encStage 2>&1 && mv $encStage $encDst 2>&1";
856 $cmd = $ignoreMissing ? "test -f $encSrc && $cmdWrite" : $cmdWrite;
857 // Clean up permissions on any newly created destination file
858 $octalPermissions = '0' . decoct( $this->fileMode );
859 if ( strlen( $octalPermissions ) == 4 ) {
860 $cmd .= " && chmod $octalPermissions $encDst 2>/dev/null";
864 return $cmd;
868 * @param string $fsSrcPath Absolute file system path
869 * @param string $fsDstPath Absolute file system path
870 * @param bool $ignoreMissing Whether to no-op if the source file is non-existent
871 * @return string Command
873 private function makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing = false ) {
874 // https://manpages.debian.org/buster/coreutils/mv.1.en.html
875 // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/move
876 $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsSrcPath ) );
877 $encDst = Shellbox::escape( $this->cleanPathSlashes( $fsDstPath ) );
878 if ( $this->os === 'Windows' ) {
879 $writeCmd = "MOVE /Y $encSrc $encDst 2>&1";
880 $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd;
881 } else {
882 $writeCmd = "mv -f $encSrc $encDst 2>&1";
883 $cmd = $ignoreMissing ? "test -f $encSrc && $writeCmd" : $writeCmd;
886 return $cmd;
890 * @param string $fsPath Absolute file system path
891 * @param bool $ignoreMissing Whether to no-op if the file is non-existent
892 * @return string Command
894 private function makeUnlinkCommand( $fsPath, $ignoreMissing = false ) {
895 // https://manpages.debian.org/buster/coreutils/rm.1.en.html
896 // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/del
897 $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsPath ) );
898 if ( $this->os === 'Windows' ) {
899 $writeCmd = "DEL /Q $encSrc 2>&1";
900 $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd;
901 } else {
902 $cmd = $ignoreMissing ? "rm -f $encSrc 2>&1" : "rm $encSrc 2>&1";
905 return $cmd;
909 * Chmod a file, suppressing the warnings
911 * @param string $fsPath Absolute file system path
912 * @return bool Success
914 protected function chmod( $fsPath ) {
915 if ( $this->os === 'Windows' ) {
916 return true;
919 AtEase::suppressWarnings();
920 $ok = chmod( $fsPath, $this->fileMode );
921 AtEase::restoreWarnings();
923 return $ok;
927 * Unlink a file, suppressing the warnings
929 * @param string $fsPath Absolute file system path
930 * @return bool Success
932 protected function unlink( $fsPath ) {
933 AtEase::suppressWarnings();
934 $ok = unlink( $fsPath );
935 AtEase::restoreWarnings();
936 clearstatcache( true, $fsPath );
938 return $ok;
942 * Remove an empty directory, suppressing the warnings
944 * @param string $fsDirectory Absolute file system path
945 * @return bool Success
947 protected function rmdir( $fsDirectory ) {
948 AtEase::suppressWarnings();
949 $ok = rmdir( $fsDirectory ); // remove directory if empty
950 AtEase::restoreWarnings();
951 clearstatcache( true, $fsDirectory );
953 return $ok;
957 * @param array $params Parameters for FileBackend 'create' operation
958 * @return TempFSFile|null
960 protected function newTempFileWithContent( array $params ) {
961 $tempFile = $this->tmpFileFactory->newTempFSFile( 'create_', 'tmp' );
962 if ( !$tempFile ) {
963 return null;
966 AtEase::suppressWarnings();
967 if ( file_put_contents( $tempFile->getPath(), $params['content'] ) === false ) {
968 $tempFile = null;
970 AtEase::restoreWarnings();
972 return $tempFile;
976 * Return the text of an index.html file to hide directory listings
978 * @return string
980 protected function indexHtmlPrivate() {
981 return '';
985 * Return the text of a .htaccess file to make a directory private
987 * @return string
989 protected function htaccessPrivate() {
990 return "Require all denied\n";
994 * Clean up directory separators for the given OS
996 * @param string $fsPath
997 * @return string
999 protected function cleanPathSlashes( $fsPath ) {
1000 return ( $this->os === 'Windows' ) ? strtr( $fsPath, '/', '\\' ) : $fsPath;
1004 * Listen for E_WARNING errors and track whether any that happen
1006 * @param string|null $regexIgnore Optional regex of errors to ignore
1008 protected function trapWarnings( $regexIgnore = null ) {
1009 $this->warningTrapStack[] = false;
1010 set_error_handler( function ( $errno, $errstr ) use ( $regexIgnore ) {
1011 if ( $regexIgnore === null || !preg_match( $regexIgnore, $errstr ) ) {
1012 $this->logger->error( $errstr );
1013 $this->warningTrapStack[count( $this->warningTrapStack ) - 1] = true;
1015 return true; // suppress from PHP handler
1016 }, E_WARNING );
1020 * Track E_WARNING errors but ignore any that correspond to ENOENT "No such file or directory"
1022 protected function trapWarningsIgnoringNotFound() {
1023 $this->trapWarnings( $this->getFileNotFoundRegex() );
1027 * Stop listening for E_WARNING errors and get whether any happened
1029 * @return bool Whether any warnings happened
1031 protected function untrapWarnings() {
1032 restore_error_handler();
1034 return array_pop( $this->warningTrapStack );
1038 * Get a regex matching file not found errors
1040 * @return string
1042 protected function getFileNotFoundRegex() {
1043 static $regex;
1044 if ( $regex === null ) {
1045 // "No such file or directory": string literal in spl_directory.c etc.
1046 $alternatives = [ ': No such file or directory' ];
1047 if ( $this->os === 'Windows' ) {
1048 // 2 = The system cannot find the file specified.
1049 // 3 = The system cannot find the path specified.
1050 $alternatives[] = ' \(code: [23]\)';
1052 if ( function_exists( 'pcntl_strerror' ) ) {
1053 $alternatives[] = preg_quote( ': ' . pcntl_strerror( 2 ), '/' );
1054 } elseif ( function_exists( 'socket_strerror' ) && defined( 'SOCKET_ENOENT' ) ) {
1055 $alternatives[] = preg_quote( ': ' . socket_strerror( SOCKET_ENOENT ), '/' );
1057 $regex = '/(' . implode( '|', $alternatives ) . ')$/';
1059 return $regex;
1063 * Determine whether a given error message is a file not found error.
1065 * @param string $error
1066 * @return bool
1068 protected function isFileNotFoundError( $error ) {
1069 return (bool)preg_match( $this->getFileNotFoundRegex(), $error );
1073 /** @deprecated class alias since 1.43 */
1074 class_alias( FSFileBackend::class, 'FSFileBackend' );