20use Shellbox\Command\BoxedCommand;
29use Wikimedia\Timestamp\ConvertibleTimestamp;
30use Wikimedia\Timestamp\TimestampFormat as TS;
68 private $warningTrapStack = [];
83 parent::__construct( $config );
86 if ( isset( $config[
'basePath'] ) ) {
87 $this->basePath = rtrim( $config[
'basePath'],
'/' );
89 $this->basePath =
null;
92 $this->containerPaths = [];
93 foreach ( ( $config[
'containerPaths'] ?? [] ) as $container => $fsPath ) {
94 $this->containerPaths[$container] = rtrim( $fsPath,
'/' );
97 $this->fileMode = $config[
'fileMode'] ?? 0o644;
98 $this->dirMode = $config[
'directoryMode'] ?? 0o777;
99 if ( isset( $config[
'fileOwner'] ) && function_exists(
'posix_getuid' ) ) {
100 $this->fileOwner = $config[
'fileOwner'];
102 $this->currentUser = posix_getpwuid( posix_getuid() )[
'name'];
105 $this->usableDirCache =
new MapCacheLRU( self::CACHE_CHEAP_SIZE );
106 $this->isWindows = ( PHP_OS_FAMILY ===
'Windows' );
117 if ( isset( $this->containerPaths[$container] ) || $this->basePath !==
null ) {
120 return $relStoragePath;
135 if ( preg_match(
'![^/]{256}!', $fsPath ) ) {
138 if ( $this->isWindows ) {
139 return !preg_match(
'![:*?"<>|]!', $fsPath );
154 if ( isset( $this->containerPaths[$shortCont] ) ) {
155 return $this->containerPaths[$shortCont];
156 } elseif ( $this->basePath !==
null ) {
157 return "{$this->basePath}/{$fullCont}";
171 if ( $relPath ===
null ) {
176 if ( $relPath !=
'' ) {
177 $fsPath .=
"/{$relPath}";
186 if ( $fsPath ===
null ) {
190 if ( $this->fileOwner !==
null && $this->currentUser !== $this->fileOwner ) {
191 trigger_error( __METHOD__ .
": PHP process owner is not '{$this->fileOwner}'." );
195 $fsDirectory = dirname( $fsPath );
196 $usable = $this->usableDirCache->get( $fsDirectory, MapCacheLRU::TTL_PROC_SHORT );
197 if ( $usable ===
null ) {
199 $usable = @is_dir( $fsDirectory ) && @is_writable( $fsDirectory );
200 $this->usableDirCache->set( $fsDirectory, $usable ? 1 : 0 );
211 if ( $fsDstPath ===
null ) {
212 $status->fatal(
'backend-fail-invalidpath', $params[
'dst'] );
217 if ( !empty( $params[
'async'] ) ) {
220 $status->fatal(
'backend-fail-create', $params[
'dst'] );
224 $cmd = $this->makeCopyCommand( $tempFile->getPath(), $fsDstPath,
false );
225 $handler =
function ( $errors,
StatusValue $status, array $params, $cmd ) {
226 if ( $errors !==
'' && !( $this->isWindows && $errors[0] ===
" " ) ) {
227 $status->fatal(
'backend-fail-create', $params[
'dst'] );
228 trigger_error(
"$cmd\n$errors", E_USER_WARNING );
231 $status->value =
new FSFileOpHandle( $this, $params, $handler, $cmd );
232 $tempFile->bind( $status->value );
238 $fsStagePath = $this->makeStagingPath( $fsDstPath );
240 $stageHandle = fopen( $fsStagePath,
'xb' );
241 if ( $stageHandle ) {
242 $bytes = fwrite( $stageHandle, $params[
'content'] );
243 $created = ( $bytes === strlen( $params[
'content'] ) );
244 fclose( $stageHandle );
245 $created = $created ? rename( $fsStagePath, $fsDstPath ) :
false;
248 if ( $hadError || !$created ) {
249 $status->fatal(
'backend-fail-create', $params[
'dst'] );
253 $this->
chmod( $fsDstPath );
263 $fsSrcPath = $params[
'src'];
265 if ( $fsDstPath ===
null ) {
266 $status->fatal(
'backend-fail-invalidpath', $params[
'dst'] );
271 if ( $fsSrcPath === $fsDstPath ) {
272 $status->fatal(
'backend-fail-internal', $this->name );
277 if ( !empty( $params[
'async'] ) ) {
278 $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath,
false );
279 $handler =
function ( $errors,
StatusValue $status, array $params, $cmd ) {
280 if ( $errors !==
'' && !( $this->isWindows && $errors[0] ===
" " ) ) {
281 $status->fatal(
'backend-fail-store', $params[
'src'], $params[
'dst'] );
282 trigger_error(
"$cmd\n$errors", E_USER_WARNING );
285 $status->value =
new FSFileOpHandle( $this, $params, $handler, $cmd );
291 $fsStagePath = $this->makeStagingPath( $fsDstPath );
293 $srcHandle = fopen( $fsSrcPath,
'rb' );
295 $stageHandle = fopen( $fsStagePath,
'xb' );
296 if ( $stageHandle ) {
297 $bytes = stream_copy_to_stream( $srcHandle, $stageHandle );
298 $stored = ( $bytes !==
false && $bytes === fstat( $srcHandle )[
'size'] );
299 fclose( $stageHandle );
300 $stored = $stored ? rename( $fsStagePath, $fsDstPath ) :
false;
302 fclose( $srcHandle );
305 if ( $hadError || !$stored ) {
306 $status->fatal(
'backend-fail-store', $params[
'src'], $params[
'dst'] );
310 $this->
chmod( $fsDstPath );
321 if ( $fsSrcPath ===
null ) {
322 $status->fatal(
'backend-fail-invalidpath', $params[
'src'] );
328 if ( $fsDstPath ===
null ) {
329 $status->fatal(
'backend-fail-invalidpath', $params[
'dst'] );
334 if ( $fsSrcPath === $fsDstPath ) {
338 $ignoreMissing = !empty( $params[
'ignoreMissingSource'] );
340 if ( !empty( $params[
'async'] ) ) {
341 $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing );
342 $handler =
function ( $errors,
StatusValue $status, array $params, $cmd ) {
343 if ( $errors !==
'' && !( $this->isWindows && $errors[0] ===
" " ) ) {
344 $status->fatal(
'backend-fail-copy', $params[
'src'], $params[
'dst'] );
345 trigger_error(
"$cmd\n$errors", E_USER_WARNING );
348 $status->value =
new FSFileOpHandle( $this, $params, $handler, $cmd );
354 $fsStagePath = $this->makeStagingPath( $fsDstPath );
356 $srcHandle = fopen( $fsSrcPath,
'rb' );
358 $stageHandle = fopen( $fsStagePath,
'xb' );
359 if ( $stageHandle ) {
360 $bytes = stream_copy_to_stream( $srcHandle, $stageHandle );
361 $copied = ( $bytes !==
false && $bytes === fstat( $srcHandle )[
'size'] );
362 fclose( $stageHandle );
363 $copied = $copied ? rename( $fsStagePath, $fsDstPath ) :
false;
365 fclose( $srcHandle );
368 if ( $hadError || ( !$copied && !$ignoreMissing ) ) {
369 $status->fatal(
'backend-fail-copy', $params[
'src'], $params[
'dst'] );
374 $this->
chmod( $fsDstPath );
386 if ( $fsSrcPath ===
null ) {
387 $status->fatal(
'backend-fail-invalidpath', $params[
'src'] );
393 if ( $fsDstPath ===
null ) {
394 $status->fatal(
'backend-fail-invalidpath', $params[
'dst'] );
399 if ( $fsSrcPath === $fsDstPath ) {
403 $ignoreMissing = !empty( $params[
'ignoreMissingSource'] );
405 if ( !empty( $params[
'async'] ) ) {
406 $cmd = $this->makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing );
407 $handler =
function ( $errors,
StatusValue $status, array $params, $cmd ) {
408 if ( $errors !==
'' && !( $this->isWindows && $errors[0] ===
" " ) ) {
409 $status->fatal(
'backend-fail-move', $params[
'src'], $params[
'dst'] );
410 trigger_error(
"$cmd\n$errors", E_USER_WARNING );
413 $status->value =
new FSFileOpHandle( $this, $params, $handler, $cmd );
419 $moved = rename( $fsSrcPath, $fsDstPath );
421 if ( $hadError || ( !$moved && !$ignoreMissing ) ) {
422 $status->fatal(
'backend-fail-move', $params[
'src'], $params[
'dst'] );
436 if ( $fsSrcPath ===
null ) {
437 $status->fatal(
'backend-fail-invalidpath', $params[
'src'] );
442 $ignoreMissing = !empty( $params[
'ignoreMissingSource'] );
444 if ( !empty( $params[
'async'] ) ) {
445 $cmd = $this->makeUnlinkCommand( $fsSrcPath, $ignoreMissing );
446 $handler =
function ( $errors,
StatusValue $status, array $params, $cmd ) {
447 if ( $errors !==
'' && !( $this->isWindows && $errors[0] ===
" " ) ) {
448 $status->fatal(
'backend-fail-delete', $params[
'src'] );
449 trigger_error(
"$cmd\n$errors", E_USER_WARNING );
452 $status->value =
new FSFileOpHandle( $this, $params, $handler, $cmd );
455 $deleted =
unlink( $fsSrcPath );
457 if ( $hadError || ( !$deleted && !$ignoreMissing ) ) {
458 $status->fatal(
'backend-fail-delete', $params[
'src'] );
474 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
478 $alreadyExisted = @is_dir( $fsDirectory );
479 if ( !$alreadyExisted ) {
481 $created = @mkdir( $fsDirectory, $this->dirMode,
true );
484 $alreadyExisted = @is_dir( $fsDirectory );
488 $isWritable = $created ?: @is_writable( $fsDirectory );
489 if ( !$alreadyExisted && !$created ) {
490 $this->logger->error( __METHOD__ .
": cannot create directory $fsDirectory" );
491 $status->fatal(
'directorycreateerror', $params[
'dir'] );
492 } elseif ( !$isWritable ) {
493 $this->logger->error( __METHOD__ .
": directory $fsDirectory is read-only" );
494 $status->fatal(
'directoryreadonlyerror', $params[
'dir'] );
501 if ( $status->isGood() ) {
502 $this->usableDirCache->set( $fsDirectory, 1 );
513 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
515 if ( !empty( $params[
'noListing'] ) && !is_file(
"{$fsDirectory}/index.html" ) ) {
517 $bytes = file_put_contents(
"{$fsDirectory}/index.html", $this->
indexHtmlPrivate() );
519 if ( $bytes ===
false ) {
520 $status->fatal(
'backend-fail-create', $params[
'dir'] .
'/index.html' );
524 if ( !empty( $params[
'noAccess'] ) && !is_file(
"{$contRoot}/.htaccess" ) ) {
526 $bytes = @file_put_contents(
"{$contRoot}/.htaccess", $this->
htaccessPrivate() );
527 if ( $bytes ===
false ) {
528 $storeDir =
"mwstore://{$this->name}/{$shortCont}";
529 $status->fatal(
'backend-fail-create',
"{$storeDir}/.htaccess" );
541 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
543 if ( !empty( $params[
'listing'] ) && is_file(
"{$fsDirectory}/index.html" ) ) {
544 $exists = ( file_get_contents(
"{$fsDirectory}/index.html" ) === $this->
indexHtmlPrivate() );
545 if ( $exists && !$this->
unlink(
"{$fsDirectory}/index.html" ) ) {
546 $status->fatal(
'backend-fail-delete', $params[
'dir'] .
'/index.html' );
550 if ( !empty( $params[
'access'] ) && is_file(
"{$contRoot}/.htaccess" ) ) {
551 $exists = ( file_get_contents(
"{$contRoot}/.htaccess" ) === $this->
htaccessPrivate() );
552 if ( $exists && !$this->
unlink(
"{$contRoot}/.htaccess" ) ) {
553 $storeDir =
"mwstore://{$this->name}/{$shortCont}";
554 $status->fatal(
'backend-fail-delete',
"{$storeDir}/.htaccess" );
566 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
568 $this->
rmdir( $fsDirectory );
576 if ( $fsSrcPath ===
null ) {
577 return self::RES_ERROR;
581 $stat = is_file( $fsSrcPath ) ? stat( $fsSrcPath ) :
false;
584 if ( is_array( $stat ) ) {
585 $ct =
new ConvertibleTimestamp( $stat[
'mtime'] );
588 'mtime' => $ct->getTimestamp( TS::MW ),
589 'size' => $stat[
'size']
593 return $hadError ? self::RES_ERROR : self::RES_ABSENT;
598 if ( is_array( $paths ) ) {
599 foreach ( $paths as
$path ) {
601 if ( $fsPath !==
null ) {
602 clearstatcache(
true, $fsPath );
603 $this->usableDirCache->clear( $fsPath );
607 clearstatcache(
true );
608 $this->usableDirCache->clear();
616 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
619 $exists = is_dir( $fsDirectory );
622 return $hadError ? self::RES_ERROR : $exists;
635 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
638 $error = $list->getLastError();
639 if ( $error !==
null ) {
641 $this->logger->info( __METHOD__ .
": non-existant directory: '$fsDirectory'" );
644 } elseif ( is_dir( $fsDirectory ) ) {
645 $this->logger->warning( __METHOD__ .
": unreadable directory: '$fsDirectory'" );
647 return self::RES_ERROR;
649 $this->logger->warning( __METHOD__ .
": unreachable directory: '$fsDirectory'" );
651 return self::RES_ERROR;
668 $fsDirectory = ( $dirRel !=
'' ) ?
"{$contRoot}/{$dirRel}" : $contRoot;
671 $error = $list->getLastError();
672 if ( $error !==
null ) {
674 $this->logger->info( __METHOD__ .
": non-existent directory: '$fsDirectory'" );
677 } elseif ( is_dir( $fsDirectory ) ) {
678 $this->logger->warning( __METHOD__ .
679 ": unreadable directory: '$fsDirectory': $error" );
681 return self::RES_ERROR;
683 $this->logger->warning( __METHOD__ .
684 ": unreachable directory: '$fsDirectory': $error" );
686 return self::RES_ERROR;
697 foreach ( $params[
'srcs'] as $src ) {
700 $fsFiles[$src] = self::RES_ERROR;
710 } elseif ( $hadError ) {
711 $fsFiles[$src] = self::RES_ERROR;
713 $fsFiles[$src] = self::RES_ABSENT;
724 foreach ( $params[
'srcs'] as $src ) {
727 $tmpFiles[$src] = self::RES_ERROR;
732 $tmpFile = $this->tmpFileFactory->newTempFSFile(
'localcopy_', $ext );
734 $tmpFiles[$src] = self::RES_ERROR;
738 $tmpPath = $tmpFile->getPath();
742 $copySuccess = $isFile ?
copy(
$source, $tmpPath ) :
false;
745 if ( $copySuccess ) {
746 $this->
chmod( $tmpPath );
747 $tmpFiles[$src] = $tmpFile;
748 } elseif ( $hadError ) {
749 $tmpFiles[$src] = self::RES_ERROR;
751 $tmpFiles[$src] = self::RES_ABSENT;
763 if (
$path ===
null ) {
764 return $this->
newStatus(
'backend-fail-invalidpath', $params[
'src'] );
766 $command->inputFileFromFile( $boxedName,
$path );
784 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
785 $pipes[$index] = popen( $fileOpHandle->cmd,
'r' );
789 foreach ( $pipes as $index => $pipe ) {
792 $errs[$index] = stream_get_contents( $pipe );
796 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
798 $function = $fileOpHandle->callback;
799 $function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd );
800 $statuses[$index] = $status;
810 private function makeStagingPath( $fsPath ) {
811 $time = dechex( time() );
812 $hash = \Wikimedia\base_convert( md5( basename( $fsPath ) ), 16, 36, 25 );
813 $unique = \Wikimedia\base_convert( bin2hex( random_bytes( 16 ) ), 16, 36, 25 );
815 return dirname( $fsPath ) .
"/.{$time}_{$hash}_{$unique}.tmpfsfile";
824 private function makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing ) {
828 $fsStagePath = $this->makeStagingPath( $fsDstPath );
832 if ( $this->isWindows ) {
835 $cmdWrite =
"COPY /B /Y $encSrc $encStage 2>&1 && MOVE /Y $encStage $encDst 2>&1";
836 $cmd = $ignoreMissing ?
"IF EXIST $encSrc $cmdWrite" : $cmdWrite;
840 $cmdWrite =
"cp $encSrc $encStage 2>&1 && mv $encStage $encDst 2>&1";
841 $cmd = $ignoreMissing ?
"test -f $encSrc && $cmdWrite" : $cmdWrite;
843 $octalPermissions =
'0' . decoct( $this->fileMode );
844 if ( strlen( $octalPermissions ) == 4 ) {
845 $cmd .=
" && chmod $octalPermissions $encDst 2>/dev/null";
858 private function makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing =
false ) {
863 if ( $this->isWindows ) {
864 $writeCmd =
"MOVE /Y $encSrc $encDst 2>&1";
865 $cmd = $ignoreMissing ?
"IF EXIST $encSrc $writeCmd" : $writeCmd;
867 $writeCmd =
"mv -f $encSrc $encDst 2>&1";
868 $cmd = $ignoreMissing ?
"test -f $encSrc && $writeCmd" : $writeCmd;
879 private function makeUnlinkCommand( $fsPath, $ignoreMissing =
false ) {
883 if ( $this->isWindows ) {
884 $writeCmd =
"DEL /Q $encSrc 2>&1";
885 $cmd = $ignoreMissing ?
"IF EXIST $encSrc $writeCmd" : $writeCmd;
887 $cmd = $ignoreMissing ?
"rm -f $encSrc 2>&1" :
"rm $encSrc 2>&1";
899 protected function chmod( $fsPath ) {
900 if ( $this->isWindows ) {
905 $ok = @
chmod( $fsPath, $this->fileMode );
919 clearstatcache(
true, $fsPath );
930 protected function rmdir( $fsDirectory ) {
932 $ok = @
rmdir( $fsDirectory );
933 clearstatcache(
true, $fsDirectory );
943 $tempFile = $this->tmpFileFactory->newTempFSFile(
'create_',
'tmp' );
949 if ( @file_put_contents( $tempFile->getPath(), $params[
'content'] ) ===
false ) {
971 return "Require all denied\n" .
982 return ( $this->isWindows ) ? strtr( $fsPath,
'/',
'\\' ) : $fsPath;
991 $this->warningTrapStack[] =
false;
992 set_error_handler(
function ( $errno, $errstr ) use ( $regexIgnore ) {
993 if ( $regexIgnore ===
null || !preg_match( $regexIgnore, $errstr ) ) {
994 $this->logger->error( $errstr );
995 $this->warningTrapStack[count( $this->warningTrapStack ) - 1] =
true;
1014 restore_error_handler();
1016 return array_pop( $this->warningTrapStack );
1026 if ( $regex ===
null ) {
1028 $alternatives = [
': No such file or directory' ];
1029 if ( $this->isWindows ) {
1032 $alternatives[] =
' \(code: [23]\)';
1034 if ( function_exists(
'pcntl_strerror' ) ) {
1035 $alternatives[] = preg_quote(
': ' . pcntl_strerror( 2 ),
'/' );
1036 } elseif ( function_exists(
'socket_strerror' ) && defined(
'SOCKET_ENOENT' ) ) {
1038 $alternatives[] = preg_quote(
': ' . socket_strerror( SOCKET_ENOENT ),
'/' );
1040 $regex =
'/(' . implode(
'|', $alternatives ) .
')$/';
1057class_alias( FSFileBackend::class,
'FSFileBackend' );
Generic operation result class Has warning/error list, boolean status and arbitrary value.