49use Wikimedia\AtEase\AtEase;
55use Wikimedia\Timestamp\ConvertibleTimestamp;
96 private $warningTrapStack = [];
109 parent::__construct( $config );
111 if ( PHP_OS_FAMILY ===
'Windows' ) {
112 $this->os =
'Windows';
113 } elseif ( PHP_OS_FAMILY ===
'BSD' || PHP_OS_FAMILY ===
'Darwin' ) {
119 if ( isset( $config[
'basePath'] ) ) {
120 $this->basePath = rtrim( $config[
'basePath'],
'/' );
122 $this->basePath =
null;
125 $this->containerPaths = [];
126 foreach ( ( $config[
'containerPaths'] ?? [] ) as $container => $fsPath ) {
127 $this->containerPaths[$container] = rtrim( $fsPath,
'/' );
130 $this->fileMode = $config[
'fileMode'] ?? 0644;
131 $this->dirMode = $config[
'directoryMode'] ?? 0777;
132 if ( isset( $config[
'fileOwner'] ) && function_exists(
'posix_getuid' ) ) {
133 $this->fileOwner = $config[
'fileOwner'];
135 $this->currentUser = posix_getpwuid( posix_getuid() )[
'name'];
138 $this->usableDirCache =
new MapCacheLRU( self::CACHE_CHEAP_SIZE );
147 if ( isset( $this->containerPaths[$container] ) || isset( $this->basePath ) ) {
150 return $relStoragePath;
165 if ( preg_match(
'![^/]{256}!', $fsPath ) ) {
168 if ( $this->os ===
'Windows' ) {
169 return !preg_match(
'![:*?"<>|]!', $fsPath );
184 if ( isset( $this->containerPaths[$shortCont] ) ) {
185 return $this->containerPaths[$shortCont];
186 } elseif ( isset( $this->basePath ) ) {
187 return "{$this->basePath}/{$fullCont}";
201 if ( $relPath ===
null ) {
206 if ( $relPath !=
'' ) {
207 $fsPath .=
"/{$relPath}";
215 if ( $fsPath ===
null ) {
219 if ( $this->fileOwner !==
null && $this->currentUser !== $this->fileOwner ) {
220 trigger_error( __METHOD__ .
": PHP process owner is not '{$this->fileOwner}'." );
224 $fsDirectory = dirname( $fsPath );
225 $usable = $this->usableDirCache->get( $fsDirectory, MapCacheLRU::TTL_PROC_SHORT );
226 if ( $usable ===
null ) {
227 AtEase::suppressWarnings();
228 $usable = is_dir( $fsDirectory ) && is_writable( $fsDirectory );
229 AtEase::restoreWarnings();
230 $this->usableDirCache->set( $fsDirectory, $usable ? 1 : 0 );
240 if ( $fsDstPath ===
null ) {
241 $status->fatal(
'backend-fail-invalidpath',
$params[
'dst'] );
246 if ( !empty(
$params[
'async'] ) ) {
249 $status->fatal(
'backend-fail-create',
$params[
'dst'] );
253 $cmd = $this->makeCopyCommand( $tempFile->getPath(), $fsDstPath,
false );
255 if ( $errors !==
'' && !( $this->os ===
'Windows' && $errors[0] ===
" " ) ) {
256 $status->fatal(
'backend-fail-create',
$params[
'dst'] );
257 trigger_error(
"$cmd\n$errors", E_USER_WARNING );
261 $tempFile->bind( $status->value );
267 $fsStagePath = $this->makeStagingPath( $fsDstPath );
269 $stageHandle = fopen( $fsStagePath,
'xb' );
270 if ( $stageHandle ) {
271 $bytes = fwrite( $stageHandle,
$params[
'content'] );
272 $created = ( $bytes === strlen(
$params[
'content'] ) );
273 fclose( $stageHandle );
274 $created = $created ? rename( $fsStagePath, $fsDstPath ) :
false;
277 if ( $hadError || !$created ) {
278 $status->fatal(
'backend-fail-create',
$params[
'dst'] );
282 $this->
chmod( $fsDstPath );
293 if ( $fsDstPath ===
null ) {
294 $status->fatal(
'backend-fail-invalidpath',
$params[
'dst'] );
299 if ( $fsSrcPath === $fsDstPath ) {
300 $status->fatal(
'backend-fail-internal', $this->name );
305 if ( !empty(
$params[
'async'] ) ) {
306 $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath,
false );
308 if ( $errors !==
'' && !( $this->os ===
'Windows' && $errors[0] ===
" " ) ) {
309 $status->fatal(
'backend-fail-store',
$params[
'src'],
$params[
'dst'] );
310 trigger_error(
"$cmd\n$errors", E_USER_WARNING );
319 $fsStagePath = $this->makeStagingPath( $fsDstPath );
321 $srcHandle = fopen( $fsSrcPath,
'rb' );
323 $stageHandle = fopen( $fsStagePath,
'xb' );
324 if ( $stageHandle ) {
325 $bytes = stream_copy_to_stream( $srcHandle, $stageHandle );
326 $stored = ( $bytes !==
false && $bytes === fstat( $srcHandle )[
'size'] );
327 fclose( $stageHandle );
328 $stored = $stored ? rename( $fsStagePath, $fsDstPath ) :
false;
330 fclose( $srcHandle );
333 if ( $hadError || !$stored ) {
334 $status->fatal(
'backend-fail-store',
$params[
'src'],
$params[
'dst'] );
338 $this->
chmod( $fsDstPath );
348 if ( $fsSrcPath ===
null ) {
349 $status->fatal(
'backend-fail-invalidpath',
$params[
'src'] );
355 if ( $fsDstPath ===
null ) {
356 $status->fatal(
'backend-fail-invalidpath',
$params[
'dst'] );
361 if ( $fsSrcPath === $fsDstPath ) {
365 $ignoreMissing = !empty(
$params[
'ignoreMissingSource'] );
367 if ( !empty(
$params[
'async'] ) ) {
368 $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing );
370 if ( $errors !==
'' && !( $this->os ===
'Windows' && $errors[0] ===
" " ) ) {
371 $status->fatal(
'backend-fail-copy',
$params[
'src'],
$params[
'dst'] );
372 trigger_error(
"$cmd\n$errors", E_USER_WARNING );
381 $fsStagePath = $this->makeStagingPath( $fsDstPath );
383 $srcHandle = fopen( $fsSrcPath,
'rb' );
385 $stageHandle = fopen( $fsStagePath,
'xb' );
386 if ( $stageHandle ) {
387 $bytes = stream_copy_to_stream( $srcHandle, $stageHandle );
388 $copied = ( $bytes !==
false && $bytes === fstat( $srcHandle )[
'size'] );
389 fclose( $stageHandle );
390 $copied = $copied ? rename( $fsStagePath, $fsDstPath ) :
false;
392 fclose( $srcHandle );
395 if ( $hadError || ( !$copied && !$ignoreMissing ) ) {
396 $status->fatal(
'backend-fail-copy',
$params[
'src'],
$params[
'dst'] );
401 $this->
chmod( $fsDstPath );
412 if ( $fsSrcPath ===
null ) {
413 $status->fatal(
'backend-fail-invalidpath',
$params[
'src'] );
419 if ( $fsDstPath ===
null ) {
420 $status->fatal(
'backend-fail-invalidpath',
$params[
'dst'] );
425 if ( $fsSrcPath === $fsDstPath ) {
429 $ignoreMissing = !empty(
$params[
'ignoreMissingSource'] );
431 if ( !empty(
$params[
'async'] ) ) {
432 $cmd = $this->makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing );
434 if ( $errors !==
'' && !( $this->os ===
'Windows' && $errors[0] ===
" " ) ) {
435 $status->fatal(
'backend-fail-move',
$params[
'src'],
$params[
'dst'] );
436 trigger_error(
"$cmd\n$errors", E_USER_WARNING );
445 $moved = rename( $fsSrcPath, $fsDstPath );
447 if ( $hadError || ( !$moved && !$ignoreMissing ) ) {
448 $status->fatal(
'backend-fail-move',
$params[
'src'],
$params[
'dst'] );
461 if ( $fsSrcPath ===
null ) {
462 $status->fatal(
'backend-fail-invalidpath',
$params[
'src'] );
467 $ignoreMissing = !empty(
$params[
'ignoreMissingSource'] );
469 if ( !empty(
$params[
'async'] ) ) {
470 $cmd = $this->makeUnlinkCommand( $fsSrcPath, $ignoreMissing );
472 if ( $errors !==
'' && !( $this->os ===
'Windows' && $errors[0] ===
" " ) ) {
473 $status->fatal(
'backend-fail-delete',
$params[
'src'] );
474 trigger_error(
"$cmd\n$errors", E_USER_WARNING );
480 $deleted =
unlink( $fsSrcPath );
482 if ( $hadError || ( !$deleted && !$ignoreMissing ) ) {
483 $status->fatal(
'backend-fail-delete',
$params[
'src'] );
499 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
502 AtEase::suppressWarnings();
503 $alreadyExisted = is_dir( $fsDirectory );
504 if ( !$alreadyExisted ) {
505 $created = mkdir( $fsDirectory, $this->dirMode,
true );
507 $alreadyExisted = is_dir( $fsDirectory );
510 $isWritable = $created ?: is_writable( $fsDirectory );
511 AtEase::restoreWarnings();
512 if ( !$alreadyExisted && !$created ) {
513 $this->logger->error( __METHOD__ .
": cannot create directory $fsDirectory" );
514 $status->fatal(
'directorycreateerror',
$params[
'dir'] );
515 } elseif ( !$isWritable ) {
516 $this->logger->error( __METHOD__ .
": directory $fsDirectory is read-only" );
517 $status->fatal(
'directoryreadonlyerror',
$params[
'dir'] );
524 if ( $status->isGood() ) {
525 $this->usableDirCache->set( $fsDirectory, 1 );
535 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
537 if ( !empty(
$params[
'noListing'] ) && !is_file(
"{$fsDirectory}/index.html" ) ) {
539 $bytes = file_put_contents(
"{$fsDirectory}/index.html", $this->
indexHtmlPrivate() );
541 if ( $bytes ===
false ) {
542 $status->fatal(
'backend-fail-create',
$params[
'dir'] .
'/index.html' );
546 if ( !empty(
$params[
'noAccess'] ) && !is_file(
"{$contRoot}/.htaccess" ) ) {
547 AtEase::suppressWarnings();
548 $bytes = file_put_contents(
"{$contRoot}/.htaccess", $this->
htaccessPrivate() );
549 AtEase::restoreWarnings();
550 if ( $bytes ===
false ) {
551 $storeDir =
"mwstore://{$this->name}/{$shortCont}";
552 $status->fatal(
'backend-fail-create',
"{$storeDir}/.htaccess" );
563 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
565 if ( !empty(
$params[
'listing'] ) && is_file(
"{$fsDirectory}/index.html" ) ) {
566 $exists = ( file_get_contents(
"{$fsDirectory}/index.html" ) === $this->
indexHtmlPrivate() );
567 if ( $exists && !$this->
unlink(
"{$fsDirectory}/index.html" ) ) {
568 $status->fatal(
'backend-fail-delete',
$params[
'dir'] .
'/index.html' );
572 if ( !empty(
$params[
'access'] ) && is_file(
"{$contRoot}/.htaccess" ) ) {
573 $exists = ( file_get_contents(
"{$contRoot}/.htaccess" ) === $this->
htaccessPrivate() );
574 if ( $exists && !$this->
unlink(
"{$contRoot}/.htaccess" ) ) {
575 $storeDir =
"mwstore://{$this->name}/{$shortCont}";
576 $status->fatal(
'backend-fail-delete',
"{$storeDir}/.htaccess" );
587 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
589 $this->
rmdir( $fsDirectory );
596 if ( $fsSrcPath ===
null ) {
597 return self::RES_ERROR;
601 $stat = is_file( $fsSrcPath ) ? stat( $fsSrcPath ) :
false;
604 if ( is_array( $stat ) ) {
605 $ct =
new ConvertibleTimestamp( $stat[
'mtime'] );
608 'mtime' => $ct->getTimestamp( TS_MW ),
609 'size' => $stat[
'size']
613 return $hadError ? self::RES_ERROR : self::RES_ABSENT;
617 if ( is_array( $paths ) ) {
618 foreach ( $paths as
$path ) {
620 if ( $fsPath !==
null ) {
621 clearstatcache(
true, $fsPath );
622 $this->usableDirCache->clear( $fsPath );
626 clearstatcache(
true );
627 $this->usableDirCache->clear();
634 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
637 $exists = is_dir( $fsDirectory );
640 return $hadError ? self::RES_ERROR : $exists;
653 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
656 $error = $list->getLastError();
657 if ( $error !==
null ) {
659 $this->logger->info( __METHOD__ .
": non-existant directory: '$fsDirectory'" );
662 } elseif ( is_dir( $fsDirectory ) ) {
663 $this->logger->warning( __METHOD__ .
": unreadable directory: '$fsDirectory'" );
665 return self::RES_ERROR;
667 $this->logger->warning( __METHOD__ .
": unreachable directory: '$fsDirectory'" );
669 return self::RES_ERROR;
686 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
689 $error = $list->getLastError();
690 if ( $error !==
null ) {
692 $this->logger->info( __METHOD__ .
": non-existent directory: '$fsDirectory'" );
695 } elseif ( is_dir( $fsDirectory ) ) {
696 $this->logger->warning( __METHOD__ .
697 ": unreadable directory: '$fsDirectory': $error" );
699 return self::RES_ERROR;
701 $this->logger->warning( __METHOD__ .
702 ": unreachable directory: '$fsDirectory': $error" );
704 return self::RES_ERROR;
714 foreach (
$params[
'srcs'] as $src ) {
717 $fsFiles[$src] = self::RES_ERROR;
727 } elseif ( $hadError ) {
728 $fsFiles[$src] = self::RES_ERROR;
730 $fsFiles[$src] = self::RES_ABSENT;
740 foreach (
$params[
'srcs'] as $src ) {
743 $tmpFiles[$src] = self::RES_ERROR;
748 $tmpFile = $this->tmpFileFactory->newTempFSFile(
'localcopy_', $ext );
750 $tmpFiles[$src] = self::RES_ERROR;
754 $tmpPath = $tmpFile->getPath();
758 $copySuccess = $isFile ?
copy(
$source, $tmpPath ) :
false;
761 if ( $copySuccess ) {
762 $this->
chmod( $tmpPath );
763 $tmpFiles[$src] = $tmpFile;
764 } elseif ( $hadError ) {
765 $tmpFiles[$src] = self::RES_ERROR;
767 $tmpFiles[$src] = self::RES_ABSENT;
787 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
788 $pipes[$index] = popen( $fileOpHandle->cmd,
'r' );
792 foreach ( $pipes as $index => $pipe ) {
795 $errs[$index] = stream_get_contents( $pipe );
799 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
801 $function = $fileOpHandle->callback;
802 $function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd );
803 $statuses[$index] = $status;
813 private function makeStagingPath( $fsPath ) {
814 $time = dechex( time() );
815 $hash = \Wikimedia\base_convert( md5( basename( $fsPath ) ), 16, 36, 25 );
816 $unique = \Wikimedia\base_convert( bin2hex( random_bytes( 16 ) ), 16, 36, 25 );
818 return dirname( $fsPath ) .
"/.{$time}_{$hash}_{$unique}.tmpfsfile";
827 private function makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing ) {
831 $fsStagePath = $this->makeStagingPath( $fsDstPath );
835 if ( $this->os ===
'Windows' ) {
838 $cmdWrite =
"COPY /B /Y $encSrc $encStage 2>&1 && MOVE /Y $encStage $encDst 2>&1";
839 $cmd = $ignoreMissing ?
"IF EXIST $encSrc $cmdWrite" : $cmdWrite;
843 $cmdWrite =
"cp $encSrc $encStage 2>&1 && mv $encStage $encDst 2>&1";
844 $cmd = $ignoreMissing ?
"test -f $encSrc && $cmdWrite" : $cmdWrite;
846 $octalPermissions =
'0' . decoct( $this->fileMode );
847 if ( strlen( $octalPermissions ) == 4 ) {
848 $cmd .=
" && chmod $octalPermissions $encDst 2>/dev/null";
861 private function makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing =
false ) {
866 if ( $this->os ===
'Windows' ) {
867 $writeCmd =
"MOVE /Y $encSrc $encDst 2>&1";
868 $cmd = $ignoreMissing ?
"IF EXIST $encSrc $writeCmd" : $writeCmd;
870 $writeCmd =
"mv -f $encSrc $encDst 2>&1";
871 $cmd = $ignoreMissing ?
"test -f $encSrc && $writeCmd" : $writeCmd;
882 private function makeUnlinkCommand( $fsPath, $ignoreMissing =
false ) {
886 if ( $this->os ===
'Windows' ) {
887 $writeCmd =
"DEL /Q $encSrc 2>&1";
888 $cmd = $ignoreMissing ?
"IF EXIST $encSrc $writeCmd" : $writeCmd;
890 $cmd = $ignoreMissing ?
"rm -f $encSrc 2>&1" :
"rm $encSrc 2>&1";
902 protected function chmod( $fsPath ) {
903 if ( $this->os ===
'Windows' ) {
907 AtEase::suppressWarnings();
908 $ok =
chmod( $fsPath, $this->fileMode );
909 AtEase::restoreWarnings();
921 AtEase::suppressWarnings();
923 AtEase::restoreWarnings();
924 clearstatcache(
true, $fsPath );
935 protected function rmdir( $fsDirectory ) {
936 AtEase::suppressWarnings();
937 $ok =
rmdir( $fsDirectory );
938 AtEase::restoreWarnings();
939 clearstatcache(
true, $fsDirectory );
949 $tempFile = $this->tmpFileFactory->newTempFSFile(
'create_',
'tmp' );
954 AtEase::suppressWarnings();
955 if ( file_put_contents( $tempFile->getPath(),
$params[
'content'] ) ===
false ) {
958 AtEase::restoreWarnings();
978 return "Require all denied\n";
988 return ( $this->os ===
'Windows' ) ? strtr( $fsPath,
'/',
'\\' ) : $fsPath;
997 $this->warningTrapStack[] =
false;
998 set_error_handler(
function ( $errno, $errstr ) use ( $regexIgnore ) {
999 if ( $regexIgnore ===
null || !preg_match( $regexIgnore, $errstr ) ) {
1000 $this->logger->error( $errstr );
1001 $this->warningTrapStack[count( $this->warningTrapStack ) - 1] =
true;
1020 restore_error_handler();
1022 return array_pop( $this->warningTrapStack );
1032 if ( $regex ===
null ) {
1034 $alternatives = [
': No such file or directory' ];
1035 if ( $this->os ===
'Windows' ) {
1038 $alternatives[] =
' \(code: [23]\)';
1040 if ( function_exists(
'pcntl_strerror' ) ) {
1041 $alternatives[] = preg_quote(
': ' . pcntl_strerror( 2 ),
'/' );
1042 } elseif ( function_exists(
'socket_strerror' ) && defined(
'SOCKET_ENOENT' ) ) {
1043 $alternatives[] = preg_quote(
': ' . socket_strerror( SOCKET_ENOENT ),
'/' );
1045 $regex =
'/(' . implode(
'|', $alternatives ) .
')$/';
1062class_alias( FSFileBackend::class,
'FSFileBackend' );
array $params
The job parameters.
Store key-value entries in a size-limited in-memory LRU cache.
Generic operation result class Has warning/error list, boolean status and arbitrary value.