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