MediaWiki REL1_39
FSFileBackend.php
Go to the documentation of this file.
1<?php
44use Shellbox\Shellbox;
45use Wikimedia\AtEase\AtEase;
46use Wikimedia\Timestamp\ConvertibleTimestamp;
47
66 protected $usableDirCache;
67
69 protected $basePath;
70
72 protected $containerPaths;
73
75 protected $dirMode;
77 protected $fileMode;
79 protected $fileOwner;
80
82 protected $os;
84 protected $currentUser;
85
87 private $warningTrapStack = [];
88
99 public function __construct( array $config ) {
100 parent::__construct( $config );
101
102 if ( PHP_OS_FAMILY === 'Windows' ) {
103 $this->os = 'Windows';
104 } elseif ( PHP_OS_FAMILY === 'BSD' || PHP_OS_FAMILY === 'Darwin' ) {
105 $this->os = 'BSD';
106 } else {
107 $this->os = 'Linux';
108 }
109 // Remove any possible trailing slash from directories
110 if ( isset( $config['basePath'] ) ) {
111 $this->basePath = rtrim( $config['basePath'], '/' ); // remove trailing slash
112 } else {
113 $this->basePath = null; // none; containers must have explicit paths
114 }
115
116 $this->containerPaths = [];
117 foreach ( ( $config['containerPaths'] ?? [] ) as $container => $fsPath ) {
118 $this->containerPaths[$container] = rtrim( $fsPath, '/' ); // remove trailing slash
119 }
120
121 $this->fileMode = $config['fileMode'] ?? 0644;
122 $this->dirMode = $config['directoryMode'] ?? 0777;
123 if ( isset( $config['fileOwner'] ) && function_exists( 'posix_getuid' ) ) {
124 $this->fileOwner = $config['fileOwner'];
125 // Cache this, assuming it doesn't change
126 $this->currentUser = posix_getpwuid( posix_getuid() )['name'];
127 }
128
129 $this->usableDirCache = new MapCacheLRU( self::CACHE_CHEAP_SIZE );
130 }
131
132 public function getFeatures() {
134 }
135
136 protected function resolveContainerPath( $container, $relStoragePath ) {
137 // Check that container has a root directory
138 if ( isset( $this->containerPaths[$container] ) || isset( $this->basePath ) ) {
139 // Check for sensible relative paths (assume the base paths are OK)
140 if ( $this->isLegalRelPath( $relStoragePath ) ) {
141 return $relStoragePath;
142 }
143 }
144
145 return null; // invalid
146 }
147
154 protected function isLegalRelPath( $fsPath ) {
155 // Check for file names longer than 255 chars
156 if ( preg_match( '![^/]{256}!', $fsPath ) ) { // ext3/NTFS
157 return false;
158 }
159 if ( $this->os === 'Windows' ) { // NTFS
160 return !preg_match( '![:*?"<>|]!', $fsPath );
161 } else {
162 return true;
163 }
164 }
165
174 protected function containerFSRoot( $shortCont, $fullCont ) {
175 if ( isset( $this->containerPaths[$shortCont] ) ) {
176 return $this->containerPaths[$shortCont];
177 } elseif ( isset( $this->basePath ) ) {
178 return "{$this->basePath}/{$fullCont}";
179 }
180
181 return null; // no container base path defined
182 }
183
190 protected function resolveToFSPath( $storagePath ) {
191 list( $fullCont, $relPath ) = $this->resolveStoragePathReal( $storagePath );
192 if ( $relPath === null ) {
193 return null; // invalid
194 }
195 list( , $shortCont, ) = FileBackend::splitStoragePath( $storagePath );
196 $fsPath = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
197 if ( $relPath != '' ) {
198 $fsPath .= "/{$relPath}";
199 }
200
201 return $fsPath;
202 }
203
204 public function isPathUsableInternal( $storagePath ) {
205 $fsPath = $this->resolveToFSPath( $storagePath );
206 if ( $fsPath === null ) {
207 return false; // invalid
208 }
209
210 if ( $this->fileOwner !== null && $this->currentUser !== $this->fileOwner ) {
211 trigger_error( __METHOD__ . ": PHP process owner is not '{$this->fileOwner}'." );
212 return false;
213 }
214
215 $fsDirectory = dirname( $fsPath );
216 $usable = $this->usableDirCache->get( $fsDirectory, MapCacheLRU::TTL_PROC_SHORT );
217 if ( $usable === null ) {
218 AtEase::suppressWarnings();
219 $usable = is_dir( $fsDirectory ) && is_writable( $fsDirectory );
220 AtEase::restoreWarnings();
221 $this->usableDirCache->set( $fsDirectory, $usable ? 1 : 0 );
222 }
223
224 return $usable;
225 }
226
227 protected function doCreateInternal( array $params ) {
228 $status = $this->newStatus();
229
230 $fsDstPath = $this->resolveToFSPath( $params['dst'] );
231 if ( $fsDstPath === null ) {
232 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
233
234 return $status;
235 }
236
237 if ( !empty( $params['async'] ) ) { // deferred
238 $tempFile = $this->newTempFileWithContent( $params );
239 if ( !$tempFile ) {
240 $status->fatal( 'backend-fail-create', $params['dst'] );
241
242 return $status;
243 }
244 $cmd = $this->makeCopyCommand( $tempFile->getPath(), $fsDstPath, false );
245 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
246 if ( $errors !== '' && !( $this->os === 'Windows' && $errors[0] === " " ) ) {
247 $status->fatal( 'backend-fail-create', $params['dst'] );
248 trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
249 }
250 };
251 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
252 $tempFile->bind( $status->value );
253 } else { // immediate write
254 $created = false;
255 // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old
256 // inode are unaffected since it writes to a new inode, and (c) new threads reading
257 // the file will either totally see the old version or totally see the new version
258 $fsStagePath = $this->makeStagingPath( $fsDstPath );
260 $stageHandle = fopen( $fsStagePath, 'xb' );
261 if ( $stageHandle ) {
262 $bytes = fwrite( $stageHandle, $params['content'] );
263 $created = ( $bytes === strlen( $params['content'] ) );
264 fclose( $stageHandle );
265 $created = $created ? rename( $fsStagePath, $fsDstPath ) : false;
266 }
267 $hadError = $this->untrapWarnings();
268 if ( $hadError || !$created ) {
269 $status->fatal( 'backend-fail-create', $params['dst'] );
270
271 return $status;
272 }
273 $this->chmod( $fsDstPath );
274 }
275
276 return $status;
277 }
278
279 protected function doStoreInternal( array $params ) {
280 $status = $this->newStatus();
281
282 $fsSrcPath = $params['src']; // file system path
283 $fsDstPath = $this->resolveToFSPath( $params['dst'] );
284 if ( $fsDstPath === null ) {
285 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
286
287 return $status;
288 }
289
290 if ( $fsSrcPath === $fsDstPath ) {
291 $status->fatal( 'backend-fail-internal', $this->name );
292
293 return $status;
294 }
295
296 if ( !empty( $params['async'] ) ) { // deferred
297 $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath, false );
298 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
299 if ( $errors !== '' && !( $this->os === 'Windows' && $errors[0] === " " ) ) {
300 $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
301 trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
302 }
303 };
304 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
305 } else { // immediate write
306 $stored = false;
307 // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old
308 // inode are unaffected since it writes to a new inode, and (c) new threads reading
309 // the file will either totally see the old version or totally see the new version
310 $fsStagePath = $this->makeStagingPath( $fsDstPath );
312 $srcHandle = fopen( $fsSrcPath, 'rb' );
313 if ( $srcHandle ) {
314 $stageHandle = fopen( $fsStagePath, 'xb' );
315 if ( $stageHandle ) {
316 $bytes = stream_copy_to_stream( $srcHandle, $stageHandle );
317 $stored = ( $bytes !== false && $bytes === fstat( $srcHandle )['size'] );
318 fclose( $stageHandle );
319 $stored = $stored ? rename( $fsStagePath, $fsDstPath ) : false;
320 }
321 fclose( $srcHandle );
322 }
323 $hadError = $this->untrapWarnings();
324 if ( $hadError || !$stored ) {
325 $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
326
327 return $status;
328 }
329 $this->chmod( $fsDstPath );
330 }
331
332 return $status;
333 }
334
335 protected function doCopyInternal( array $params ) {
336 $status = $this->newStatus();
337
338 $fsSrcPath = $this->resolveToFSPath( $params['src'] );
339 if ( $fsSrcPath === null ) {
340 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
341
342 return $status;
343 }
344
345 $fsDstPath = $this->resolveToFSPath( $params['dst'] );
346 if ( $fsDstPath === null ) {
347 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
348
349 return $status;
350 }
351
352 if ( $fsSrcPath === $fsDstPath ) {
353 return $status; // no-op
354 }
355
356 $ignoreMissing = !empty( $params['ignoreMissingSource'] );
357
358 if ( !empty( $params['async'] ) ) { // deferred
359 $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing );
360 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
361 if ( $errors !== '' && !( $this->os === 'Windows' && $errors[0] === " " ) ) {
362 $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
363 trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
364 }
365 };
366 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
367 } else { // immediate write
368 $copied = false;
369 // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old
370 // inode are unaffected since it writes to a new inode, and (c) new threads reading
371 // the file will either totally see the old version or totally see the new version
372 $fsStagePath = $this->makeStagingPath( $fsDstPath );
374 $srcHandle = fopen( $fsSrcPath, 'rb' );
375 if ( $srcHandle ) {
376 $stageHandle = fopen( $fsStagePath, 'xb' );
377 if ( $stageHandle ) {
378 $bytes = stream_copy_to_stream( $srcHandle, $stageHandle );
379 $copied = ( $bytes !== false && $bytes === fstat( $srcHandle )['size'] );
380 fclose( $stageHandle );
381 $copied = $copied ? rename( $fsStagePath, $fsDstPath ) : false;
382 }
383 fclose( $srcHandle );
384 }
385 $hadError = $this->untrapWarnings();
386 if ( $hadError || ( !$copied && !$ignoreMissing ) ) {
387 $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
388
389 return $status;
390 }
391 if ( $copied ) {
392 $this->chmod( $fsDstPath );
393 }
394 }
395
396 return $status;
397 }
398
399 protected function doMoveInternal( array $params ) {
400 $status = $this->newStatus();
401
402 $fsSrcPath = $this->resolveToFSPath( $params['src'] );
403 if ( $fsSrcPath === null ) {
404 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
405
406 return $status;
407 }
408
409 $fsDstPath = $this->resolveToFSPath( $params['dst'] );
410 if ( $fsDstPath === null ) {
411 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
412
413 return $status;
414 }
415
416 if ( $fsSrcPath === $fsDstPath ) {
417 return $status; // no-op
418 }
419
420 $ignoreMissing = !empty( $params['ignoreMissingSource'] );
421
422 if ( !empty( $params['async'] ) ) { // deferred
423 $cmd = $this->makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing );
424 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
425 if ( $errors !== '' && !( $this->os === 'Windows' && $errors[0] === " " ) ) {
426 $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
427 trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
428 }
429 };
430 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
431 } else { // immediate write
432 // Use rename() here since (a) this clears xattrs, (b) any threads still reading the
433 // old inode are unaffected since it writes to a new inode, and (c) this is fast and
434 // atomic within a file system volume (as is normally the case)
436 $moved = rename( $fsSrcPath, $fsDstPath );
437 $hadError = $this->untrapWarnings();
438 if ( $hadError || ( !$moved && !$ignoreMissing ) ) {
439 $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
440
441 return $status;
442 }
443 }
444
445 return $status;
446 }
447
448 protected function doDeleteInternal( array $params ) {
449 $status = $this->newStatus();
450
451 $fsSrcPath = $this->resolveToFSPath( $params['src'] );
452 if ( $fsSrcPath === null ) {
453 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
454
455 return $status;
456 }
457
458 $ignoreMissing = !empty( $params['ignoreMissingSource'] );
459
460 if ( !empty( $params['async'] ) ) { // deferred
461 $cmd = $this->makeUnlinkCommand( $fsSrcPath, $ignoreMissing );
462 $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
463 if ( $errors !== '' && !( $this->os === 'Windows' && $errors[0] === " " ) ) {
464 $status->fatal( 'backend-fail-delete', $params['src'] );
465 trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
466 }
467 };
468 $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
469 } else { // immediate write
471 $deleted = unlink( $fsSrcPath );
472 $hadError = $this->untrapWarnings();
473 if ( $hadError || ( !$deleted && !$ignoreMissing ) ) {
474 $status->fatal( 'backend-fail-delete', $params['src'] );
475
476 return $status;
477 }
478 }
479
480 return $status;
481 }
482
486 protected function doPrepareInternal( $fullCont, $dirRel, array $params ) {
487 $status = $this->newStatus();
488 list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
489 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
490 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
491 // Create the directory and its parents as needed...
492 $created = false;
493 AtEase::suppressWarnings();
494 $alreadyExisted = is_dir( $fsDirectory ); // already there?
495 if ( !$alreadyExisted ) {
496 $created = mkdir( $fsDirectory, $this->dirMode, true );
497 if ( !$created ) {
498 $alreadyExisted = is_dir( $fsDirectory ); // another thread made it?
499 }
500 }
501 $isWritable = $created ?: is_writable( $fsDirectory ); // assume writable if created here
502 AtEase::restoreWarnings();
503 if ( !$alreadyExisted && !$created ) {
504 $this->logger->error( __METHOD__ . ": cannot create directory $fsDirectory" );
505 $status->fatal( 'directorycreateerror', $params['dir'] ); // fails on races
506 } elseif ( !$isWritable ) {
507 $this->logger->error( __METHOD__ . ": directory $fsDirectory is read-only" );
508 $status->fatal( 'directoryreadonlyerror', $params['dir'] );
509 }
510 // Respect any 'noAccess' or 'noListing' flags...
511 if ( $created ) {
512 $status->merge( $this->doSecureInternal( $fullCont, $dirRel, $params ) );
513 }
514
515 if ( $status->isGood() ) {
516 $this->usableDirCache->set( $fsDirectory, 1 );
517 }
518
519 return $status;
520 }
521
522 protected function doSecureInternal( $fullCont, $dirRel, array $params ) {
523 $status = $this->newStatus();
524 list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
525 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
526 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
527 // Seed new directories with a blank index.html, to prevent crawling...
528 if ( !empty( $params['noListing'] ) && !is_file( "{$fsDirectory}/index.html" ) ) {
529 $this->trapWarnings();
530 $bytes = file_put_contents( "{$fsDirectory}/index.html", $this->indexHtmlPrivate() );
531 $this->untrapWarnings();
532 if ( $bytes === false ) {
533 $status->fatal( 'backend-fail-create', $params['dir'] . '/index.html' );
534 }
535 }
536 // Add a .htaccess file to the root of the container...
537 if ( !empty( $params['noAccess'] ) && !is_file( "{$contRoot}/.htaccess" ) ) {
538 AtEase::suppressWarnings();
539 $bytes = file_put_contents( "{$contRoot}/.htaccess", $this->htaccessPrivate() );
540 AtEase::restoreWarnings();
541 if ( $bytes === false ) {
542 $storeDir = "mwstore://{$this->name}/{$shortCont}";
543 $status->fatal( 'backend-fail-create', "{$storeDir}/.htaccess" );
544 }
545 }
546
547 return $status;
548 }
549
550 protected function doPublishInternal( $fullCont, $dirRel, array $params ) {
551 $status = $this->newStatus();
552 list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
553 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
554 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
555 // Unseed new directories with a blank index.html, to allow crawling...
556 if ( !empty( $params['listing'] ) && is_file( "{$fsDirectory}/index.html" ) ) {
557 $exists = ( file_get_contents( "{$fsDirectory}/index.html" ) === $this->indexHtmlPrivate() );
558 if ( $exists && !$this->unlink( "{$fsDirectory}/index.html" ) ) { // reverse secure()
559 $status->fatal( 'backend-fail-delete', $params['dir'] . '/index.html' );
560 }
561 }
562 // Remove the .htaccess file from the root of the container...
563 if ( !empty( $params['access'] ) && is_file( "{$contRoot}/.htaccess" ) ) {
564 $exists = ( file_get_contents( "{$contRoot}/.htaccess" ) === $this->htaccessPrivate() );
565 if ( $exists && !$this->unlink( "{$contRoot}/.htaccess" ) ) { // reverse secure()
566 $storeDir = "mwstore://{$this->name}/{$shortCont}";
567 $status->fatal( 'backend-fail-delete', "{$storeDir}/.htaccess" );
568 }
569 }
570
571 return $status;
572 }
573
574 protected function doCleanInternal( $fullCont, $dirRel, array $params ) {
575 $status = $this->newStatus();
576 list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
577 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
578 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
579
580 $this->rmdir( $fsDirectory );
581
582 return $status;
583 }
584
585 protected function doGetFileStat( array $params ) {
586 $fsSrcPath = $this->resolveToFSPath( $params['src'] );
587 if ( $fsSrcPath === null ) {
588 return self::$RES_ERROR; // invalid storage path
589 }
590
591 $this->trapWarnings(); // don't trust 'false' if there were errors
592 $stat = is_file( $fsSrcPath ) ? stat( $fsSrcPath ) : false; // regular files only
593 $hadError = $this->untrapWarnings();
594
595 if ( is_array( $stat ) ) {
596 $ct = new ConvertibleTimestamp( $stat['mtime'] );
597
598 return [
599 'mtime' => $ct->getTimestamp( TS_MW ),
600 'size' => $stat['size']
601 ];
602 }
603
604 return $hadError ? self::$RES_ERROR : self::$RES_ABSENT;
605 }
606
607 protected function doClearCache( array $paths = null ) {
608 if ( is_array( $paths ) ) {
609 foreach ( $paths as $path ) {
610 $fsPath = $this->resolveToFSPath( $path );
611 if ( $fsPath !== null ) {
612 clearstatcache( true, $fsPath );
613 $this->usableDirCache->clear( $fsPath );
614 }
615 }
616 } else {
617 clearstatcache( true ); // clear the PHP file stat cache
618 $this->usableDirCache->clear();
619 }
620 }
621
622 protected function doDirectoryExists( $fullCont, $dirRel, array $params ) {
623 list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
624 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
625 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
626
627 $this->trapWarnings(); // don't trust 'false' if there were errors
628 $exists = is_dir( $fsDirectory );
629 $hadError = $this->untrapWarnings();
630
631 return $hadError ? self::$RES_ERROR : $exists;
632 }
633
641 public function getDirectoryListInternal( $fullCont, $dirRel, array $params ) {
642 list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
643 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
644 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
645
646 $list = new FSFileBackendDirList( $fsDirectory, $params );
647 $error = $list->getLastError();
648 if ( $error !== null ) {
649 if ( $this->isFileNotFoundError( $error ) ) {
650 $this->logger->info( __METHOD__ . ": non-existant directory: '$fsDirectory'" );
651
652 return []; // nothing under this dir
653 } elseif ( is_dir( $fsDirectory ) ) {
654 $this->logger->warning( __METHOD__ . ": unreadable directory: '$fsDirectory'" );
655
656 return self::$RES_ERROR; // bad permissions?
657 } else {
658 $this->logger->warning( __METHOD__ . ": unreachable directory: '$fsDirectory'" );
659
660 return self::$RES_ERROR;
661 }
662 }
663
664 return $list;
665 }
666
674 public function getFileListInternal( $fullCont, $dirRel, array $params ) {
675 list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
676 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
677 $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
678
679 $list = new FSFileBackendFileList( $fsDirectory, $params );
680 $error = $list->getLastError();
681 if ( $error !== null ) {
682 if ( $this->isFileNotFoundError( $error ) ) {
683 $this->logger->info( __METHOD__ . ": non-existent directory: '$fsDirectory'" );
684
685 return []; // nothing under this dir
686 } elseif ( is_dir( $fsDirectory ) ) {
687 $this->logger->warning( __METHOD__ .
688 ": unreadable directory: '$fsDirectory': $error" );
689
690 return self::$RES_ERROR; // bad permissions?
691 } else {
692 $this->logger->warning( __METHOD__ .
693 ": unreachable directory: '$fsDirectory': $error" );
694
695 return self::$RES_ERROR;
696 }
697 }
698
699 return $list;
700 }
701
702 protected function doGetLocalReferenceMulti( array $params ) {
703 $fsFiles = []; // (path => FSFile)
704
705 foreach ( $params['srcs'] as $src ) {
706 $source = $this->resolveToFSPath( $src );
707 if ( $source === null ) {
708 $fsFiles[$src] = self::$RES_ERROR; // invalid path
709 continue;
710 }
711
712 $this->trapWarnings(); // don't trust 'false' if there were errors
713 $isFile = is_file( $source ); // regular files only
714 $hadError = $this->untrapWarnings();
715
716 if ( $isFile ) {
717 $fsFiles[$src] = new FSFile( $source );
718 } elseif ( $hadError ) {
719 $fsFiles[$src] = self::$RES_ERROR;
720 } else {
721 $fsFiles[$src] = self::$RES_ABSENT;
722 }
723 }
724
725 return $fsFiles;
726 }
727
728 protected function doGetLocalCopyMulti( array $params ) {
729 $tmpFiles = []; // (path => TempFSFile)
730
731 foreach ( $params['srcs'] as $src ) {
732 $source = $this->resolveToFSPath( $src );
733 if ( $source === null ) {
734 $tmpFiles[$src] = self::$RES_ERROR; // invalid path
735 continue;
736 }
737 // Create a new temporary file with the same extension...
739 $tmpFile = $this->tmpFileFactory->newTempFSFile( 'localcopy_', $ext );
740 if ( !$tmpFile ) {
741 $tmpFiles[$src] = self::$RES_ERROR;
742 continue;
743 }
744
745 $tmpPath = $tmpFile->getPath();
746 // Copy the source file over the temp file
747 $this->trapWarnings(); // don't trust 'false' if there were errors
748 $isFile = is_file( $source ); // regular files only
749 $copySuccess = $isFile ? copy( $source, $tmpPath ) : false;
750 $hadError = $this->untrapWarnings();
751
752 if ( $copySuccess ) {
753 $this->chmod( $tmpPath );
754 $tmpFiles[$src] = $tmpFile;
755 } elseif ( $hadError ) {
756 $tmpFiles[$src] = self::$RES_ERROR; // copy failed
757 } else {
758 $tmpFiles[$src] = self::$RES_ABSENT;
759 }
760 }
761
762 return $tmpFiles;
763 }
764
765 protected function directoriesAreVirtual() {
766 return false;
767 }
768
774 protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
775 $statuses = [];
776
777 $pipes = [];
778 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
779 $pipes[$index] = popen( $fileOpHandle->cmd, 'r' );
780 }
781
782 $errs = [];
783 foreach ( $pipes as $index => $pipe ) {
784 // Result will be empty on success in *NIX. On Windows,
785 // it may be something like " 1 file(s) [copied|moved].".
786 $errs[$index] = stream_get_contents( $pipe );
787 fclose( $pipe );
788 }
789
790 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
791 $status = $this->newStatus();
792 $function = $fileOpHandle->callback;
793 $function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd );
794 $statuses[$index] = $status;
795 }
796
797 return $statuses;
798 }
799
804 private function makeStagingPath( $fsPath ) {
805 $time = dechex( time() ); // make it easy to find old orphans
806 $hash = \Wikimedia\base_convert( md5( basename( $fsPath ) ), 16, 36, 25 );
807 $unique = \Wikimedia\base_convert( bin2hex( random_bytes( 16 ) ), 16, 36, 25 );
808
809 return dirname( $fsPath ) . "/.{$time}_{$hash}_{$unique}.tmpfsfile";
810 }
811
818 private function makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing ) {
819 // Use copy+rename since (a) this clears xattrs, (b) threads still reading the old
820 // inode are unaffected since it writes to a new inode, and (c) new threads reading
821 // the file will either totally see the old version or totally see the new version
822 $fsStagePath = $this->makeStagingPath( $fsDstPath );
823 $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsSrcPath ) );
824 $encStage = Shellbox::escape( $this->cleanPathSlashes( $fsStagePath ) );
825 $encDst = Shellbox::escape( $this->cleanPathSlashes( $fsDstPath ) );
826 if ( $this->os === 'Windows' ) {
827 // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/copy
828 // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/move
829 $cmdWrite = "COPY /B /Y $encSrc $encStage 2>&1 && MOVE /Y $encStage $encDst 2>&1";
830 $cmd = $ignoreMissing ? "IF EXIST $encSrc $cmdWrite" : $cmdWrite;
831 } else {
832 // https://manpages.debian.org/buster/coreutils/cp.1.en.html
833 // https://manpages.debian.org/buster/coreutils/mv.1.en.html
834 $cmdWrite = "cp $encSrc $encStage 2>&1 && mv $encStage $encDst 2>&1";
835 $cmd = $ignoreMissing ? "test -f $encSrc && $cmdWrite" : $cmdWrite;
836 // Clean up permissions on any newly created destination file
837 $octalPermissions = '0' . decoct( $this->fileMode );
838 if ( strlen( $octalPermissions ) == 4 ) {
839 $cmd .= " && chmod $octalPermissions $encDst 2>/dev/null";
840 }
841 }
842
843 return $cmd;
844 }
845
852 private function makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing = false ) {
853 // https://manpages.debian.org/buster/coreutils/mv.1.en.html
854 // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/move
855 $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsSrcPath ) );
856 $encDst = Shellbox::escape( $this->cleanPathSlashes( $fsDstPath ) );
857 if ( $this->os === 'Windows' ) {
858 $writeCmd = "MOVE /Y $encSrc $encDst 2>&1";
859 $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd;
860 } else {
861 $writeCmd = "mv -f $encSrc $encDst 2>&1";
862 $cmd = $ignoreMissing ? "test -f $encSrc && $writeCmd" : $writeCmd;
863 }
864
865 return $cmd;
866 }
867
873 private function makeUnlinkCommand( $fsPath, $ignoreMissing = false ) {
874 // https://manpages.debian.org/buster/coreutils/rm.1.en.html
875 // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/del
876 $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsPath ) );
877 if ( $this->os === 'Windows' ) {
878 $writeCmd = "DEL /Q $encSrc 2>&1";
879 $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd;
880 } else {
881 $cmd = $ignoreMissing ? "rm -f $encSrc 2>&1" : "rm $encSrc 2>&1";
882 }
883
884 return $cmd;
885 }
886
893 protected function chmod( $fsPath ) {
894 if ( $this->os === 'Windows' ) {
895 return true;
896 }
897
898 AtEase::suppressWarnings();
899 $ok = chmod( $fsPath, $this->fileMode );
900 AtEase::restoreWarnings();
901
902 return $ok;
903 }
904
911 protected function unlink( $fsPath ) {
912 AtEase::suppressWarnings();
913 $ok = unlink( $fsPath );
914 AtEase::restoreWarnings();
915 clearstatcache( true, $fsPath );
916
917 return $ok;
918 }
919
926 protected function rmdir( $fsDirectory ) {
927 AtEase::suppressWarnings();
928 $ok = rmdir( $fsDirectory ); // remove directory if empty
929 AtEase::restoreWarnings();
930 clearstatcache( true, $fsDirectory );
931
932 return $ok;
933 }
934
939 protected function newTempFileWithContent( array $params ) {
940 $tempFile = $this->tmpFileFactory->newTempFSFile( 'create_', 'tmp' );
941 if ( !$tempFile ) {
942 return null;
943 }
944
945 AtEase::suppressWarnings();
946 if ( file_put_contents( $tempFile->getPath(), $params['content'] ) === false ) {
947 $tempFile = null;
948 }
949 AtEase::restoreWarnings();
950
951 return $tempFile;
952 }
953
959 protected function indexHtmlPrivate() {
960 return '';
961 }
962
968 protected function htaccessPrivate() {
969 return "Require all denied\n";
970 }
971
978 protected function cleanPathSlashes( $fsPath ) {
979 return ( $this->os === 'Windows' ) ? strtr( $fsPath, '/', '\\' ) : $fsPath;
980 }
981
987 protected function trapWarnings( $regexIgnore = null ) {
988 $this->warningTrapStack[] = false;
989 set_error_handler( function ( $errno, $errstr ) use ( $regexIgnore ) {
990 if ( $regexIgnore === null || !preg_match( $regexIgnore, $errstr ) ) {
991 $this->logger->error( $errstr );
992 $this->warningTrapStack[count( $this->warningTrapStack ) - 1] = true;
993 }
994 return true; // suppress from PHP handler
995 }, E_WARNING );
996 }
997
1001 protected function trapWarningsIgnoringNotFound() {
1002 $this->trapWarnings( $this->getFileNotFoundRegex() );
1003 }
1004
1010 protected function untrapWarnings() {
1011 restore_error_handler();
1012
1013 return array_pop( $this->warningTrapStack );
1014 }
1015
1021 protected function getFileNotFoundRegex() {
1022 static $regex;
1023 if ( $regex === null ) {
1024 // "No such file or directory": string literal in spl_directory.c etc.
1025 $alternatives = [ ': No such file or directory' ];
1026 if ( $this->os === 'Windows' ) {
1027 // 2 = The system cannot find the file specified.
1028 // 3 = The system cannot find the path specified.
1029 $alternatives[] = ' \‍(code: [23]\‍)';
1030 }
1031 if ( function_exists( 'pcntl_strerror' ) ) {
1032 $alternatives[] = preg_quote( ': ' . pcntl_strerror( 2 ), '/' );
1033 } elseif ( function_exists( 'socket_strerror' ) && defined( 'SOCKET_ENOENT' ) ) {
1034 $alternatives[] = preg_quote( ': ' . socket_strerror( SOCKET_ENOENT ), '/' );
1035 }
1036 $regex = '/(' . implode( '|', $alternatives ) . ')$/';
1037 }
1038 return $regex;
1039 }
1040
1047 protected function isFileNotFoundError( $error ) {
1048 return (bool)preg_match( $this->getFileNotFoundRegex(), $error );
1049 }
1050}
Class for a file system (FS) based file backend.
doCopyInternal(array $params)
doDirectoryExists( $fullCont, $dirRel, array $params)
newTempFileWithContent(array $params)
__construct(array $config)
getDirectoryListInternal( $fullCont, $dirRel, array $params)
doDeleteInternal(array $params)
string $os
Simpler version of PHP_OS_FAMILY.
indexHtmlPrivate()
Return the text of an index.html file to hide directory listings.
doExecuteOpHandlesInternal(array $fileOpHandles)
getFileNotFoundRegex()
Get a regex matching file not found errors.
resolveToFSPath( $storagePath)
Get the absolute file system path for a storage path.
isFileNotFoundError( $error)
Determine whether a given error message is a file not found error.
doGetFileStat(array $params)
rmdir( $fsDirectory)
Remove an empty directory, suppressing the warnings.
htaccessPrivate()
Return the text of a .htaccess file to make a directory private.
doGetLocalCopyMulti(array $params)
MapCacheLRU $usableDirCache
Cache for known prepared/usable directories.
untrapWarnings()
Stop listening for E_WARNING errors and get whether any happened.
string $fileOwner
Required OS username to own files.
doPublishInternal( $fullCont, $dirRel, array $params)
resolveContainerPath( $container, $relStoragePath)
Resolve a relative storage path, checking if it's allowed by the backend.
isLegalRelPath( $fsPath)
Check a relative file system path for validity.
array $containerPaths
Map of container names to root paths for custom container paths.
doCleanInternal( $fullCont, $dirRel, array $params)
chmod( $fsPath)
Chmod a file, suppressing the warnings.
directoriesAreVirtual()
Is this a key/value store where directories are just virtual? Virtual directories exists in so much a...
doSecureInternal( $fullCont, $dirRel, array $params)
doMoveInternal(array $params)
doClearCache(array $paths=null)
Clears any additional stat caches for storage paths.
trapWarnings( $regexIgnore=null)
Listen for E_WARNING errors and track whether any that happen.
doStoreInternal(array $params)
doPrepareInternal( $fullCont, $dirRel, array $params)
FileBackendStore::doPrepare() to override StatusValue Good status without value for success,...
getFeatures()
Get the a bitfield of extra features supported by the backend medium.
containerFSRoot( $shortCont, $fullCont)
Given the short (unresolved) and full (resolved) name of a container, return the file system path of ...
unlink( $fsPath)
Unlink a file, suppressing the warnings.
int $fileMode
File permission mode.
string null $basePath
Directory holding the container directories.
isPathUsableInternal( $storagePath)
Check if a file can be created or changed at a given storage path in the backend.
trapWarningsIgnoringNotFound()
Track E_WARNING errors but ignore any that correspond to ENOENT "No such file or directory".
cleanPathSlashes( $fsPath)
Clean up directory separators for the given OS.
doCreateInternal(array $params)
doGetLocalReferenceMulti(array $params)
string $currentUser
OS username running this script.
int $dirMode
Directory permission mode.
getFileListInternal( $fullCont, $dirRel, array $params)
Class representing a non-directory file on the file system.
Definition FSFile.php:32
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 false $RES_ABSENT
Idiom for "no result due to missing file" (since 1.34)
static null $RES_ERROR
Idiom for "no result due to I/O errors" (since 1.34)
const ATTR_UNICODE_PATHS
static splitStoragePath( $storagePath)
Split a storage path into a backend name, a container name, and a relative file path.
static extensionFromPath( $path, $case='lowercase')
Get the final extension from a storage or FS path.
newStatus(... $args)
Yields the result of the status wrapper callback on either:
copy(array $params, array $opts=[])
Performs a single copy operation.
Handles a simple LRU key/value map with a maximum number of entries.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
$source
if(!is_readable( $file)) $ext
Definition router.php:48