X-Git-Url: https://scripts.mit.edu/gitweb/autoinstallsdev/mediawiki.git/blobdiff_plain/19e297c21b10b1b8a3acad5e73fc71dcb35db44a..6932310fd58ebef145fa01eb76edf7150284d8ea:/includes/libs/filebackend/fileop/FileOp.php diff --git a/includes/libs/filebackend/fileop/FileOp.php b/includes/libs/filebackend/fileop/FileOp.php new file mode 100644 index 00000000..40af7aca --- /dev/null +++ b/includes/libs/filebackend/fileop/FileOp.php @@ -0,0 +1,469 @@ +backend = $backend; + $this->logger = $logger; + list( $required, $optional, $paths ) = $this->allowedParams(); + foreach ( $required as $name ) { + if ( isset( $params[$name] ) ) { + $this->params[$name] = $params[$name]; + } else { + throw new InvalidArgumentException( "File operation missing parameter '$name'." ); + } + } + foreach ( $optional as $name ) { + if ( isset( $params[$name] ) ) { + $this->params[$name] = $params[$name]; + } + } + foreach ( $paths as $name ) { + if ( isset( $this->params[$name] ) ) { + // Normalize paths so the paths to the same file have the same string + $this->params[$name] = self::normalizeIfValidStoragePath( $this->params[$name] ); + } + } + } + + /** + * Normalize a string if it is a valid storage path + * + * @param string $path + * @return string + */ + protected static function normalizeIfValidStoragePath( $path ) { + if ( FileBackend::isStoragePath( $path ) ) { + $res = FileBackend::normalizeStoragePath( $path ); + + return ( $res !== null ) ? $res : $path; + } + + return $path; + } + + /** + * Set the batch UUID this operation belongs to + * + * @param string $batchId + */ + final public function setBatchId( $batchId ) { + $this->batchId = $batchId; + } + + /** + * Get the value of the parameter with the given name + * + * @param string $name + * @return mixed Returns null if the parameter is not set + */ + final public function getParam( $name ) { + return isset( $this->params[$name] ) ? $this->params[$name] : null; + } + + /** + * Check if this operation failed precheck() or attempt() + * + * @return bool + */ + final public function failed() { + return $this->failed; + } + + /** + * Get a new empty predicates array for precheck() + * + * @return array + */ + final public static function newPredicates() { + return [ 'exists' => [], 'sha1' => [] ]; + } + + /** + * Get a new empty dependency tracking array for paths read/written to + * + * @return array + */ + final public static function newDependencies() { + return [ 'read' => [], 'write' => [] ]; + } + + /** + * Update a dependency tracking array to account for this operation + * + * @param array $deps Prior path reads/writes; format of FileOp::newPredicates() + * @return array + */ + final public function applyDependencies( array $deps ) { + $deps['read'] += array_fill_keys( $this->storagePathsRead(), 1 ); + $deps['write'] += array_fill_keys( $this->storagePathsChanged(), 1 ); + + return $deps; + } + + /** + * Check if this operation changes files listed in $paths + * + * @param array $deps Prior path reads/writes; format of FileOp::newPredicates() + * @return bool + */ + final public function dependsOn( array $deps ) { + foreach ( $this->storagePathsChanged() as $path ) { + if ( isset( $deps['read'][$path] ) || isset( $deps['write'][$path] ) ) { + return true; // "output" or "anti" dependency + } + } + foreach ( $this->storagePathsRead() as $path ) { + if ( isset( $deps['write'][$path] ) ) { + return true; // "flow" dependency + } + } + + return false; + } + + /** + * Get the file journal entries for this file operation + * + * @param array $oPredicates Pre-op info about files (format of FileOp::newPredicates) + * @param array $nPredicates Post-op info about files (format of FileOp::newPredicates) + * @return array + */ + final public function getJournalEntries( array $oPredicates, array $nPredicates ) { + if ( !$this->doOperation ) { + return []; // this is a no-op + } + $nullEntries = []; + $updateEntries = []; + $deleteEntries = []; + $pathsUsed = array_merge( $this->storagePathsRead(), $this->storagePathsChanged() ); + foreach ( array_unique( $pathsUsed ) as $path ) { + $nullEntries[] = [ // assertion for recovery + 'op' => 'null', + 'path' => $path, + 'newSha1' => $this->fileSha1( $path, $oPredicates ) + ]; + } + foreach ( $this->storagePathsChanged() as $path ) { + if ( $nPredicates['sha1'][$path] === false ) { // deleted + $deleteEntries[] = [ + 'op' => 'delete', + 'path' => $path, + 'newSha1' => '' + ]; + } else { // created/updated + $updateEntries[] = [ + 'op' => $this->fileExists( $path, $oPredicates ) ? 'update' : 'create', + 'path' => $path, + 'newSha1' => $nPredicates['sha1'][$path] + ]; + } + } + + return array_merge( $nullEntries, $updateEntries, $deleteEntries ); + } + + /** + * Check preconditions of the operation without writing anything. + * This must update $predicates for each path that the op can change + * except when a failing StatusValue object is returned. + * + * @param array &$predicates + * @return StatusValue + */ + final public function precheck( array &$predicates ) { + if ( $this->state !== self::STATE_NEW ) { + return StatusValue::newFatal( 'fileop-fail-state', self::STATE_NEW, $this->state ); + } + $this->state = self::STATE_CHECKED; + $status = $this->doPrecheck( $predicates ); + if ( !$status->isOK() ) { + $this->failed = true; + } + + return $status; + } + + /** + * @param array &$predicates + * @return StatusValue + */ + protected function doPrecheck( array &$predicates ) { + return StatusValue::newGood(); + } + + /** + * Attempt the operation + * + * @return StatusValue + */ + final public function attempt() { + if ( $this->state !== self::STATE_CHECKED ) { + return StatusValue::newFatal( 'fileop-fail-state', self::STATE_CHECKED, $this->state ); + } elseif ( $this->failed ) { // failed precheck + return StatusValue::newFatal( 'fileop-fail-attempt-precheck' ); + } + $this->state = self::STATE_ATTEMPTED; + if ( $this->doOperation ) { + $status = $this->doAttempt(); + if ( !$status->isOK() ) { + $this->failed = true; + $this->logFailure( 'attempt' ); + } + } else { // no-op + $status = StatusValue::newGood(); + } + + return $status; + } + + /** + * @return StatusValue + */ + protected function doAttempt() { + return StatusValue::newGood(); + } + + /** + * Attempt the operation in the background + * + * @return StatusValue + */ + final public function attemptAsync() { + $this->async = true; + $result = $this->attempt(); + $this->async = false; + + return $result; + } + + /** + * Get the file operation parameters + * + * @return array (required params list, optional params list, list of params that are paths) + */ + protected function allowedParams() { + return [ [], [], [] ]; + } + + /** + * Adjust params to FileBackendStore internal file calls + * + * @param array $params + * @return array (required params list, optional params list) + */ + protected function setFlags( array $params ) { + return [ 'async' => $this->async ] + $params; + } + + /** + * Get a list of storage paths read from for this operation + * + * @return array + */ + public function storagePathsRead() { + return []; + } + + /** + * Get a list of storage paths written to for this operation + * + * @return array + */ + public function storagePathsChanged() { + return []; + } + + /** + * Check for errors with regards to the destination file already existing. + * Also set the destExists, overwriteSameCase and sourceSha1 member variables. + * A bad StatusValue will be returned if there is no chance it can be overwritten. + * + * @param array $predicates + * @return StatusValue + */ + protected function precheckDestExistence( array $predicates ) { + $status = StatusValue::newGood(); + // Get hash of source file/string and the destination file + $this->sourceSha1 = $this->getSourceSha1Base36(); // FS file or data string + if ( $this->sourceSha1 === null ) { // file in storage? + $this->sourceSha1 = $this->fileSha1( $this->params['src'], $predicates ); + } + $this->overwriteSameCase = false; + $this->destExists = $this->fileExists( $this->params['dst'], $predicates ); + if ( $this->destExists ) { + if ( $this->getParam( 'overwrite' ) ) { + return $status; // OK + } elseif ( $this->getParam( 'overwriteSame' ) ) { + $dhash = $this->fileSha1( $this->params['dst'], $predicates ); + // Check if hashes are valid and match each other... + if ( !strlen( $this->sourceSha1 ) || !strlen( $dhash ) ) { + $status->fatal( 'backend-fail-hashes' ); + } elseif ( $this->sourceSha1 !== $dhash ) { + // Give an error if the files are not identical + $status->fatal( 'backend-fail-notsame', $this->params['dst'] ); + } else { + $this->overwriteSameCase = true; // OK + } + + return $status; // do nothing; either OK or bad status + } else { + $status->fatal( 'backend-fail-alreadyexists', $this->params['dst'] ); + + return $status; + } + } + + return $status; + } + + /** + * precheckDestExistence() helper function to get the source file SHA-1. + * Subclasses should overwride this if the source is not in storage. + * + * @return string|bool Returns false on failure + */ + protected function getSourceSha1Base36() { + return null; // N/A + } + + /** + * Check if a file will exist in storage when this operation is attempted + * + * @param string $source Storage path + * @param array $predicates + * @return bool + */ + final protected function fileExists( $source, array $predicates ) { + if ( isset( $predicates['exists'][$source] ) ) { + return $predicates['exists'][$source]; // previous op assures this + } else { + $params = [ 'src' => $source, 'latest' => true ]; + + return $this->backend->fileExists( $params ); + } + } + + /** + * Get the SHA-1 of a file in storage when this operation is attempted + * + * @param string $source Storage path + * @param array $predicates + * @return string|bool False on failure + */ + final protected function fileSha1( $source, array $predicates ) { + if ( isset( $predicates['sha1'][$source] ) ) { + return $predicates['sha1'][$source]; // previous op assures this + } elseif ( isset( $predicates['exists'][$source] ) && !$predicates['exists'][$source] ) { + return false; // previous op assures this + } else { + $params = [ 'src' => $source, 'latest' => true ]; + + return $this->backend->getFileSha1Base36( $params ); + } + } + + /** + * Get the backend this operation is for + * + * @return FileBackendStore + */ + public function getBackend() { + return $this->backend; + } + + /** + * Log a file operation failure and preserve any temp files + * + * @param string $action + */ + final public function logFailure( $action ) { + $params = $this->params; + $params['failedAction'] = $action; + try { + $this->logger->error( static::class . + " failed (batch #{$this->batchId}): " . FormatJson::encode( $params ) ); + } catch ( Exception $e ) { + // bad config? debug log error? + } + } +}