MediaWiki master
FSFileBackend.php
Go to the documentation of this file.
1<?php
44namespace Wikimedia\FileBackend;
45
46use MapCacheLRU;
47use Shellbox\Shellbox;
48use StatusValue;
49use Wikimedia\AtEase\AtEase;
55use Wikimedia\Timestamp\ConvertibleTimestamp;
56
75 protected $usableDirCache;
76
78 protected $basePath;
79
81 protected $containerPaths;
82
84 protected $dirMode;
86 protected $fileMode;
88 protected $fileOwner;
89
91 protected $os;
93 protected $currentUser;
94
96 private $warningTrapStack = [];
97
108 public function __construct( array $config ) {
109 parent::__construct( $config );
110
111 if ( PHP_OS_FAMILY === 'Windows' ) {
112 $this->os = 'Windows';
113 } elseif ( PHP_OS_FAMILY === 'BSD' || PHP_OS_FAMILY === 'Darwin' ) {
114 $this->os = 'BSD';
115 } else {
116 $this->os = 'Linux';
117 }
118 // Remove any possible trailing slash from directories
119 if ( isset( $config['basePath'] ) ) {
120 $this->basePath = rtrim( $config['basePath'], '/' ); // remove trailing slash
121 } else {
122 $this->basePath = null; // none; containers must have explicit paths
123 }
124
125 $this->containerPaths = [];
126 foreach ( ( $config['containerPaths'] ?? [] ) as $container => $fsPath ) {
127 $this->containerPaths[$container] = rtrim( $fsPath, '/' ); // remove trailing slash
128 }
129
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'];
134 // Cache this, assuming it doesn't change
135 $this->currentUser = posix_getpwuid( posix_getuid() )['name'];
136 }
137
138 $this->usableDirCache = new MapCacheLRU( self::CACHE_CHEAP_SIZE );
139 }
140
141 public function getFeatures() {
143 }
144
145 protected function resolveContainerPath( $container, $relStoragePath ) {
146 // Check that container has a root directory
147 if ( isset( $this->containerPaths[$container] ) || isset( $this->basePath ) ) {
148 // Check for sensible relative paths (assume the base paths are OK)
149 if ( $this->isLegalRelPath( $relStoragePath ) ) {
150 return $relStoragePath;
151 }
152 }
153
154 return null; // invalid
155 }
156
163 protected function isLegalRelPath( $fsPath ) {
164 // Check for file names longer than 255 chars
165 if ( preg_match( '![^/]{256}!', $fsPath ) ) { // ext3/NTFS
166 return false;
167 }
168 if ( $this->os === 'Windows' ) { // NTFS
169 return !preg_match( '![:*?"<>|]!', $fsPath );
170 } else {
171 return true;
172 }
173 }
174
183 protected function containerFSRoot( $shortCont, $fullCont ) {
184 if ( isset( $this->containerPaths[$shortCont] ) ) {
185 return $this->containerPaths[$shortCont];
186 } elseif ( isset( $this->basePath ) ) {
187 return "{$this->basePath}/{$fullCont}";
188 }
189
190 return null; // no container base path defined
191 }
192
199 protected function resolveToFSPath( $storagePath ) {
200 [ $fullCont, $relPath ] = $this->resolveStoragePathReal( $storagePath );
201 if ( $relPath === null ) {
202 return null; // invalid
203 }
204 [ , $shortCont, ] = FileBackend::splitStoragePath( $storagePath );
205 $fsPath = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
206 if ( $relPath != '' ) {
207 $fsPath .= "/{$relPath}";
208 }
209
210 return $fsPath;
211 }
212
213 public function isPathUsableInternal( $storagePath ) {
214 $fsPath = $this->resolveToFSPath( $storagePath );
215 if ( $fsPath === null ) {
216 return false; // invalid
217 }
218
219 if ( $this->fileOwner !== null && $this->currentUser !== $this->fileOwner ) {
220 trigger_error( __METHOD__ . ": PHP process owner is not '{$this->fileOwner}'." );
221 return false;
222 }
223
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 );
231 }
232
233 return $usable;
234 }
235
236 protected function doCreateInternal( array $params ) {
237 $status = $this->newStatus();
238
239 $fsDstPath = $this->resolveToFSPath( $params['dst'] );
240 if ( $fsDstPath === null ) {
241 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
242
243 return $status;
244 }
245
246 if ( !empty( $params['async'] ) ) { // deferred
247 $tempFile = $this->newTempFileWithContent( $params );
248 if ( !$tempFile ) {
249 $status->fatal( 'backend-fail-create', $params['dst'] );
250
251 return $status;
252 }
253 $cmd = $this->makeCopyCommand( $tempFile->getPath(), $fsDstPath, false );
254 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
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 ); // command output
258 }
259 };
260 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
261 $tempFile->bind( $status->value );
262 } else { // immediate write
263 $created = false;
264 // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old
265 // inode are unaffected since it writes to a new inode, and (c) new threads reading
266 // the file will either totally see the old version or totally see the new version
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;
275 }
276 $hadError = $this->untrapWarnings();
277 if ( $hadError || !$created ) {
278 $status->fatal( 'backend-fail-create', $params['dst'] );
279
280 return $status;
281 }
282 $this->chmod( $fsDstPath );
283 }
284
285 return $status;
286 }
287
288 protected function doStoreInternal( array $params ) {
289 $status = $this->newStatus();
290
291 $fsSrcPath = $params['src']; // file system path
292 $fsDstPath = $this->resolveToFSPath( $params['dst'] );
293 if ( $fsDstPath === null ) {
294 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
295
296 return $status;
297 }
298
299 if ( $fsSrcPath === $fsDstPath ) {
300 $status->fatal( 'backend-fail-internal', $this->name );
301
302 return $status;
303 }
304
305 if ( !empty( $params['async'] ) ) { // deferred
306 $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath, false );
307 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
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 ); // command output
311 }
312 };
313 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
314 } else { // immediate write
315 $stored = false;
316 // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old
317 // inode are unaffected since it writes to a new inode, and (c) new threads reading
318 // the file will either totally see the old version or totally see the new version
319 $fsStagePath = $this->makeStagingPath( $fsDstPath );
321 $srcHandle = fopen( $fsSrcPath, 'rb' );
322 if ( $srcHandle ) {
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;
329 }
330 fclose( $srcHandle );
331 }
332 $hadError = $this->untrapWarnings();
333 if ( $hadError || !$stored ) {
334 $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
335
336 return $status;
337 }
338 $this->chmod( $fsDstPath );
339 }
340
341 return $status;
342 }
343
344 protected function doCopyInternal( array $params ) {
345 $status = $this->newStatus();
346
347 $fsSrcPath = $this->resolveToFSPath( $params['src'] );
348 if ( $fsSrcPath === null ) {
349 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
350
351 return $status;
352 }
353
354 $fsDstPath = $this->resolveToFSPath( $params['dst'] );
355 if ( $fsDstPath === null ) {
356 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
357
358 return $status;
359 }
360
361 if ( $fsSrcPath === $fsDstPath ) {
362 return $status; // no-op
363 }
364
365 $ignoreMissing = !empty( $params['ignoreMissingSource'] );
366
367 if ( !empty( $params['async'] ) ) { // deferred
368 $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing );
369 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
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 ); // command output
373 }
374 };
375 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
376 } else { // immediate write
377 $copied = false;
378 // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old
379 // inode are unaffected since it writes to a new inode, and (c) new threads reading
380 // the file will either totally see the old version or totally see the new version
381 $fsStagePath = $this->makeStagingPath( $fsDstPath );
383 $srcHandle = fopen( $fsSrcPath, 'rb' );
384 if ( $srcHandle ) {
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;
391 }
392 fclose( $srcHandle );
393 }
394 $hadError = $this->untrapWarnings();
395 if ( $hadError || ( !$copied && !$ignoreMissing ) ) {
396 $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
397
398 return $status;
399 }
400 if ( $copied ) {
401 $this->chmod( $fsDstPath );
402 }
403 }
404
405 return $status;
406 }
407
408 protected function doMoveInternal( array $params ) {
409 $status = $this->newStatus();
410
411 $fsSrcPath = $this->resolveToFSPath( $params['src'] );
412 if ( $fsSrcPath === null ) {
413 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
414
415 return $status;
416 }
417
418 $fsDstPath = $this->resolveToFSPath( $params['dst'] );
419 if ( $fsDstPath === null ) {
420 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
421
422 return $status;
423 }
424
425 if ( $fsSrcPath === $fsDstPath ) {
426 return $status; // no-op
427 }
428
429 $ignoreMissing = !empty( $params['ignoreMissingSource'] );
430
431 if ( !empty( $params['async'] ) ) { // deferred
432 $cmd = $this->makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing );
433 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
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 ); // command output
437 }
438 };
439 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
440 } else { // immediate write
441 // Use rename() here since (a) this clears xattrs, (b) any threads still reading the
442 // old inode are unaffected since it writes to a new inode, and (c) this is fast and
443 // atomic within a file system volume (as is normally the case)
445 $moved = rename( $fsSrcPath, $fsDstPath );
446 $hadError = $this->untrapWarnings();
447 if ( $hadError || ( !$moved && !$ignoreMissing ) ) {
448 $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
449
450 return $status;
451 }
452 }
453
454 return $status;
455 }
456
457 protected function doDeleteInternal( array $params ) {
458 $status = $this->newStatus();
459
460 $fsSrcPath = $this->resolveToFSPath( $params['src'] );
461 if ( $fsSrcPath === null ) {
462 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
463
464 return $status;
465 }
466
467 $ignoreMissing = !empty( $params['ignoreMissingSource'] );
468
469 if ( !empty( $params['async'] ) ) { // deferred
470 $cmd = $this->makeUnlinkCommand( $fsSrcPath, $ignoreMissing );
471 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
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 ); // command output
475 }
476 };
477 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
478 } else { // immediate write
480 $deleted = unlink( $fsSrcPath );
481 $hadError = $this->untrapWarnings();
482 if ( $hadError || ( !$deleted && !$ignoreMissing ) ) {
483 $status->fatal( 'backend-fail-delete', $params['src'] );
484
485 return $status;
486 }
487 }
488
489 return $status;
490 }
491
495 protected function doPrepareInternal( $fullCont, $dirRel, array $params ) {
496 $status = $this->newStatus();
497 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
498 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
499 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
500 // Create the directory and its parents as needed...
501 $created = false;
502 AtEase::suppressWarnings();
503 $alreadyExisted = is_dir( $fsDirectory ); // already there?
504 if ( !$alreadyExisted ) {
505 $created = mkdir( $fsDirectory, $this->dirMode, true );
506 if ( !$created ) {
507 $alreadyExisted = is_dir( $fsDirectory ); // another thread made it?
508 }
509 }
510 $isWritable = $created ?: is_writable( $fsDirectory ); // assume writable if created here
511 AtEase::restoreWarnings();
512 if ( !$alreadyExisted && !$created ) {
513 $this->logger->error( __METHOD__ . ": cannot create directory $fsDirectory" );
514 $status->fatal( 'directorycreateerror', $params['dir'] ); // fails on races
515 } elseif ( !$isWritable ) {
516 $this->logger->error( __METHOD__ . ": directory $fsDirectory is read-only" );
517 $status->fatal( 'directoryreadonlyerror', $params['dir'] );
518 }
519 // Respect any 'noAccess' or 'noListing' flags...
520 if ( $created ) {
521 $status->merge( $this->doSecureInternal( $fullCont, $dirRel, $params ) );
522 }
523
524 if ( $status->isGood() ) {
525 $this->usableDirCache->set( $fsDirectory, 1 );
526 }
527
528 return $status;
529 }
530
531 protected function doSecureInternal( $fullCont, $dirRel, array $params ) {
532 $status = $this->newStatus();
533 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
534 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
535 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
536 // Seed new directories with a blank index.html, to prevent crawling...
537 if ( !empty( $params['noListing'] ) && !is_file( "{$fsDirectory}/index.html" ) ) {
538 $this->trapWarnings();
539 $bytes = file_put_contents( "{$fsDirectory}/index.html", $this->indexHtmlPrivate() );
540 $this->untrapWarnings();
541 if ( $bytes === false ) {
542 $status->fatal( 'backend-fail-create', $params['dir'] . '/index.html' );
543 }
544 }
545 // Add a .htaccess file to the root of the container...
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" );
553 }
554 }
555
556 return $status;
557 }
558
559 protected function doPublishInternal( $fullCont, $dirRel, array $params ) {
560 $status = $this->newStatus();
561 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
562 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
563 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
564 // Unseed new directories with a blank index.html, to allow crawling...
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" ) ) { // reverse secure()
568 $status->fatal( 'backend-fail-delete', $params['dir'] . '/index.html' );
569 }
570 }
571 // Remove the .htaccess file from the root of the container...
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" ) ) { // reverse secure()
575 $storeDir = "mwstore://{$this->name}/{$shortCont}";
576 $status->fatal( 'backend-fail-delete', "{$storeDir}/.htaccess" );
577 }
578 }
579
580 return $status;
581 }
582
583 protected function doCleanInternal( $fullCont, $dirRel, array $params ) {
584 $status = $this->newStatus();
585 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
586 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
587 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
588
589 $this->rmdir( $fsDirectory );
590
591 return $status;
592 }
593
594 protected function doGetFileStat( array $params ) {
595 $fsSrcPath = $this->resolveToFSPath( $params['src'] );
596 if ( $fsSrcPath === null ) {
597 return self::RES_ERROR; // invalid storage path
598 }
599
600 $this->trapWarnings(); // don't trust 'false' if there were errors
601 $stat = is_file( $fsSrcPath ) ? stat( $fsSrcPath ) : false; // regular files only
602 $hadError = $this->untrapWarnings();
603
604 if ( is_array( $stat ) ) {
605 $ct = new ConvertibleTimestamp( $stat['mtime'] );
606
607 return [
608 'mtime' => $ct->getTimestamp( TS_MW ),
609 'size' => $stat['size']
610 ];
611 }
612
613 return $hadError ? self::RES_ERROR : self::RES_ABSENT;
614 }
615
616 protected function doClearCache( array $paths = null ) {
617 if ( is_array( $paths ) ) {
618 foreach ( $paths as $path ) {
619 $fsPath = $this->resolveToFSPath( $path );
620 if ( $fsPath !== null ) {
621 clearstatcache( true, $fsPath );
622 $this->usableDirCache->clear( $fsPath );
623 }
624 }
625 } else {
626 clearstatcache( true ); // clear the PHP file stat cache
627 $this->usableDirCache->clear();
628 }
629 }
630
631 protected function doDirectoryExists( $fullCont, $dirRel, array $params ) {
632 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
633 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
634 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
635
636 $this->trapWarnings(); // don't trust 'false' if there were errors
637 $exists = is_dir( $fsDirectory );
638 $hadError = $this->untrapWarnings();
639
640 return $hadError ? self::RES_ERROR : $exists;
641 }
642
650 public function getDirectoryListInternal( $fullCont, $dirRel, array $params ) {
651 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
652 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
653 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
654
655 $list = new FSFileBackendDirList( $fsDirectory, $params );
656 $error = $list->getLastError();
657 if ( $error !== null ) {
658 if ( $this->isFileNotFoundError( $error ) ) {
659 $this->logger->info( __METHOD__ . ": non-existant directory: '$fsDirectory'" );
660
661 return []; // nothing under this dir
662 } elseif ( is_dir( $fsDirectory ) ) {
663 $this->logger->warning( __METHOD__ . ": unreadable directory: '$fsDirectory'" );
664
665 return self::RES_ERROR; // bad permissions?
666 } else {
667 $this->logger->warning( __METHOD__ . ": unreachable directory: '$fsDirectory'" );
668
669 return self::RES_ERROR;
670 }
671 }
672
673 return $list;
674 }
675
683 public function getFileListInternal( $fullCont, $dirRel, array $params ) {
684 [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] );
685 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
686 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
687
688 $list = new FSFileBackendFileList( $fsDirectory, $params );
689 $error = $list->getLastError();
690 if ( $error !== null ) {
691 if ( $this->isFileNotFoundError( $error ) ) {
692 $this->logger->info( __METHOD__ . ": non-existent directory: '$fsDirectory'" );
693
694 return []; // nothing under this dir
695 } elseif ( is_dir( $fsDirectory ) ) {
696 $this->logger->warning( __METHOD__ .
697 ": unreadable directory: '$fsDirectory': $error" );
698
699 return self::RES_ERROR; // bad permissions?
700 } else {
701 $this->logger->warning( __METHOD__ .
702 ": unreachable directory: '$fsDirectory': $error" );
703
704 return self::RES_ERROR;
705 }
706 }
707
708 return $list;
709 }
710
711 protected function doGetLocalReferenceMulti( array $params ) {
712 $fsFiles = []; // (path => FSFile)
713
714 foreach ( $params['srcs'] as $src ) {
715 $source = $this->resolveToFSPath( $src );
716 if ( $source === null ) {
717 $fsFiles[$src] = self::RES_ERROR; // invalid path
718 continue;
719 }
720
721 $this->trapWarnings(); // don't trust 'false' if there were errors
722 $isFile = is_file( $source ); // regular files only
723 $hadError = $this->untrapWarnings();
724
725 if ( $isFile ) {
726 $fsFiles[$src] = new FSFile( $source );
727 } elseif ( $hadError ) {
728 $fsFiles[$src] = self::RES_ERROR;
729 } else {
730 $fsFiles[$src] = self::RES_ABSENT;
731 }
732 }
733
734 return $fsFiles;
735 }
736
737 protected function doGetLocalCopyMulti( array $params ) {
738 $tmpFiles = []; // (path => TempFSFile)
739
740 foreach ( $params['srcs'] as $src ) {
741 $source = $this->resolveToFSPath( $src );
742 if ( $source === null ) {
743 $tmpFiles[$src] = self::RES_ERROR; // invalid path
744 continue;
745 }
746 // Create a new temporary file with the same extension...
747 $ext = FileBackend::extensionFromPath( $src );
748 $tmpFile = $this->tmpFileFactory->newTempFSFile( 'localcopy_', $ext );
749 if ( !$tmpFile ) {
750 $tmpFiles[$src] = self::RES_ERROR;
751 continue;
752 }
753
754 $tmpPath = $tmpFile->getPath();
755 // Copy the source file over the temp file
756 $this->trapWarnings(); // don't trust 'false' if there were errors
757 $isFile = is_file( $source ); // regular files only
758 $copySuccess = $isFile ? copy( $source, $tmpPath ) : false;
759 $hadError = $this->untrapWarnings();
760
761 if ( $copySuccess ) {
762 $this->chmod( $tmpPath );
763 $tmpFiles[$src] = $tmpFile;
764 } elseif ( $hadError ) {
765 $tmpFiles[$src] = self::RES_ERROR; // copy failed
766 } else {
767 $tmpFiles[$src] = self::RES_ABSENT;
768 }
769 }
770
771 return $tmpFiles;
772 }
773
774 protected function directoriesAreVirtual() {
775 return false;
776 }
777
783 protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
784 $statuses = [];
785
786 $pipes = [];
787 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
788 $pipes[$index] = popen( $fileOpHandle->cmd, 'r' );
789 }
790
791 $errs = [];
792 foreach ( $pipes as $index => $pipe ) {
793 // Result will be empty on success in *NIX. On Windows,
794 // it may be something like " 1 file(s) [copied|moved].".
795 $errs[$index] = stream_get_contents( $pipe );
796 fclose( $pipe );
797 }
798
799 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
800 $status = $this->newStatus();
801 $function = $fileOpHandle->callback;
802 $function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd );
803 $statuses[$index] = $status;
804 }
805
806 return $statuses;
807 }
808
813 private function makeStagingPath( $fsPath ) {
814 $time = dechex( time() ); // make it easy to find old orphans
815 $hash = \Wikimedia\base_convert( md5( basename( $fsPath ) ), 16, 36, 25 );
816 $unique = \Wikimedia\base_convert( bin2hex( random_bytes( 16 ) ), 16, 36, 25 );
817
818 return dirname( $fsPath ) . "/.{$time}_{$hash}_{$unique}.tmpfsfile";
819 }
820
827 private function makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing ) {
828 // Use copy+rename since (a) this clears xattrs, (b) threads still reading the old
829 // inode are unaffected since it writes to a new inode, and (c) new threads reading
830 // the file will either totally see the old version or totally see the new version
831 $fsStagePath = $this->makeStagingPath( $fsDstPath );
832 $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsSrcPath ) );
833 $encStage = Shellbox::escape( $this->cleanPathSlashes( $fsStagePath ) );
834 $encDst = Shellbox::escape( $this->cleanPathSlashes( $fsDstPath ) );
835 if ( $this->os === 'Windows' ) {
836 // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/copy
837 // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/move
838 $cmdWrite = "COPY /B /Y $encSrc $encStage 2>&1 && MOVE /Y $encStage $encDst 2>&1";
839 $cmd = $ignoreMissing ? "IF EXIST $encSrc $cmdWrite" : $cmdWrite;
840 } else {
841 // https://manpages.debian.org/buster/coreutils/cp.1.en.html
842 // https://manpages.debian.org/buster/coreutils/mv.1.en.html
843 $cmdWrite = "cp $encSrc $encStage 2>&1 && mv $encStage $encDst 2>&1";
844 $cmd = $ignoreMissing ? "test -f $encSrc && $cmdWrite" : $cmdWrite;
845 // Clean up permissions on any newly created destination file
846 $octalPermissions = '0' . decoct( $this->fileMode );
847 if ( strlen( $octalPermissions ) == 4 ) {
848 $cmd .= " && chmod $octalPermissions $encDst 2>/dev/null";
849 }
850 }
851
852 return $cmd;
853 }
854
861 private function makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing = false ) {
862 // https://manpages.debian.org/buster/coreutils/mv.1.en.html
863 // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/move
864 $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsSrcPath ) );
865 $encDst = Shellbox::escape( $this->cleanPathSlashes( $fsDstPath ) );
866 if ( $this->os === 'Windows' ) {
867 $writeCmd = "MOVE /Y $encSrc $encDst 2>&1";
868 $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd;
869 } else {
870 $writeCmd = "mv -f $encSrc $encDst 2>&1";
871 $cmd = $ignoreMissing ? "test -f $encSrc && $writeCmd" : $writeCmd;
872 }
873
874 return $cmd;
875 }
876
882 private function makeUnlinkCommand( $fsPath, $ignoreMissing = false ) {
883 // https://manpages.debian.org/buster/coreutils/rm.1.en.html
884 // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/del
885 $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsPath ) );
886 if ( $this->os === 'Windows' ) {
887 $writeCmd = "DEL /Q $encSrc 2>&1";
888 $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd;
889 } else {
890 $cmd = $ignoreMissing ? "rm -f $encSrc 2>&1" : "rm $encSrc 2>&1";
891 }
892
893 return $cmd;
894 }
895
902 protected function chmod( $fsPath ) {
903 if ( $this->os === 'Windows' ) {
904 return true;
905 }
906
907 AtEase::suppressWarnings();
908 $ok = chmod( $fsPath, $this->fileMode );
909 AtEase::restoreWarnings();
910
911 return $ok;
912 }
913
920 protected function unlink( $fsPath ) {
921 AtEase::suppressWarnings();
922 $ok = unlink( $fsPath );
923 AtEase::restoreWarnings();
924 clearstatcache( true, $fsPath );
925
926 return $ok;
927 }
928
935 protected function rmdir( $fsDirectory ) {
936 AtEase::suppressWarnings();
937 $ok = rmdir( $fsDirectory ); // remove directory if empty
938 AtEase::restoreWarnings();
939 clearstatcache( true, $fsDirectory );
940
941 return $ok;
942 }
943
948 protected function newTempFileWithContent( array $params ) {
949 $tempFile = $this->tmpFileFactory->newTempFSFile( 'create_', 'tmp' );
950 if ( !$tempFile ) {
951 return null;
952 }
953
954 AtEase::suppressWarnings();
955 if ( file_put_contents( $tempFile->getPath(), $params['content'] ) === false ) {
956 $tempFile = null;
957 }
958 AtEase::restoreWarnings();
959
960 return $tempFile;
961 }
962
968 protected function indexHtmlPrivate() {
969 return '';
970 }
971
977 protected function htaccessPrivate() {
978 return "Require all denied\n";
979 }
980
987 protected function cleanPathSlashes( $fsPath ) {
988 return ( $this->os === 'Windows' ) ? strtr( $fsPath, '/', '\\' ) : $fsPath;
989 }
990
996 protected function trapWarnings( $regexIgnore = null ) {
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;
1002 }
1003 return true; // suppress from PHP handler
1004 }, E_WARNING );
1005 }
1006
1010 protected function trapWarningsIgnoringNotFound() {
1011 $this->trapWarnings( $this->getFileNotFoundRegex() );
1012 }
1013
1019 protected function untrapWarnings() {
1020 restore_error_handler();
1021
1022 return array_pop( $this->warningTrapStack );
1023 }
1024
1030 protected function getFileNotFoundRegex() {
1031 static $regex;
1032 if ( $regex === null ) {
1033 // "No such file or directory": string literal in spl_directory.c etc.
1034 $alternatives = [ ': No such file or directory' ];
1035 if ( $this->os === 'Windows' ) {
1036 // 2 = The system cannot find the file specified.
1037 // 3 = The system cannot find the path specified.
1038 $alternatives[] = ' \‍(code: [23]\‍)';
1039 }
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 ), '/' );
1044 }
1045 $regex = '/(' . implode( '|', $alternatives ) . ')$/';
1046 }
1047 return $regex;
1048 }
1049
1056 protected function isFileNotFoundError( $error ) {
1057 return (bool)preg_match( $this->getFileNotFoundRegex(), $error );
1058 }
1059}
1060
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.
Class for a file system (FS) based file backend.
doDirectoryExists( $fullCont, $dirRel, array $params)
isPathUsableInternal( $storagePath)
Check if a file can be created or changed at a given storage path in the backend.
doPrepareInternal( $fullCont, $dirRel, array $params)
FileBackendStore::doPrepare() to override StatusValue Good status without value for success,...
indexHtmlPrivate()
Return the text of an index.html file to hide directory listings.
doSecureInternal( $fullCont, $dirRel, array $params)
MapCacheLRU $usableDirCache
Cache for known prepared/usable directories.
string $fileOwner
Required OS username to own files.
htaccessPrivate()
Return the text of a .htaccess file to make a directory private.
getFeatures()
Get the a bitfield of extra features supported by the backend medium.
isLegalRelPath( $fsPath)
Check a relative file system path for validity.
resolveToFSPath( $storagePath)
Get the absolute file system path for a storage path.
string $currentUser
OS username running this script.
trapWarningsIgnoringNotFound()
Track E_WARNING errors but ignore any that correspond to ENOENT "No such file or directory".
unlink( $fsPath)
Unlink a file, suppressing the warnings.
getDirectoryListInternal( $fullCont, $dirRel, array $params)
int $dirMode
Directory permission mode.
doExecuteOpHandlesInternal(array $fileOpHandles)
containerFSRoot( $shortCont, $fullCont)
Given the short (unresolved) and full (resolved) name of a container, return the file system path of ...
doClearCache(array $paths=null)
Clears any additional stat caches for storage paths.
untrapWarnings()
Stop listening for E_WARNING errors and get whether any happened.
array< string, string > $containerPaths
Map of container names to root paths for custom container paths.
isFileNotFoundError( $error)
Determine whether a given error message is a file not found error.
trapWarnings( $regexIgnore=null)
Listen for E_WARNING errors and track whether any that happen.
doPublishInternal( $fullCont, $dirRel, array $params)
string null $basePath
Directory holding the container directories.
cleanPathSlashes( $fsPath)
Clean up directory separators for the given OS.
doCleanInternal( $fullCont, $dirRel, array $params)
string $os
Simpler version of PHP_OS_FAMILY.
int $fileMode
File permission mode.
rmdir( $fsDirectory)
Remove an empty directory, suppressing the warnings.
directoriesAreVirtual()
Is this a key/value store where directories are just virtual? Virtual directories exists in so much a...
resolveContainerPath( $container, $relStoragePath)
Resolve a relative storage path, checking if it's allowed by the backend.
chmod( $fsPath)
Chmod a file, suppressing the warnings.
getFileNotFoundRegex()
Get a regex matching file not found errors.
getFileListInternal( $fullCont, $dirRel, array $params)
Class representing a non-directory file on the file system.
Definition FSFile.php:34
This class is used to hold the location and do limited manipulation of files stored temporarily (this...
Base class for all backends using particular storage medium.
resolveStoragePathReal( $storagePath)
Like resolveStoragePath() except null values are returned if the container is sharded and the shard c...
static extensionFromPath( $path, $case='lowercase')
Get the final extension from a storage or FS path.
static splitStoragePath( $storagePath)
Split a storage path into a backend name, a container name, and a relative file path.
newStatus(... $args)
Yields the result of the status wrapper callback on either:
copy(array $params, array $opts=[])
Performs a single copy operation.
$source