Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
59.91% |
272 / 454 |
|
28.57% |
12 / 42 |
CRAP | |
0.00% |
0 / 1 |
| FSFileBackend | |
60.04% |
272 / 453 |
|
28.57% |
12 / 42 |
2594.74 | |
0.00% |
0 / 1 |
| __construct | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
5.25 | |||
| getFeatures | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| resolveContainerPath | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| isLegalRelPath | |
60.00% |
3 / 5 |
|
0.00% |
0 / 1 |
3.58 | |||
| containerFSRoot | |
40.00% |
2 / 5 |
|
0.00% |
0 / 1 |
4.94 | |||
| resolveToFSPath | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| isPathUsableInternal | |
58.33% |
7 / 12 |
|
0.00% |
0 / 1 |
10.54 | |||
| doCreateInternal | |
51.52% |
17 / 33 |
|
0.00% |
0 / 1 |
24.79 | |||
| doStoreInternal | |
62.86% |
22 / 35 |
|
0.00% |
0 / 1 |
21.66 | |||
| doCopyInternal | |
64.10% |
25 / 39 |
|
0.00% |
0 / 1 |
27.84 | |||
| doMoveInternal | |
48.15% |
13 / 27 |
|
0.00% |
0 / 1 |
27.87 | |||
| doDeleteInternal | |
47.62% |
10 / 21 |
|
0.00% |
0 / 1 |
20.64 | |||
| doPrepareInternal | |
77.27% |
17 / 22 |
|
0.00% |
0 / 1 |
11.17 | |||
| doSecureInternal | |
43.75% |
7 / 16 |
|
0.00% |
0 / 1 |
19.39 | |||
| doPublishInternal | |
50.00% |
7 / 14 |
|
0.00% |
0 / 1 |
22.50 | |||
| doCleanInternal | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| doGetFileStat | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
5 | |||
| doClearCache | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
4.25 | |||
| doDirectoryExists | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| getDirectoryListInternal | |
66.67% |
10 / 15 |
|
0.00% |
0 / 1 |
5.93 | |||
| getFileListInternal | |
58.82% |
10 / 17 |
|
0.00% |
0 / 1 |
6.75 | |||
| doGetLocalReferenceMulti | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
5.01 | |||
| doGetLocalCopyMulti | |
78.26% |
18 / 23 |
|
0.00% |
0 / 1 |
7.50 | |||
| addShellboxInputFile | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
| directoriesAreVirtual | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| doExecuteOpHandlesInternal | |
50.00% |
7 / 14 |
|
0.00% |
0 / 1 |
6.00 | |||
| makeStagingPath | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| makeCopyCommand | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
30 | |||
| makeMoveCommand | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
| makeUnlinkCommand | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
| chmod | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| unlink | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| rmdir | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| newTempFileWithContent | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
| indexHtmlPrivate | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| htaccessPrivate | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| cleanPathSlashes | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
| trapWarnings | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
3.21 | |||
| trapWarningsIgnoringNotFound | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| untrapWarnings | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getFileNotFoundRegex | |
27.27% |
3 / 11 |
|
0.00% |
0 / 1 |
19.85 | |||
| isFileNotFoundError | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | * @ingroup FileBackend |
| 6 | */ |
| 7 | |
| 8 | /** |
| 9 | * File system based backend. |
| 10 | * |
| 11 | * @license GPL-2.0-or-later |
| 12 | * @file |
| 13 | * @ingroup FileBackend |
| 14 | */ |
| 15 | |
| 16 | // @phan-file-suppress UnusedPluginSuppression,UnusedPluginFileSuppression False positive on Windows only |
| 17 | |
| 18 | namespace Wikimedia\FileBackend; |
| 19 | |
| 20 | use Shellbox\Command\BoxedCommand; |
| 21 | use Shellbox\Shellbox; |
| 22 | use StatusValue; |
| 23 | use Wikimedia\FileBackend\FileIteration\FSFileBackendDirList; |
| 24 | use Wikimedia\FileBackend\FileIteration\FSFileBackendFileList; |
| 25 | use Wikimedia\FileBackend\FileOpHandle\FSFileOpHandle; |
| 26 | use Wikimedia\FileBackend\FSFile\FSFile; |
| 27 | use Wikimedia\FileBackend\FSFile\TempFSFile; |
| 28 | use Wikimedia\ObjectCache\MapCacheLRU; |
| 29 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
| 30 | use Wikimedia\Timestamp\TimestampFormat as TS; |
| 31 | |
| 32 | /** |
| 33 | * @brief Class for a file system (FS) based file backend. |
| 34 | * |
| 35 | * All "containers" each map to a directory under the backend's base directory. |
| 36 | * For backwards-compatibility, some container paths can be set to custom paths. |
| 37 | * The domain ID will not be used in any custom paths, so this should be avoided. |
| 38 | * |
| 39 | * Having directories with thousands of files will diminish performance. |
| 40 | * Sharding can be accomplished by using FileRepo-style hash paths. |
| 41 | * |
| 42 | * StatusValue messages should avoid mentioning the internal FS paths. |
| 43 | * PHP warnings are assumed to be logged rather than output. |
| 44 | * |
| 45 | * @ingroup FileBackend |
| 46 | * @since 1.19 |
| 47 | */ |
| 48 | class FSFileBackend extends FileBackendStore { |
| 49 | /** @var MapCacheLRU Cache for known prepared/usable directories */ |
| 50 | protected $usableDirCache; |
| 51 | |
| 52 | /** @var string|null Directory holding the container directories */ |
| 53 | protected $basePath; |
| 54 | |
| 55 | /** @var array<string,string> Map of container names to root paths for custom container paths */ |
| 56 | protected $containerPaths; |
| 57 | |
| 58 | /** @var int Directory permission mode */ |
| 59 | protected $dirMode; |
| 60 | /** @var int File permission mode */ |
| 61 | protected $fileMode; |
| 62 | /** @var string Required OS username to own files */ |
| 63 | protected $fileOwner; |
| 64 | /** @var string OS username running this script */ |
| 65 | protected $currentUser; |
| 66 | |
| 67 | /** @var bool[] Map of (stack index => whether a warning happened) */ |
| 68 | private $warningTrapStack = []; |
| 69 | /** @var bool Simpler version of PHP_OS_FAMILY */ |
| 70 | private $isWindows; |
| 71 | |
| 72 | /** |
| 73 | * @see FileBackendStore::__construct() |
| 74 | * Additional $config params include: |
| 75 | * - basePath : File system directory that holds containers. |
| 76 | * - containerPaths : Map of container names to custom file system directories. |
| 77 | * This should only be used for backwards-compatibility. |
| 78 | * - fileMode : Octal UNIX file permissions to use on files stored. |
| 79 | * - directoryMode : Octal UNIX file permissions to use on directories created. |
| 80 | * @param array $config |
| 81 | */ |
| 82 | public function __construct( array $config ) { |
| 83 | parent::__construct( $config ); |
| 84 | |
| 85 | // Remove any possible trailing slash from directories |
| 86 | if ( isset( $config['basePath'] ) ) { |
| 87 | $this->basePath = rtrim( $config['basePath'], '/' ); // remove trailing slash |
| 88 | } else { |
| 89 | $this->basePath = null; // none; containers must have explicit paths |
| 90 | } |
| 91 | |
| 92 | $this->containerPaths = []; |
| 93 | foreach ( ( $config['containerPaths'] ?? [] ) as $container => $fsPath ) { |
| 94 | $this->containerPaths[$container] = rtrim( $fsPath, '/' ); // remove trailing slash |
| 95 | } |
| 96 | |
| 97 | $this->fileMode = $config['fileMode'] ?? 0o644; |
| 98 | $this->dirMode = $config['directoryMode'] ?? 0o777; |
| 99 | if ( isset( $config['fileOwner'] ) && function_exists( 'posix_getuid' ) ) { |
| 100 | $this->fileOwner = $config['fileOwner']; |
| 101 | // Cache this, assuming it doesn't change |
| 102 | $this->currentUser = posix_getpwuid( posix_getuid() )['name']; |
| 103 | } |
| 104 | |
| 105 | $this->usableDirCache = new MapCacheLRU( self::CACHE_CHEAP_SIZE ); |
| 106 | $this->isWindows = ( PHP_OS_FAMILY === 'Windows' ); |
| 107 | } |
| 108 | |
| 109 | /** @inheritDoc */ |
| 110 | public function getFeatures() { |
| 111 | return self::ATTR_UNICODE_PATHS; |
| 112 | } |
| 113 | |
| 114 | /** @inheritDoc */ |
| 115 | protected function resolveContainerPath( $container, $relStoragePath ) { |
| 116 | // Check that container has a root directory |
| 117 | if ( isset( $this->containerPaths[$container] ) || $this->basePath !== null ) { |
| 118 | // Check for sensible relative paths (assume the base paths are OK) |
| 119 | if ( $this->isLegalRelPath( $relStoragePath ) ) { |
| 120 | return $relStoragePath; |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | return null; // invalid |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Check a relative file system path for validity |
| 129 | * |
| 130 | * @param string $fsPath Normalized relative path |
| 131 | * @return bool |
| 132 | */ |
| 133 | protected function isLegalRelPath( $fsPath ) { |
| 134 | // Check for file names longer than 255 chars |
| 135 | if ( preg_match( '![^/]{256}!', $fsPath ) ) { // ext3/NTFS |
| 136 | return false; |
| 137 | } |
| 138 | if ( $this->isWindows ) { // NTFS |
| 139 | return !preg_match( '![:*?"<>|]!', $fsPath ); |
| 140 | } else { |
| 141 | return true; |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * Given the short (unresolved) and full (resolved) name of |
| 147 | * a container, return the file system path of the container. |
| 148 | * |
| 149 | * @param string $shortCont |
| 150 | * @param string $fullCont |
| 151 | * @return string|null |
| 152 | */ |
| 153 | protected function containerFSRoot( $shortCont, $fullCont ) { |
| 154 | if ( isset( $this->containerPaths[$shortCont] ) ) { |
| 155 | return $this->containerPaths[$shortCont]; |
| 156 | } elseif ( $this->basePath !== null ) { |
| 157 | return "{$this->basePath}/{$fullCont}"; |
| 158 | } |
| 159 | |
| 160 | return null; // no container base path defined |
| 161 | } |
| 162 | |
| 163 | /** |
| 164 | * Get the absolute file system path for a storage path |
| 165 | * |
| 166 | * @param string $storagePath |
| 167 | * @return string|null |
| 168 | */ |
| 169 | protected function resolveToFSPath( $storagePath ) { |
| 170 | [ $fullCont, $relPath ] = $this->resolveStoragePathReal( $storagePath ); |
| 171 | if ( $relPath === null ) { |
| 172 | return null; // invalid |
| 173 | } |
| 174 | [ , $shortCont, ] = FileBackend::splitStoragePath( $storagePath ); |
| 175 | $fsPath = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid |
| 176 | if ( $relPath != '' ) { |
| 177 | $fsPath .= "/{$relPath}"; |
| 178 | } |
| 179 | |
| 180 | return $fsPath; |
| 181 | } |
| 182 | |
| 183 | /** @inheritDoc */ |
| 184 | public function isPathUsableInternal( $storagePath ) { |
| 185 | $fsPath = $this->resolveToFSPath( $storagePath ); |
| 186 | if ( $fsPath === null ) { |
| 187 | return false; // invalid |
| 188 | } |
| 189 | |
| 190 | if ( $this->fileOwner !== null && $this->currentUser !== $this->fileOwner ) { |
| 191 | trigger_error( __METHOD__ . ": PHP process owner is not '{$this->fileOwner}'." ); |
| 192 | return false; |
| 193 | } |
| 194 | |
| 195 | $fsDirectory = dirname( $fsPath ); |
| 196 | $usable = $this->usableDirCache->get( $fsDirectory, MapCacheLRU::TTL_PROC_SHORT ); |
| 197 | if ( $usable === null ) { |
| 198 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 199 | $usable = @is_dir( $fsDirectory ) && @is_writable( $fsDirectory ); |
| 200 | $this->usableDirCache->set( $fsDirectory, $usable ? 1 : 0 ); |
| 201 | } |
| 202 | |
| 203 | return $usable; |
| 204 | } |
| 205 | |
| 206 | /** @inheritDoc */ |
| 207 | protected function doCreateInternal( array $params ) { |
| 208 | $status = $this->newStatus(); |
| 209 | |
| 210 | $fsDstPath = $this->resolveToFSPath( $params['dst'] ); |
| 211 | if ( $fsDstPath === null ) { |
| 212 | $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); |
| 213 | |
| 214 | return $status; |
| 215 | } |
| 216 | |
| 217 | if ( !empty( $params['async'] ) ) { // deferred |
| 218 | $tempFile = $this->newTempFileWithContent( $params ); |
| 219 | if ( !$tempFile ) { |
| 220 | $status->fatal( 'backend-fail-create', $params['dst'] ); |
| 221 | |
| 222 | return $status; |
| 223 | } |
| 224 | $cmd = $this->makeCopyCommand( $tempFile->getPath(), $fsDstPath, false ); |
| 225 | $handler = function ( $errors, StatusValue $status, array $params, $cmd ) { |
| 226 | if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) { |
| 227 | $status->fatal( 'backend-fail-create', $params['dst'] ); |
| 228 | trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output |
| 229 | } |
| 230 | }; |
| 231 | $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd ); |
| 232 | $tempFile->bind( $status->value ); |
| 233 | } else { // immediate write |
| 234 | $created = false; |
| 235 | // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old |
| 236 | // inode are unaffected since it writes to a new inode, and (c) new threads reading |
| 237 | // the file will either totally see the old version or totally see the new version |
| 238 | $fsStagePath = $this->makeStagingPath( $fsDstPath ); |
| 239 | $this->trapWarningsIgnoringNotFound(); |
| 240 | $stageHandle = fopen( $fsStagePath, 'xb' ); |
| 241 | if ( $stageHandle ) { |
| 242 | $bytes = fwrite( $stageHandle, $params['content'] ); |
| 243 | $created = ( $bytes === strlen( $params['content'] ) ); |
| 244 | fclose( $stageHandle ); |
| 245 | $created = $created ? rename( $fsStagePath, $fsDstPath ) : false; |
| 246 | } |
| 247 | $hadError = $this->untrapWarnings(); |
| 248 | if ( $hadError || !$created ) { |
| 249 | $status->fatal( 'backend-fail-create', $params['dst'] ); |
| 250 | |
| 251 | return $status; |
| 252 | } |
| 253 | $this->chmod( $fsDstPath ); |
| 254 | } |
| 255 | |
| 256 | return $status; |
| 257 | } |
| 258 | |
| 259 | /** @inheritDoc */ |
| 260 | protected function doStoreInternal( array $params ) { |
| 261 | $status = $this->newStatus(); |
| 262 | |
| 263 | $fsSrcPath = $params['src']; // file system path |
| 264 | $fsDstPath = $this->resolveToFSPath( $params['dst'] ); |
| 265 | if ( $fsDstPath === null ) { |
| 266 | $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); |
| 267 | |
| 268 | return $status; |
| 269 | } |
| 270 | |
| 271 | if ( $fsSrcPath === $fsDstPath ) { |
| 272 | $status->fatal( 'backend-fail-internal', $this->name ); |
| 273 | |
| 274 | return $status; |
| 275 | } |
| 276 | |
| 277 | if ( !empty( $params['async'] ) ) { // deferred |
| 278 | $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath, false ); |
| 279 | $handler = function ( $errors, StatusValue $status, array $params, $cmd ) { |
| 280 | if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) { |
| 281 | $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] ); |
| 282 | trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output |
| 283 | } |
| 284 | }; |
| 285 | $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd ); |
| 286 | } else { // immediate write |
| 287 | $stored = false; |
| 288 | // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old |
| 289 | // inode are unaffected since it writes to a new inode, and (c) new threads reading |
| 290 | // the file will either totally see the old version or totally see the new version |
| 291 | $fsStagePath = $this->makeStagingPath( $fsDstPath ); |
| 292 | $this->trapWarningsIgnoringNotFound(); |
| 293 | $srcHandle = fopen( $fsSrcPath, 'rb' ); |
| 294 | if ( $srcHandle ) { |
| 295 | $stageHandle = fopen( $fsStagePath, 'xb' ); |
| 296 | if ( $stageHandle ) { |
| 297 | $bytes = stream_copy_to_stream( $srcHandle, $stageHandle ); |
| 298 | $stored = ( $bytes !== false && $bytes === fstat( $srcHandle )['size'] ); |
| 299 | fclose( $stageHandle ); |
| 300 | $stored = $stored ? rename( $fsStagePath, $fsDstPath ) : false; |
| 301 | } |
| 302 | fclose( $srcHandle ); |
| 303 | } |
| 304 | $hadError = $this->untrapWarnings(); |
| 305 | if ( $hadError || !$stored ) { |
| 306 | $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] ); |
| 307 | |
| 308 | return $status; |
| 309 | } |
| 310 | $this->chmod( $fsDstPath ); |
| 311 | } |
| 312 | |
| 313 | return $status; |
| 314 | } |
| 315 | |
| 316 | /** @inheritDoc */ |
| 317 | protected function doCopyInternal( array $params ) { |
| 318 | $status = $this->newStatus(); |
| 319 | |
| 320 | $fsSrcPath = $this->resolveToFSPath( $params['src'] ); |
| 321 | if ( $fsSrcPath === null ) { |
| 322 | $status->fatal( 'backend-fail-invalidpath', $params['src'] ); |
| 323 | |
| 324 | return $status; |
| 325 | } |
| 326 | |
| 327 | $fsDstPath = $this->resolveToFSPath( $params['dst'] ); |
| 328 | if ( $fsDstPath === null ) { |
| 329 | $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); |
| 330 | |
| 331 | return $status; |
| 332 | } |
| 333 | |
| 334 | if ( $fsSrcPath === $fsDstPath ) { |
| 335 | return $status; // no-op |
| 336 | } |
| 337 | |
| 338 | $ignoreMissing = !empty( $params['ignoreMissingSource'] ); |
| 339 | |
| 340 | if ( !empty( $params['async'] ) ) { // deferred |
| 341 | $cmd = $this->makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing ); |
| 342 | $handler = function ( $errors, StatusValue $status, array $params, $cmd ) { |
| 343 | if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) { |
| 344 | $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] ); |
| 345 | trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output |
| 346 | } |
| 347 | }; |
| 348 | $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd ); |
| 349 | } else { // immediate write |
| 350 | $copied = false; |
| 351 | // Use fwrite+rename since (a) this clears xattrs, (b) threads still reading the old |
| 352 | // inode are unaffected since it writes to a new inode, and (c) new threads reading |
| 353 | // the file will either totally see the old version or totally see the new version |
| 354 | $fsStagePath = $this->makeStagingPath( $fsDstPath ); |
| 355 | $this->trapWarningsIgnoringNotFound(); |
| 356 | $srcHandle = fopen( $fsSrcPath, 'rb' ); |
| 357 | if ( $srcHandle ) { |
| 358 | $stageHandle = fopen( $fsStagePath, 'xb' ); |
| 359 | if ( $stageHandle ) { |
| 360 | $bytes = stream_copy_to_stream( $srcHandle, $stageHandle ); |
| 361 | $copied = ( $bytes !== false && $bytes === fstat( $srcHandle )['size'] ); |
| 362 | fclose( $stageHandle ); |
| 363 | $copied = $copied ? rename( $fsStagePath, $fsDstPath ) : false; |
| 364 | } |
| 365 | fclose( $srcHandle ); |
| 366 | } |
| 367 | $hadError = $this->untrapWarnings(); |
| 368 | if ( $hadError || ( !$copied && !$ignoreMissing ) ) { |
| 369 | $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] ); |
| 370 | |
| 371 | return $status; |
| 372 | } |
| 373 | if ( $copied ) { |
| 374 | $this->chmod( $fsDstPath ); |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | return $status; |
| 379 | } |
| 380 | |
| 381 | /** @inheritDoc */ |
| 382 | protected function doMoveInternal( array $params ) { |
| 383 | $status = $this->newStatus(); |
| 384 | |
| 385 | $fsSrcPath = $this->resolveToFSPath( $params['src'] ); |
| 386 | if ( $fsSrcPath === null ) { |
| 387 | $status->fatal( 'backend-fail-invalidpath', $params['src'] ); |
| 388 | |
| 389 | return $status; |
| 390 | } |
| 391 | |
| 392 | $fsDstPath = $this->resolveToFSPath( $params['dst'] ); |
| 393 | if ( $fsDstPath === null ) { |
| 394 | $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); |
| 395 | |
| 396 | return $status; |
| 397 | } |
| 398 | |
| 399 | if ( $fsSrcPath === $fsDstPath ) { |
| 400 | return $status; // no-op |
| 401 | } |
| 402 | |
| 403 | $ignoreMissing = !empty( $params['ignoreMissingSource'] ); |
| 404 | |
| 405 | if ( !empty( $params['async'] ) ) { // deferred |
| 406 | $cmd = $this->makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing ); |
| 407 | $handler = function ( $errors, StatusValue $status, array $params, $cmd ) { |
| 408 | if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) { |
| 409 | $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] ); |
| 410 | trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output |
| 411 | } |
| 412 | }; |
| 413 | $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd ); |
| 414 | } else { // immediate write |
| 415 | // Use rename() here since (a) this clears xattrs, (b) any threads still reading the |
| 416 | // old inode are unaffected since it writes to a new inode, and (c) this is fast and |
| 417 | // atomic within a file system volume (as is normally the case) |
| 418 | $this->trapWarningsIgnoringNotFound(); |
| 419 | $moved = rename( $fsSrcPath, $fsDstPath ); |
| 420 | $hadError = $this->untrapWarnings(); |
| 421 | if ( $hadError || ( !$moved && !$ignoreMissing ) ) { |
| 422 | $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] ); |
| 423 | |
| 424 | return $status; |
| 425 | } |
| 426 | } |
| 427 | |
| 428 | return $status; |
| 429 | } |
| 430 | |
| 431 | /** @inheritDoc */ |
| 432 | protected function doDeleteInternal( array $params ) { |
| 433 | $status = $this->newStatus(); |
| 434 | |
| 435 | $fsSrcPath = $this->resolveToFSPath( $params['src'] ); |
| 436 | if ( $fsSrcPath === null ) { |
| 437 | $status->fatal( 'backend-fail-invalidpath', $params['src'] ); |
| 438 | |
| 439 | return $status; |
| 440 | } |
| 441 | |
| 442 | $ignoreMissing = !empty( $params['ignoreMissingSource'] ); |
| 443 | |
| 444 | if ( !empty( $params['async'] ) ) { // deferred |
| 445 | $cmd = $this->makeUnlinkCommand( $fsSrcPath, $ignoreMissing ); |
| 446 | $handler = function ( $errors, StatusValue $status, array $params, $cmd ) { |
| 447 | if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) { |
| 448 | $status->fatal( 'backend-fail-delete', $params['src'] ); |
| 449 | trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output |
| 450 | } |
| 451 | }; |
| 452 | $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd ); |
| 453 | } else { // immediate write |
| 454 | $this->trapWarningsIgnoringNotFound(); |
| 455 | $deleted = unlink( $fsSrcPath ); |
| 456 | $hadError = $this->untrapWarnings(); |
| 457 | if ( $hadError || ( !$deleted && !$ignoreMissing ) ) { |
| 458 | $status->fatal( 'backend-fail-delete', $params['src'] ); |
| 459 | |
| 460 | return $status; |
| 461 | } |
| 462 | } |
| 463 | |
| 464 | return $status; |
| 465 | } |
| 466 | |
| 467 | /** |
| 468 | * @inheritDoc |
| 469 | */ |
| 470 | protected function doPrepareInternal( $fullCont, $dirRel, array $params ) { |
| 471 | $status = $this->newStatus(); |
| 472 | [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] ); |
| 473 | $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid |
| 474 | $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; |
| 475 | // Create the directory and its parents as needed... |
| 476 | $created = false; |
| 477 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 478 | $alreadyExisted = @is_dir( $fsDirectory ); // already there? |
| 479 | if ( !$alreadyExisted ) { |
| 480 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 481 | $created = @mkdir( $fsDirectory, $this->dirMode, true ); |
| 482 | if ( !$created ) { |
| 483 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 484 | $alreadyExisted = @is_dir( $fsDirectory ); // another thread made it? |
| 485 | } |
| 486 | } |
| 487 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 488 | $isWritable = $created ?: @is_writable( $fsDirectory ); // assume writable if created here |
| 489 | if ( !$alreadyExisted && !$created ) { |
| 490 | $this->logger->error( __METHOD__ . ": cannot create directory $fsDirectory" ); |
| 491 | $status->fatal( 'directorycreateerror', $params['dir'] ); // fails on races |
| 492 | } elseif ( !$isWritable ) { |
| 493 | $this->logger->error( __METHOD__ . ": directory $fsDirectory is read-only" ); |
| 494 | $status->fatal( 'directoryreadonlyerror', $params['dir'] ); |
| 495 | } |
| 496 | // Respect any 'noAccess' or 'noListing' flags... |
| 497 | if ( $created ) { |
| 498 | $status->merge( $this->doSecureInternal( $fullCont, $dirRel, $params ) ); |
| 499 | } |
| 500 | |
| 501 | if ( $status->isGood() ) { |
| 502 | $this->usableDirCache->set( $fsDirectory, 1 ); |
| 503 | } |
| 504 | |
| 505 | return $status; |
| 506 | } |
| 507 | |
| 508 | /** @inheritDoc */ |
| 509 | protected function doSecureInternal( $fullCont, $dirRel, array $params ) { |
| 510 | $status = $this->newStatus(); |
| 511 | [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] ); |
| 512 | $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid |
| 513 | $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; |
| 514 | // Seed new directories with a blank index.html, to prevent crawling... |
| 515 | if ( !empty( $params['noListing'] ) && !is_file( "{$fsDirectory}/index.html" ) ) { |
| 516 | $this->trapWarnings(); |
| 517 | $bytes = file_put_contents( "{$fsDirectory}/index.html", $this->indexHtmlPrivate() ); |
| 518 | $this->untrapWarnings(); |
| 519 | if ( $bytes === false ) { |
| 520 | $status->fatal( 'backend-fail-create', $params['dir'] . '/index.html' ); |
| 521 | } |
| 522 | } |
| 523 | // Add a .htaccess file to the root of the container... |
| 524 | if ( !empty( $params['noAccess'] ) && !is_file( "{$contRoot}/.htaccess" ) ) { |
| 525 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 526 | $bytes = @file_put_contents( "{$contRoot}/.htaccess", $this->htaccessPrivate() ); |
| 527 | if ( $bytes === false ) { |
| 528 | $storeDir = "mwstore://{$this->name}/{$shortCont}"; |
| 529 | $status->fatal( 'backend-fail-create', "{$storeDir}/.htaccess" ); |
| 530 | } |
| 531 | } |
| 532 | |
| 533 | return $status; |
| 534 | } |
| 535 | |
| 536 | /** @inheritDoc */ |
| 537 | protected function doPublishInternal( $fullCont, $dirRel, array $params ) { |
| 538 | $status = $this->newStatus(); |
| 539 | [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] ); |
| 540 | $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid |
| 541 | $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; |
| 542 | // Unseed new directories with a blank index.html, to allow crawling... |
| 543 | if ( !empty( $params['listing'] ) && is_file( "{$fsDirectory}/index.html" ) ) { |
| 544 | $exists = ( file_get_contents( "{$fsDirectory}/index.html" ) === $this->indexHtmlPrivate() ); |
| 545 | if ( $exists && !$this->unlink( "{$fsDirectory}/index.html" ) ) { // reverse secure() |
| 546 | $status->fatal( 'backend-fail-delete', $params['dir'] . '/index.html' ); |
| 547 | } |
| 548 | } |
| 549 | // Remove the .htaccess file from the root of the container... |
| 550 | if ( !empty( $params['access'] ) && is_file( "{$contRoot}/.htaccess" ) ) { |
| 551 | $exists = ( file_get_contents( "{$contRoot}/.htaccess" ) === $this->htaccessPrivate() ); |
| 552 | if ( $exists && !$this->unlink( "{$contRoot}/.htaccess" ) ) { // reverse secure() |
| 553 | $storeDir = "mwstore://{$this->name}/{$shortCont}"; |
| 554 | $status->fatal( 'backend-fail-delete', "{$storeDir}/.htaccess" ); |
| 555 | } |
| 556 | } |
| 557 | |
| 558 | return $status; |
| 559 | } |
| 560 | |
| 561 | /** @inheritDoc */ |
| 562 | protected function doCleanInternal( $fullCont, $dirRel, array $params ) { |
| 563 | $status = $this->newStatus(); |
| 564 | [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] ); |
| 565 | $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid |
| 566 | $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; |
| 567 | |
| 568 | $this->rmdir( $fsDirectory ); |
| 569 | |
| 570 | return $status; |
| 571 | } |
| 572 | |
| 573 | /** @inheritDoc */ |
| 574 | protected function doGetFileStat( array $params ) { |
| 575 | $fsSrcPath = $this->resolveToFSPath( $params['src'] ); |
| 576 | if ( $fsSrcPath === null ) { |
| 577 | return self::RES_ERROR; // invalid storage path |
| 578 | } |
| 579 | |
| 580 | $this->trapWarnings(); // don't trust 'false' if there were errors |
| 581 | $stat = is_file( $fsSrcPath ) ? stat( $fsSrcPath ) : false; // regular files only |
| 582 | $hadError = $this->untrapWarnings(); |
| 583 | |
| 584 | if ( is_array( $stat ) ) { |
| 585 | $ct = new ConvertibleTimestamp( $stat['mtime'] ); |
| 586 | |
| 587 | return [ |
| 588 | 'mtime' => $ct->getTimestamp( TS::MW ), |
| 589 | 'size' => $stat['size'] |
| 590 | ]; |
| 591 | } |
| 592 | |
| 593 | return $hadError ? self::RES_ERROR : self::RES_ABSENT; |
| 594 | } |
| 595 | |
| 596 | /** @inheritDoc */ |
| 597 | protected function doClearCache( ?array $paths = null ) { |
| 598 | if ( is_array( $paths ) ) { |
| 599 | foreach ( $paths as $path ) { |
| 600 | $fsPath = $this->resolveToFSPath( $path ); |
| 601 | if ( $fsPath !== null ) { |
| 602 | clearstatcache( true, $fsPath ); |
| 603 | $this->usableDirCache->clear( $fsPath ); |
| 604 | } |
| 605 | } |
| 606 | } else { |
| 607 | clearstatcache( true ); // clear the PHP file stat cache |
| 608 | $this->usableDirCache->clear(); |
| 609 | } |
| 610 | } |
| 611 | |
| 612 | /** @inheritDoc */ |
| 613 | protected function doDirectoryExists( $fullCont, $dirRel, array $params ) { |
| 614 | [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] ); |
| 615 | $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid |
| 616 | $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; |
| 617 | |
| 618 | $this->trapWarnings(); // don't trust 'false' if there were errors |
| 619 | $exists = is_dir( $fsDirectory ); |
| 620 | $hadError = $this->untrapWarnings(); |
| 621 | |
| 622 | return $hadError ? self::RES_ERROR : $exists; |
| 623 | } |
| 624 | |
| 625 | /** |
| 626 | * @see FileBackendStore::getDirectoryListInternal() |
| 627 | * @param string $fullCont |
| 628 | * @param string $dirRel |
| 629 | * @param array $params |
| 630 | * @return array|FSFileBackendDirList|null |
| 631 | */ |
| 632 | public function getDirectoryListInternal( $fullCont, $dirRel, array $params ) { |
| 633 | [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] ); |
| 634 | $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid |
| 635 | $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; |
| 636 | |
| 637 | $list = new FSFileBackendDirList( $fsDirectory, $params ); |
| 638 | $error = $list->getLastError(); |
| 639 | if ( $error !== null ) { |
| 640 | if ( $this->isFileNotFoundError( $error ) ) { |
| 641 | $this->logger->info( __METHOD__ . ": non-existant directory: '$fsDirectory'" ); |
| 642 | |
| 643 | return []; // nothing under this dir |
| 644 | } elseif ( is_dir( $fsDirectory ) ) { |
| 645 | $this->logger->warning( __METHOD__ . ": unreadable directory: '$fsDirectory'" ); |
| 646 | |
| 647 | return self::RES_ERROR; // bad permissions? |
| 648 | } else { |
| 649 | $this->logger->warning( __METHOD__ . ": unreachable directory: '$fsDirectory'" ); |
| 650 | |
| 651 | return self::RES_ERROR; |
| 652 | } |
| 653 | } |
| 654 | |
| 655 | return $list; |
| 656 | } |
| 657 | |
| 658 | /** |
| 659 | * @see FileBackendStore::getFileListInternal() |
| 660 | * @param string $fullCont |
| 661 | * @param string $dirRel |
| 662 | * @param array $params |
| 663 | * @return array|FSFileBackendFileList|null |
| 664 | */ |
| 665 | public function getFileListInternal( $fullCont, $dirRel, array $params ) { |
| 666 | [ , $shortCont, ] = FileBackend::splitStoragePath( $params['dir'] ); |
| 667 | $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid |
| 668 | $fsDirectory = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; |
| 669 | |
| 670 | $list = new FSFileBackendFileList( $fsDirectory, $params ); |
| 671 | $error = $list->getLastError(); |
| 672 | if ( $error !== null ) { |
| 673 | if ( $this->isFileNotFoundError( $error ) ) { |
| 674 | $this->logger->info( __METHOD__ . ": non-existent directory: '$fsDirectory'" ); |
| 675 | |
| 676 | return []; // nothing under this dir |
| 677 | } elseif ( is_dir( $fsDirectory ) ) { |
| 678 | $this->logger->warning( __METHOD__ . |
| 679 | ": unreadable directory: '$fsDirectory': $error" ); |
| 680 | |
| 681 | return self::RES_ERROR; // bad permissions? |
| 682 | } else { |
| 683 | $this->logger->warning( __METHOD__ . |
| 684 | ": unreachable directory: '$fsDirectory': $error" ); |
| 685 | |
| 686 | return self::RES_ERROR; |
| 687 | } |
| 688 | } |
| 689 | |
| 690 | return $list; |
| 691 | } |
| 692 | |
| 693 | /** @inheritDoc */ |
| 694 | protected function doGetLocalReferenceMulti( array $params ) { |
| 695 | $fsFiles = []; // (path => FSFile) |
| 696 | |
| 697 | foreach ( $params['srcs'] as $src ) { |
| 698 | $source = $this->resolveToFSPath( $src ); |
| 699 | if ( $source === null ) { |
| 700 | $fsFiles[$src] = self::RES_ERROR; // invalid path |
| 701 | continue; |
| 702 | } |
| 703 | |
| 704 | $this->trapWarnings(); // don't trust 'false' if there were errors |
| 705 | $isFile = is_file( $source ); // regular files only |
| 706 | $hadError = $this->untrapWarnings(); |
| 707 | |
| 708 | if ( $isFile ) { |
| 709 | $fsFiles[$src] = new FSFile( $source ); |
| 710 | } elseif ( $hadError ) { |
| 711 | $fsFiles[$src] = self::RES_ERROR; |
| 712 | } else { |
| 713 | $fsFiles[$src] = self::RES_ABSENT; |
| 714 | } |
| 715 | } |
| 716 | |
| 717 | return $fsFiles; |
| 718 | } |
| 719 | |
| 720 | /** @inheritDoc */ |
| 721 | protected function doGetLocalCopyMulti( array $params ) { |
| 722 | $tmpFiles = []; // (path => TempFSFile) |
| 723 | |
| 724 | foreach ( $params['srcs'] as $src ) { |
| 725 | $source = $this->resolveToFSPath( $src ); |
| 726 | if ( $source === null ) { |
| 727 | $tmpFiles[$src] = self::RES_ERROR; // invalid path |
| 728 | continue; |
| 729 | } |
| 730 | // Create a new temporary file with the same extension... |
| 731 | $ext = FileBackend::extensionFromPath( $src ); |
| 732 | $tmpFile = $this->tmpFileFactory->newTempFSFile( 'localcopy_', $ext ); |
| 733 | if ( !$tmpFile ) { |
| 734 | $tmpFiles[$src] = self::RES_ERROR; |
| 735 | continue; |
| 736 | } |
| 737 | |
| 738 | $tmpPath = $tmpFile->getPath(); |
| 739 | // Copy the source file over the temp file |
| 740 | $this->trapWarnings(); // don't trust 'false' if there were errors |
| 741 | $isFile = is_file( $source ); // regular files only |
| 742 | $copySuccess = $isFile ? copy( $source, $tmpPath ) : false; |
| 743 | $hadError = $this->untrapWarnings(); |
| 744 | |
| 745 | if ( $copySuccess ) { |
| 746 | $this->chmod( $tmpPath ); |
| 747 | $tmpFiles[$src] = $tmpFile; |
| 748 | } elseif ( $hadError ) { |
| 749 | $tmpFiles[$src] = self::RES_ERROR; // copy failed |
| 750 | } else { |
| 751 | $tmpFiles[$src] = self::RES_ABSENT; |
| 752 | } |
| 753 | } |
| 754 | |
| 755 | return $tmpFiles; |
| 756 | } |
| 757 | |
| 758 | /** @inheritDoc */ |
| 759 | public function addShellboxInputFile( BoxedCommand $command, string $boxedName, |
| 760 | array $params |
| 761 | ) { |
| 762 | $path = $this->resolveToFSPath( $params['src'] ); |
| 763 | if ( $path === null ) { |
| 764 | return $this->newStatus( 'backend-fail-invalidpath', $params['src'] ); |
| 765 | } |
| 766 | $command->inputFileFromFile( $boxedName, $path ); |
| 767 | return $this->newStatus(); |
| 768 | } |
| 769 | |
| 770 | /** @inheritDoc */ |
| 771 | protected function directoriesAreVirtual() { |
| 772 | return false; |
| 773 | } |
| 774 | |
| 775 | /** |
| 776 | * @param FSFileOpHandle[] $fileOpHandles |
| 777 | * |
| 778 | * @return StatusValue[] |
| 779 | */ |
| 780 | protected function doExecuteOpHandlesInternal( array $fileOpHandles ) { |
| 781 | $statuses = []; |
| 782 | |
| 783 | $pipes = []; |
| 784 | foreach ( $fileOpHandles as $index => $fileOpHandle ) { |
| 785 | $pipes[$index] = popen( $fileOpHandle->cmd, 'r' ); |
| 786 | } |
| 787 | |
| 788 | $errs = []; |
| 789 | foreach ( $pipes as $index => $pipe ) { |
| 790 | // Result will be empty on success in *NIX. On Windows, |
| 791 | // it may be something like " 1 file(s) [copied|moved].". |
| 792 | $errs[$index] = stream_get_contents( $pipe ); |
| 793 | fclose( $pipe ); |
| 794 | } |
| 795 | |
| 796 | foreach ( $fileOpHandles as $index => $fileOpHandle ) { |
| 797 | $status = $this->newStatus(); |
| 798 | $function = $fileOpHandle->callback; |
| 799 | $function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd ); |
| 800 | $statuses[$index] = $status; |
| 801 | } |
| 802 | |
| 803 | return $statuses; |
| 804 | } |
| 805 | |
| 806 | /** |
| 807 | * @param string $fsPath Absolute file system path |
| 808 | * @return string Absolute file system path on the same device |
| 809 | */ |
| 810 | private function makeStagingPath( $fsPath ) { |
| 811 | $time = dechex( time() ); // make it easy to find old orphans |
| 812 | $hash = \Wikimedia\base_convert( md5( basename( $fsPath ) ), 16, 36, 25 ); |
| 813 | $unique = \Wikimedia\base_convert( bin2hex( random_bytes( 16 ) ), 16, 36, 25 ); |
| 814 | |
| 815 | return dirname( $fsPath ) . "/.{$time}_{$hash}_{$unique}.tmpfsfile"; |
| 816 | } |
| 817 | |
| 818 | /** |
| 819 | * @param string $fsSrcPath Absolute file system path |
| 820 | * @param string $fsDstPath Absolute file system path |
| 821 | * @param bool $ignoreMissing Whether to no-op if the source file is non-existent |
| 822 | * @return string Command |
| 823 | */ |
| 824 | private function makeCopyCommand( $fsSrcPath, $fsDstPath, $ignoreMissing ) { |
| 825 | // Use copy+rename since (a) this clears xattrs, (b) threads still reading the old |
| 826 | // inode are unaffected since it writes to a new inode, and (c) new threads reading |
| 827 | // the file will either totally see the old version or totally see the new version |
| 828 | $fsStagePath = $this->makeStagingPath( $fsDstPath ); |
| 829 | $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsSrcPath ) ); |
| 830 | $encStage = Shellbox::escape( $this->cleanPathSlashes( $fsStagePath ) ); |
| 831 | $encDst = Shellbox::escape( $this->cleanPathSlashes( $fsDstPath ) ); |
| 832 | if ( $this->isWindows ) { |
| 833 | // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/copy |
| 834 | // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/move |
| 835 | $cmdWrite = "COPY /B /Y $encSrc $encStage 2>&1 && MOVE /Y $encStage $encDst 2>&1"; |
| 836 | $cmd = $ignoreMissing ? "IF EXIST $encSrc $cmdWrite" : $cmdWrite; |
| 837 | } else { |
| 838 | // https://manpages.debian.org/buster/coreutils/cp.1.en.html |
| 839 | // https://manpages.debian.org/buster/coreutils/mv.1.en.html |
| 840 | $cmdWrite = "cp $encSrc $encStage 2>&1 && mv $encStage $encDst 2>&1"; |
| 841 | $cmd = $ignoreMissing ? "test -f $encSrc && $cmdWrite" : $cmdWrite; |
| 842 | // Clean up permissions on any newly created destination file |
| 843 | $octalPermissions = '0' . decoct( $this->fileMode ); |
| 844 | if ( strlen( $octalPermissions ) == 4 ) { |
| 845 | $cmd .= " && chmod $octalPermissions $encDst 2>/dev/null"; |
| 846 | } |
| 847 | } |
| 848 | |
| 849 | return $cmd; |
| 850 | } |
| 851 | |
| 852 | /** |
| 853 | * @param string $fsSrcPath Absolute file system path |
| 854 | * @param string $fsDstPath Absolute file system path |
| 855 | * @param bool $ignoreMissing Whether to no-op if the source file is non-existent |
| 856 | * @return string Command |
| 857 | */ |
| 858 | private function makeMoveCommand( $fsSrcPath, $fsDstPath, $ignoreMissing = false ) { |
| 859 | // https://manpages.debian.org/buster/coreutils/mv.1.en.html |
| 860 | // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/move |
| 861 | $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsSrcPath ) ); |
| 862 | $encDst = Shellbox::escape( $this->cleanPathSlashes( $fsDstPath ) ); |
| 863 | if ( $this->isWindows ) { |
| 864 | $writeCmd = "MOVE /Y $encSrc $encDst 2>&1"; |
| 865 | $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd; |
| 866 | } else { |
| 867 | $writeCmd = "mv -f $encSrc $encDst 2>&1"; |
| 868 | $cmd = $ignoreMissing ? "test -f $encSrc && $writeCmd" : $writeCmd; |
| 869 | } |
| 870 | |
| 871 | return $cmd; |
| 872 | } |
| 873 | |
| 874 | /** |
| 875 | * @param string $fsPath Absolute file system path |
| 876 | * @param bool $ignoreMissing Whether to no-op if the file is non-existent |
| 877 | * @return string Command |
| 878 | */ |
| 879 | private function makeUnlinkCommand( $fsPath, $ignoreMissing = false ) { |
| 880 | // https://manpages.debian.org/buster/coreutils/rm.1.en.html |
| 881 | // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/del |
| 882 | $encSrc = Shellbox::escape( $this->cleanPathSlashes( $fsPath ) ); |
| 883 | if ( $this->isWindows ) { |
| 884 | $writeCmd = "DEL /Q $encSrc 2>&1"; |
| 885 | $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd; |
| 886 | } else { |
| 887 | $cmd = $ignoreMissing ? "rm -f $encSrc 2>&1" : "rm $encSrc 2>&1"; |
| 888 | } |
| 889 | |
| 890 | return $cmd; |
| 891 | } |
| 892 | |
| 893 | /** |
| 894 | * Chmod a file, suppressing the warnings |
| 895 | * |
| 896 | * @param string $fsPath Absolute file system path |
| 897 | * @return bool Success |
| 898 | */ |
| 899 | protected function chmod( $fsPath ) { |
| 900 | if ( $this->isWindows ) { |
| 901 | return true; |
| 902 | } |
| 903 | |
| 904 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 905 | $ok = @chmod( $fsPath, $this->fileMode ); |
| 906 | |
| 907 | return $ok; |
| 908 | } |
| 909 | |
| 910 | /** |
| 911 | * Unlink a file, suppressing the warnings |
| 912 | * |
| 913 | * @param string $fsPath Absolute file system path |
| 914 | * @return bool Success |
| 915 | */ |
| 916 | protected function unlink( $fsPath ) { |
| 917 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 918 | $ok = @unlink( $fsPath ); |
| 919 | clearstatcache( true, $fsPath ); |
| 920 | |
| 921 | return $ok; |
| 922 | } |
| 923 | |
| 924 | /** |
| 925 | * Remove an empty directory, suppressing the warnings |
| 926 | * |
| 927 | * @param string $fsDirectory Absolute file system path |
| 928 | * @return bool Success |
| 929 | */ |
| 930 | protected function rmdir( $fsDirectory ) { |
| 931 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 932 | $ok = @rmdir( $fsDirectory ); // remove directory if empty |
| 933 | clearstatcache( true, $fsDirectory ); |
| 934 | |
| 935 | return $ok; |
| 936 | } |
| 937 | |
| 938 | /** |
| 939 | * @param array $params Parameters for FileBackend 'create' operation |
| 940 | * @return TempFSFile|null |
| 941 | */ |
| 942 | protected function newTempFileWithContent( array $params ) { |
| 943 | $tempFile = $this->tmpFileFactory->newTempFSFile( 'create_', 'tmp' ); |
| 944 | if ( !$tempFile ) { |
| 945 | return null; |
| 946 | } |
| 947 | |
| 948 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 949 | if ( @file_put_contents( $tempFile->getPath(), $params['content'] ) === false ) { |
| 950 | $tempFile = null; |
| 951 | } |
| 952 | |
| 953 | return $tempFile; |
| 954 | } |
| 955 | |
| 956 | /** |
| 957 | * Return the text of an index.html file to hide directory listings |
| 958 | * |
| 959 | * @return string |
| 960 | */ |
| 961 | protected function indexHtmlPrivate() { |
| 962 | return ''; |
| 963 | } |
| 964 | |
| 965 | /** |
| 966 | * Return the text of a .htaccess file to make a directory private |
| 967 | * |
| 968 | * @return string |
| 969 | */ |
| 970 | protected function htaccessPrivate() { |
| 971 | return "Require all denied\n" . |
| 972 | "Satisfy All\n"; |
| 973 | } |
| 974 | |
| 975 | /** |
| 976 | * Clean up directory separators for the given OS |
| 977 | * |
| 978 | * @param string $fsPath |
| 979 | * @return string |
| 980 | */ |
| 981 | protected function cleanPathSlashes( $fsPath ) { |
| 982 | return ( $this->isWindows ) ? strtr( $fsPath, '/', '\\' ) : $fsPath; |
| 983 | } |
| 984 | |
| 985 | /** |
| 986 | * Listen for E_WARNING errors and track whether any that happen |
| 987 | * |
| 988 | * @param string|null $regexIgnore Optional regex of errors to ignore |
| 989 | */ |
| 990 | protected function trapWarnings( $regexIgnore = null ) { |
| 991 | $this->warningTrapStack[] = false; |
| 992 | set_error_handler( function ( $errno, $errstr ) use ( $regexIgnore ) { |
| 993 | if ( $regexIgnore === null || !preg_match( $regexIgnore, $errstr ) ) { |
| 994 | $this->logger->error( $errstr ); |
| 995 | $this->warningTrapStack[count( $this->warningTrapStack ) - 1] = true; |
| 996 | } |
| 997 | return true; // suppress from PHP handler |
| 998 | }, E_WARNING ); |
| 999 | } |
| 1000 | |
| 1001 | /** |
| 1002 | * Track E_WARNING errors but ignore any that correspond to ENOENT "No such file or directory" |
| 1003 | */ |
| 1004 | protected function trapWarningsIgnoringNotFound() { |
| 1005 | $this->trapWarnings( $this->getFileNotFoundRegex() ); |
| 1006 | } |
| 1007 | |
| 1008 | /** |
| 1009 | * Stop listening for E_WARNING errors and get whether any happened |
| 1010 | * |
| 1011 | * @return bool Whether any warnings happened |
| 1012 | */ |
| 1013 | protected function untrapWarnings() { |
| 1014 | restore_error_handler(); |
| 1015 | |
| 1016 | return array_pop( $this->warningTrapStack ); |
| 1017 | } |
| 1018 | |
| 1019 | /** |
| 1020 | * Get a regex matching file not found errors |
| 1021 | * |
| 1022 | * @return string |
| 1023 | */ |
| 1024 | protected function getFileNotFoundRegex() { |
| 1025 | static $regex; |
| 1026 | if ( $regex === null ) { |
| 1027 | // "No such file or directory": string literal in spl_directory.c etc. |
| 1028 | $alternatives = [ ': No such file or directory' ]; |
| 1029 | if ( $this->isWindows ) { |
| 1030 | // 2 = The system cannot find the file specified. |
| 1031 | // 3 = The system cannot find the path specified. |
| 1032 | $alternatives[] = ' \(code: [23]\)'; |
| 1033 | } |
| 1034 | if ( function_exists( 'pcntl_strerror' ) ) { |
| 1035 | $alternatives[] = preg_quote( ': ' . pcntl_strerror( 2 ), '/' ); |
| 1036 | } elseif ( function_exists( 'socket_strerror' ) && defined( 'SOCKET_ENOENT' ) ) { |
| 1037 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal False positive on Windows only |
| 1038 | $alternatives[] = preg_quote( ': ' . socket_strerror( SOCKET_ENOENT ), '/' ); |
| 1039 | } |
| 1040 | $regex = '/(' . implode( '|', $alternatives ) . ')$/'; |
| 1041 | } |
| 1042 | return $regex; |
| 1043 | } |
| 1044 | |
| 1045 | /** |
| 1046 | * Determine whether a given error message is a file not found error. |
| 1047 | * |
| 1048 | * @param string $error |
| 1049 | * @return bool |
| 1050 | */ |
| 1051 | protected function isFileNotFoundError( $error ) { |
| 1052 | return (bool)preg_match( $this->getFileNotFoundRegex(), $error ); |
| 1053 | } |
| 1054 | } |
| 1055 | |
| 1056 | /** @deprecated class alias since 1.43 */ |
| 1057 | class_alias( FSFileBackend::class, 'FSFileBackend' ); |