Merge "filebackend: use self:: instead of FileBackend:: for some constant uses"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Sun, 8 Sep 2019 14:38:17 +0000 (14:38 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Sun, 8 Sep 2019 14:38:17 +0000 (14:38 +0000)
1  2 
includes/libs/filebackend/FSFileBackend.php

@@@ -40,7 -40,6 +40,7 @@@
   * @file
   * @ingroup FileBackend
   */
 +use Wikimedia\AtEase\AtEase;
  use Wikimedia\Timestamp\ConvertibleTimestamp;
  
  /**
@@@ -64,22 -63,23 +64,22 @@@ class FSFileBackend extends FileBackend
        protected $basePath;
  
        /** @var array Map of container names to root paths for custom container paths */
 -      protected $containerPaths = [];
 +      protected $containerPaths;
  
 +      /** @var int Directory permission mode */
 +      protected $dirMode;
        /** @var int File permission mode */
        protected $fileMode;
 -      /** @var int File permission mode */
 -      protected $dirMode;
 -
        /** @var string Required OS username to own files */
        protected $fileOwner;
  
 -      /** @var bool */
 +      /** @var bool Whether the OS is Windows (otherwise assumed Unix-like)*/
        protected $isWindows;
        /** @var string OS username running this script */
        protected $currentUser;
  
 -      /** @var array */
 -      protected $hadWarningErrors = [];
 +      /** @var bool[] Map of (stack index => whether a warning happened) */
 +      private $warningTrapStack = [];
  
        /**
         * @see FileBackendStore::__construct()
                        $this->basePath = null; // none; containers must have explicit paths
                }
  
 -              if ( isset( $config['containerPaths'] ) ) {
 -                      $this->containerPaths = (array)$config['containerPaths'];
 -                      foreach ( $this->containerPaths as &$path ) {
 -                              $path = rtrim( $path, '/' ); // remove trailing slash
 -                      }
 +              $this->containerPaths = [];
 +              foreach ( ( $config['containerPaths'] ?? [] ) as $container => $path ) {
 +                      $this->containerPaths[$container] = rtrim( $path, '/' ); // remove trailing slash
                }
  
                $this->fileMode = $config['fileMode'] ?? 0644;
                        // See https://www.php.net/manual/en/migration71.windows-support.php
                        return 0;
                } else {
-                       return FileBackend::ATTR_UNICODE_PATHS;
+                       return self::ATTR_UNICODE_PATHS;
                }
        }
  
                }
  
                if ( !empty( $params['async'] ) ) { // deferred
 -                      $tempFile = $this->tmpFileFactory->newTempFSFile( 'create_', 'tmp' );
 +                      $tempFile = $this->stageContentAsTempFile( $params );
                        if ( !$tempFile ) {
                                $status->fatal( 'backend-fail-create', $params['dst'] );
  
                                return $status;
                        }
 -                      $this->trapWarnings();
 -                      $bytes = file_put_contents( $tempFile->getPath(), $params['content'] );
 -                      $this->untrapWarnings();
 -                      if ( $bytes === false ) {
 -                              $status->fatal( 'backend-fail-create', $params['dst'] );
 -
 -                              return $status;
 -                      }
                        $cmd = implode( ' ', [
                                $this->isWindows ? 'COPY /B /Y' : 'cp', // (binary, overwrite)
                                escapeshellarg( $this->cleanPathSlashes( $tempFile->getPath() ) ),
        protected function doMoveInternal( array $params ) {
                $status = $this->newStatus();
  
 -              $source = $this->resolveToFSPath( $params['src'] );
 -              if ( $source === null ) {
 +              $fsSrcPath = $this->resolveToFSPath( $params['src'] );
 +              if ( $fsSrcPath === null ) {
                        $status->fatal( 'backend-fail-invalidpath', $params['src'] );
  
                        return $status;
                }
  
 -              $dest = $this->resolveToFSPath( $params['dst'] );
 -              if ( $dest === null ) {
 +              $fsDstPath = $this->resolveToFSPath( $params['dst'] );
 +              if ( $fsDstPath === null ) {
                        $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
  
                        return $status;
                }
  
 -              if ( !is_file( $source ) ) {
 -                      if ( empty( $params['ignoreMissingSource'] ) ) {
 -                              $status->fatal( 'backend-fail-move', $params['src'] );
 -                      }
 -
 -                      return $status; // do nothing; either OK or bad status
 +              if ( $fsSrcPath === $fsDstPath ) {
 +                      return $status; // no-op
                }
  
 +              $ignoreMissing = !empty( $params['ignoreMissingSource'] );
 +
                if ( !empty( $params['async'] ) ) { // deferred
 -                      $cmd = implode( ' ', [
 -                              $this->isWindows ? 'MOVE /Y' : 'mv', // (overwrite)
 -                              escapeshellarg( $this->cleanPathSlashes( $source ) ),
 -                              escapeshellarg( $this->cleanPathSlashes( $dest ) )
 -                      ] );
 +                      // https://manpages.debian.org/buster/coreutils/mv.1.en.html
 +                      // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/move
 +                      $encSrc = escapeshellarg( $this->cleanPathSlashes( $fsSrcPath ) );
 +                      $encDst = escapeshellarg( $this->cleanPathSlashes( $fsDstPath ) );
 +                      if ( $this->isWindows ) {
 +                              $writeCmd = "MOVE /Y $encSrc $encDst";
 +                              $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd;
 +                      } else {
 +                              $writeCmd = "mv -f $encSrc $encDst";
 +                              $cmd = $ignoreMissing ? "test -f $encSrc && $writeCmd" : $writeCmd;
 +                      }
                        $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
                                if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
                                        $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
                        };
                        $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
                } else { // immediate write
 -                      $this->trapWarnings();
 -                      $ok = ( $source === $dest ) ? true : rename( $source, $dest );
 -                      $this->untrapWarnings();
 -                      clearstatcache(); // file no longer at source
 -                      if ( !$ok ) {
 +                      // Use rename() here since (a) this clears xattrs, (b) any threads still reading the
 +                      // old inode are unaffected since it writes to a new inode, and (c) this is fast and
 +                      // atomic within a file system volume (as is normally the case)
 +                      $this->trapWarnings( '/: No such file or directory$/' );
 +                      $moved = rename( $fsSrcPath, $fsDstPath );
 +                      $hadError = $this->untrapWarnings();
 +                      if ( $hadError || ( !$moved && !$ignoreMissing ) ) {
                                $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
  
                                return $status;
        protected function doDeleteInternal( array $params ) {
                $status = $this->newStatus();
  
 -              $source = $this->resolveToFSPath( $params['src'] );
 -              if ( $source === null ) {
 +              $fsSrcPath = $this->resolveToFSPath( $params['src'] );
 +              if ( $fsSrcPath === null ) {
                        $status->fatal( 'backend-fail-invalidpath', $params['src'] );
  
                        return $status;
                }
  
 -              if ( !is_file( $source ) ) {
 -                      if ( empty( $params['ignoreMissingSource'] ) ) {
 -                              $status->fatal( 'backend-fail-delete', $params['src'] );
 -                      }
 -
 -                      return $status; // do nothing; either OK or bad status
 -              }
 +              $ignoreMissing = !empty( $params['ignoreMissingSource'] );
  
                if ( !empty( $params['async'] ) ) { // deferred
 -                      $cmd = implode( ' ', [
 -                              $this->isWindows ? 'DEL' : 'unlink',
 -                              escapeshellarg( $this->cleanPathSlashes( $source ) )
 -                      ] );
 +                      // https://manpages.debian.org/buster/coreutils/rm.1.en.html
 +                      // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/del
 +                      $encSrc = escapeshellarg( $this->cleanPathSlashes( $fsSrcPath ) );
 +                      if ( $this->isWindows ) {
 +                              $writeCmd = "DEL /Q $encSrc";
 +                              $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd;
 +                      } else {
 +                              $cmd = $ignoreMissing ? "rm -f $encSrc" : "rm $encSrc";
 +                      }
                        $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
                                if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
                                        $status->fatal( 'backend-fail-delete', $params['src'] );
                        };
                        $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
                } else { // immediate write
 -                      $this->trapWarnings();
 -                      $ok = unlink( $source );
 -                      $this->untrapWarnings();
 -                      if ( !$ok ) {
 +                      $this->trapWarnings( '/: No such file or directory$/' );
 +                      $deleted = unlink( $fsSrcPath );
 +                      $hadError = $this->untrapWarnings();
 +                      if ( $hadError || ( !$deleted && !$ignoreMissing ) ) {
                                $status->fatal( 'backend-fail-delete', $params['src'] );
  
                                return $status;
                $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
                $existed = is_dir( $dir ); // already there?
                // Create the directory and its parents as needed...
 -              $this->trapWarnings();
 +              AtEase::suppressWarnings();
                if ( !$existed && !mkdir( $dir, $this->dirMode, true ) && !is_dir( $dir ) ) {
                        $this->logger->error( __METHOD__ . ": cannot create directory $dir" );
                        $status->fatal( 'directorycreateerror', $params['dir'] ); // fails on races
                        $this->logger->error( __METHOD__ . ": directory $dir is not readable" );
                        $status->fatal( 'directorynotreadableerror', $params['dir'] );
                }
 -              $this->untrapWarnings();
 +              AtEase::restoreWarnings();
                // Respect any 'noAccess' or 'noListing' flags...
                if ( is_dir( $dir ) && !$existed ) {
                        $status->merge( $this->doSecureInternal( $fullCont, $dirRel, $params ) );
                }
                // Add a .htaccess file to the root of the container...
                if ( !empty( $params['noAccess'] ) && !file_exists( "{$contRoot}/.htaccess" ) ) {
 -                      $this->trapWarnings();
 +                      AtEase::suppressWarnings();
                        $bytes = file_put_contents( "{$contRoot}/.htaccess", $this->htaccessPrivate() );
 -                      $this->untrapWarnings();
 +                      AtEase::restoreWarnings();
                        if ( $bytes === false ) {
                                $storeDir = "mwstore://{$this->name}/{$shortCont}";
                                $status->fatal( 'backend-fail-create', "{$storeDir}/.htaccess" );
                // Unseed new directories with a blank index.html, to allow crawling...
                if ( !empty( $params['listing'] ) && is_file( "{$dir}/index.html" ) ) {
                        $exists = ( file_get_contents( "{$dir}/index.html" ) === $this->indexHtmlPrivate() );
 -                      $this->trapWarnings();
 -                      if ( $exists && !unlink( "{$dir}/index.html" ) ) { // reverse secure()
 +                      if ( $exists && !$this->unlink( "{$dir}/index.html" ) ) { // reverse secure()
                                $status->fatal( 'backend-fail-delete', $params['dir'] . '/index.html' );
                        }
 -                      $this->untrapWarnings();
                }
                // Remove the .htaccess file from the root of the container...
                if ( !empty( $params['access'] ) && is_file( "{$contRoot}/.htaccess" ) ) {
                        $exists = ( file_get_contents( "{$contRoot}/.htaccess" ) === $this->htaccessPrivate() );
 -                      $this->trapWarnings();
 -                      if ( $exists && !unlink( "{$contRoot}/.htaccess" ) ) { // reverse secure()
 +                      if ( $exists && !$this->unlink( "{$contRoot}/.htaccess" ) ) { // reverse secure()
                                $storeDir = "mwstore://{$this->name}/{$shortCont}";
                                $status->fatal( 'backend-fail-delete', "{$storeDir}/.htaccess" );
                        }
 -                      $this->untrapWarnings();
                }
  
                return $status;
                list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
                $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
                $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
 -              $this->trapWarnings();
 +              AtEase::suppressWarnings();
                if ( is_dir( $dir ) ) {
                        rmdir( $dir ); // remove directory if empty
                }
 -              $this->untrapWarnings();
 +              AtEase::restoreWarnings();
  
                return $status;
        }
  
                        $tmpPath = $tmpFile->getPath();
                        // Copy the source file over the temp file
 -                      $this->trapWarnings();
 +                      $this->trapWarnings(); // don't trust 'false' if there were errors
                        $isFile = is_file( $source ); // regular files only
                        $copySuccess = $isFile ? copy( $source, $tmpPath ) : false;
                        $hadError = $this->untrapWarnings();
                $statuses = [];
  
                $pipes = [];
 +              $octalPermissions = '0' . decoct( $this->fileMode );
                foreach ( $fileOpHandles as $index => $fileOpHandle ) {
 -                      $pipes[$index] = popen( "{$fileOpHandle->cmd} 2>&1", 'r' );
 +                      $cmd = "{$fileOpHandle->cmd} 2>&1";
 +                      // Add a post-operation chmod command for permissions cleanup if applicable
 +                      if (
 +                              !$this->isWindows &&
 +                              $fileOpHandle->chmodPath !== null &&
 +                              strlen( $octalPermissions ) == 4
 +                      ) {
 +                              $encPath = escapeshellarg( $fileOpHandle->chmodPath );
 +                              $cmd .= " && chmod $octalPermissions $encPath 2>/dev/null";
 +                      }
 +                      $pipes[$index] = popen( $cmd, 'r' );
                }
  
                $errs = [];
                        $function = $fileOpHandle->call;
                        $function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd );
                        $statuses[$index] = $status;
 -                      if ( $status->isOK() && $fileOpHandle->chmodPath ) {
 -                              $this->chmod( $fileOpHandle->chmodPath );
 -                      }
                }
  
                clearstatcache(); // files changed
 +
                return $statuses;
        }
  
         * @return bool Success
         */
        protected function chmod( $path ) {
 -              $this->trapWarnings();
 +              if ( $this->isWindows ) {
 +                      return true;
 +              }
 +
 +              AtEase::suppressWarnings();
                $ok = chmod( $path, $this->fileMode );
 -              $this->untrapWarnings();
 +              AtEase::restoreWarnings();
  
                return $ok;
        }
  
 +      /**
 +       * Unlink a file, suppressing the warnings
 +       *
 +       * @param string $path Absolute file system path
 +       * @return bool Success
 +       */
 +      protected function unlink( $path ) {
 +              AtEase::suppressWarnings();
 +              $ok = unlink( $path );
 +              AtEase::restoreWarnings();
 +
 +              return $ok;
 +      }
 +
 +      /**
 +       * @param array $params Operation parameters with 'content' and 'headers' fields
 +       * @return TempFSFile|null
 +       */
 +      protected function stageContentAsTempFile( array $params ) {
 +              $content = $params['content'];
 +              $tempFile = $this->tmpFileFactory->newTempFSFile( 'create_', 'tmp' );
 +              if ( !$tempFile ) {
 +                      return null;
 +              }
 +
 +              AtEase::suppressWarnings();
 +              $tmpPath = $tempFile->getPath();
 +              if ( file_put_contents( $tmpPath, $content ) === false ) {
 +                      $tempFile = null;
 +              }
 +              AtEase::restoreWarnings();
 +
 +              return $tempFile;
 +      }
 +
        /**
         * Return the text of an index.html file to hide directory listings
         *
        }
  
        /**
 -       * Listen for E_WARNING errors and track whether any happen
 +       * Listen for E_WARNING errors and track whether any that happen
 +       *
 +       * @param string|null $regexIgnore Optional regex of errors to ignore
         */
 -      protected function trapWarnings() {
 -              // push to stack
 -              $this->hadWarningErrors[] = false;
 -              set_error_handler( function ( $errno, $errstr ) {
 -                      // more detailed error logging
 -                      $this->logger->error( $errstr );
 -                      $this->hadWarningErrors[count( $this->hadWarningErrors ) - 1] = true;
 -
 -                      // suppress from PHP handler
 -                      return true;
 +      protected function trapWarnings( $regexIgnore = null ) {
 +              $this->warningTrapStack[] = false;
 +              set_error_handler( function ( $errno, $errstr ) use ( $regexIgnore ) {
 +                      if ( $regexIgnore === null || !preg_match( $regexIgnore, $errstr ) ) {
 +                              $this->logger->error( $errstr );
 +                              $this->warningTrapStack[count( $this->warningTrapStack ) - 1] = true;
 +                      }
 +                      return true; // suppress from PHP handler
                }, E_WARNING );
        }
  
        /**
 -       * Stop listening for E_WARNING errors and return true if any happened
 +       * Stop listening for E_WARNING errors and get whether any happened
         *
 -       * @return bool
 +       * @return bool Whether any warnings happened
         */
        protected function untrapWarnings() {
 -              // restore previous handler
                restore_error_handler();
 -              // pop from stack
 -              return array_pop( $this->hadWarningErrors );
 +
 +              return array_pop( $this->warningTrapStack );
        }
  }