Completed
Branch master (098997)
by
unknown
28:44
created

FSFileBackend::doPrepareInternal()   D

Complexity

Conditions 9
Paths 16

Size

Total Lines 26
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
eloc 20
nc 16
nop 3
dl 0
loc 26
rs 4.909
c 0
b 0
f 0
1
<?php
2
/**
3
 * File system based backend.
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 * @ingroup FileBackend
22
 * @author Aaron Schulz
23
 */
24
25
/**
26
 * @brief Class for a file system (FS) based file backend.
27
 *
28
 * All "containers" each map to a directory under the backend's base directory.
29
 * For backwards-compatibility, some container paths can be set to custom paths.
30
 * The domain ID will not be used in any custom paths, so this should be avoided.
31
 *
32
 * Having directories with thousands of files will diminish performance.
33
 * Sharding can be accomplished by using FileRepo-style hash paths.
34
 *
35
 * StatusValue messages should avoid mentioning the internal FS paths.
36
 * PHP warnings are assumed to be logged rather than output.
37
 *
38
 * @ingroup FileBackend
39
 * @since 1.19
40
 */
41
class FSFileBackend extends FileBackendStore {
42
	/** @var string Directory holding the container directories */
43
	protected $basePath;
44
45
	/** @var array Map of container names to root paths for custom container paths */
46
	protected $containerPaths = [];
47
48
	/** @var int File permission mode */
49
	protected $fileMode;
50
	/** @var int File permission mode */
51
	protected $dirMode;
52
53
	/** @var string Required OS username to own files */
54
	protected $fileOwner;
55
56
	/** @var bool */
57
	protected $isWindows;
58
	/** @var string OS username running this script */
59
	protected $currentUser;
60
61
	/** @var array */
62
	protected $hadWarningErrors = [];
63
64
	/**
65
	 * @see FileBackendStore::__construct()
66
	 * Additional $config params include:
67
	 *   - basePath       : File system directory that holds containers.
68
	 *   - containerPaths : Map of container names to custom file system directories.
69
	 *                      This should only be used for backwards-compatibility.
70
	 *   - fileMode       : Octal UNIX file permissions to use on files stored.
71
	 *   - directoryMode  : Octal UNIX file permissions to use on directories created.
72
	 * @param array $config
73
	 */
74
	public function __construct( array $config ) {
75
		parent::__construct( $config );
76
77
		$this->isWindows = ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' );
78
		// Remove any possible trailing slash from directories
79
		if ( isset( $config['basePath'] ) ) {
80
			$this->basePath = rtrim( $config['basePath'], '/' ); // remove trailing slash
81
		} else {
82
			$this->basePath = null; // none; containers must have explicit paths
83
		}
84
85
		if ( isset( $config['containerPaths'] ) ) {
86
			$this->containerPaths = (array)$config['containerPaths'];
87
			foreach ( $this->containerPaths as &$path ) {
88
				$path = rtrim( $path, '/' ); // remove trailing slash
89
			}
90
		}
91
92
		$this->fileMode = isset( $config['fileMode'] ) ? $config['fileMode'] : 0644;
93
		$this->dirMode = isset( $config['directoryMode'] ) ? $config['directoryMode'] : 0777;
94
		if ( isset( $config['fileOwner'] ) && function_exists( 'posix_getuid' ) ) {
95
			$this->fileOwner = $config['fileOwner'];
96
			// cache this, assuming it doesn't change
97
			$this->currentUser = posix_getpwuid( posix_getuid() )['name'];
98
		}
99
	}
100
101
	public function getFeatures() {
102
		return !$this->isWindows ? FileBackend::ATTR_UNICODE_PATHS : 0;
103
	}
104
105
	protected function resolveContainerPath( $container, $relStoragePath ) {
106
		// Check that container has a root directory
107
		if ( isset( $this->containerPaths[$container] ) || isset( $this->basePath ) ) {
108
			// Check for sane relative paths (assume the base paths are OK)
109
			if ( $this->isLegalRelPath( $relStoragePath ) ) {
110
				return $relStoragePath;
111
			}
112
		}
113
114
		return null;
115
	}
116
117
	/**
118
	 * Sanity check a relative file system path for validity
119
	 *
120
	 * @param string $path Normalized relative path
121
	 * @return bool
122
	 */
123
	protected function isLegalRelPath( $path ) {
124
		// Check for file names longer than 255 chars
125
		if ( preg_match( '![^/]{256}!', $path ) ) { // ext3/NTFS
126
			return false;
127
		}
128
		if ( $this->isWindows ) { // NTFS
129
			return !preg_match( '![:*?"<>|]!', $path );
130
		} else {
131
			return true;
132
		}
133
	}
134
135
	/**
136
	 * Given the short (unresolved) and full (resolved) name of
137
	 * a container, return the file system path of the container.
138
	 *
139
	 * @param string $shortCont
140
	 * @param string $fullCont
141
	 * @return string|null
142
	 */
143
	protected function containerFSRoot( $shortCont, $fullCont ) {
144
		if ( isset( $this->containerPaths[$shortCont] ) ) {
145
			return $this->containerPaths[$shortCont];
146
		} elseif ( isset( $this->basePath ) ) {
147
			return "{$this->basePath}/{$fullCont}";
148
		}
149
150
		return null; // no container base path defined
151
	}
152
153
	/**
154
	 * Get the absolute file system path for a storage path
155
	 *
156
	 * @param string $storagePath Storage path
157
	 * @return string|null
158
	 */
159
	protected function resolveToFSPath( $storagePath ) {
160
		list( $fullCont, $relPath ) = $this->resolveStoragePathReal( $storagePath );
161
		if ( $relPath === null ) {
162
			return null; // invalid
163
		}
164
		list( , $shortCont, ) = FileBackend::splitStoragePath( $storagePath );
165
		$fsPath = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
166
		if ( $relPath != '' ) {
167
			$fsPath .= "/{$relPath}";
168
		}
169
170
		return $fsPath;
171
	}
172
173
	public function isPathUsableInternal( $storagePath ) {
174
		$fsPath = $this->resolveToFSPath( $storagePath );
175
		if ( $fsPath === null ) {
176
			return false; // invalid
177
		}
178
		$parentDir = dirname( $fsPath );
179
180
		if ( file_exists( $fsPath ) ) {
181
			$ok = is_file( $fsPath ) && is_writable( $fsPath );
182
		} else {
183
			$ok = is_dir( $parentDir ) && is_writable( $parentDir );
184
		}
185
186
		if ( $this->fileOwner !== null && $this->currentUser !== $this->fileOwner ) {
187
			$ok = false;
188
			trigger_error( __METHOD__ . ": PHP process owner is not '{$this->fileOwner}'." );
189
		}
190
191
		return $ok;
192
	}
193
194
	protected function doCreateInternal( array $params ) {
195
		$status = $this->newStatus();
196
197
		$dest = $this->resolveToFSPath( $params['dst'] );
198
		if ( $dest === null ) {
199
			$status->fatal( 'backend-fail-invalidpath', $params['dst'] );
200
201
			return $status;
202
		}
203
204
		if ( !empty( $params['async'] ) ) { // deferred
205
			$tempFile = TempFSFile::factory( 'create_', 'tmp', $this->tmpDirectory );
206
			if ( !$tempFile ) {
207
				$status->fatal( 'backend-fail-create', $params['dst'] );
208
209
				return $status;
210
			}
211
			$this->trapWarnings();
212
			$bytes = file_put_contents( $tempFile->getPath(), $params['content'] );
213
			$this->untrapWarnings();
214
			if ( $bytes === false ) {
215
				$status->fatal( 'backend-fail-create', $params['dst'] );
216
217
				return $status;
218
			}
219
			$cmd = implode( ' ', [
220
				$this->isWindows ? 'COPY /B /Y' : 'cp', // (binary, overwrite)
221
				escapeshellarg( $this->cleanPathSlashes( $tempFile->getPath() ) ),
222
				escapeshellarg( $this->cleanPathSlashes( $dest ) )
223
			] );
224
			$handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
225
				if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
226
					$status->fatal( 'backend-fail-create', $params['dst'] );
227
					trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
228
				}
229
			};
230
			$status->value = new FSFileOpHandle( $this, $params, $handler, $cmd, $dest );
231
			$tempFile->bind( $status->value );
232
		} else { // immediate write
233
			$this->trapWarnings();
234
			$bytes = file_put_contents( $dest, $params['content'] );
235
			$this->untrapWarnings();
236
			if ( $bytes === false ) {
237
				$status->fatal( 'backend-fail-create', $params['dst'] );
238
239
				return $status;
240
			}
241
			$this->chmod( $dest );
242
		}
243
244
		return $status;
245
	}
246
247
	protected function doStoreInternal( array $params ) {
248
		$status = $this->newStatus();
249
250
		$dest = $this->resolveToFSPath( $params['dst'] );
251
		if ( $dest === null ) {
252
			$status->fatal( 'backend-fail-invalidpath', $params['dst'] );
253
254
			return $status;
255
		}
256
257
		if ( !empty( $params['async'] ) ) { // deferred
258
			$cmd = implode( ' ', [
259
				$this->isWindows ? 'COPY /B /Y' : 'cp', // (binary, overwrite)
260
				escapeshellarg( $this->cleanPathSlashes( $params['src'] ) ),
261
				escapeshellarg( $this->cleanPathSlashes( $dest ) )
262
			] );
263 View Code Duplication
			$handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
264
				if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
265
					$status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
266
					trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
267
				}
268
			};
269
			$status->value = new FSFileOpHandle( $this, $params, $handler, $cmd, $dest );
270
		} else { // immediate write
271
			$this->trapWarnings();
272
			$ok = copy( $params['src'], $dest );
273
			$this->untrapWarnings();
274
			// In some cases (at least over NFS), copy() returns true when it fails
275
			if ( !$ok || ( filesize( $params['src'] ) !== filesize( $dest ) ) ) {
276
				if ( $ok ) { // PHP bug
277
					unlink( $dest ); // remove broken file
278
					trigger_error( __METHOD__ . ": copy() failed but returned true." );
279
				}
280
				$status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
281
282
				return $status;
283
			}
284
			$this->chmod( $dest );
285
		}
286
287
		return $status;
288
	}
289
290
	protected function doCopyInternal( array $params ) {
291
		$status = $this->newStatus();
292
293
		$source = $this->resolveToFSPath( $params['src'] );
294
		if ( $source === null ) {
295
			$status->fatal( 'backend-fail-invalidpath', $params['src'] );
296
297
			return $status;
298
		}
299
300
		$dest = $this->resolveToFSPath( $params['dst'] );
301
		if ( $dest === null ) {
302
			$status->fatal( 'backend-fail-invalidpath', $params['dst'] );
303
304
			return $status;
305
		}
306
307 View Code Duplication
		if ( !is_file( $source ) ) {
308
			if ( empty( $params['ignoreMissingSource'] ) ) {
309
				$status->fatal( 'backend-fail-copy', $params['src'] );
310
			}
311
312
			return $status; // do nothing; either OK or bad status
313
		}
314
315
		if ( !empty( $params['async'] ) ) { // deferred
316
			$cmd = implode( ' ', [
317
				$this->isWindows ? 'COPY /B /Y' : 'cp', // (binary, overwrite)
318
				escapeshellarg( $this->cleanPathSlashes( $source ) ),
319
				escapeshellarg( $this->cleanPathSlashes( $dest ) )
320
			] );
321 View Code Duplication
			$handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
322
				if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
323
					$status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
324
					trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
325
				}
326
			};
327
			$status->value = new FSFileOpHandle( $this, $params, $handler, $cmd, $dest );
328
		} else { // immediate write
329
			$this->trapWarnings();
330
			$ok = ( $source === $dest ) ? true : copy( $source, $dest );
331
			$this->untrapWarnings();
332
			// In some cases (at least over NFS), copy() returns true when it fails
333
			if ( !$ok || ( filesize( $source ) !== filesize( $dest ) ) ) {
334
				if ( $ok ) { // PHP bug
335
					$this->trapWarnings();
336
					unlink( $dest ); // remove broken file
337
					$this->untrapWarnings();
338
					trigger_error( __METHOD__ . ": copy() failed but returned true." );
339
				}
340
				$status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
341
342
				return $status;
343
			}
344
			$this->chmod( $dest );
345
		}
346
347
		return $status;
348
	}
349
350
	protected function doMoveInternal( array $params ) {
351
		$status = $this->newStatus();
352
353
		$source = $this->resolveToFSPath( $params['src'] );
354
		if ( $source === null ) {
355
			$status->fatal( 'backend-fail-invalidpath', $params['src'] );
356
357
			return $status;
358
		}
359
360
		$dest = $this->resolveToFSPath( $params['dst'] );
361
		if ( $dest === null ) {
362
			$status->fatal( 'backend-fail-invalidpath', $params['dst'] );
363
364
			return $status;
365
		}
366
367 View Code Duplication
		if ( !is_file( $source ) ) {
368
			if ( empty( $params['ignoreMissingSource'] ) ) {
369
				$status->fatal( 'backend-fail-move', $params['src'] );
370
			}
371
372
			return $status; // do nothing; either OK or bad status
373
		}
374
375
		if ( !empty( $params['async'] ) ) { // deferred
376
			$cmd = implode( ' ', [
377
				$this->isWindows ? 'MOVE /Y' : 'mv', // (overwrite)
378
				escapeshellarg( $this->cleanPathSlashes( $source ) ),
379
				escapeshellarg( $this->cleanPathSlashes( $dest ) )
380
			] );
381 View Code Duplication
			$handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
382
				if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
383
					$status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
384
					trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
385
				}
386
			};
387
			$status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
388
		} else { // immediate write
389
			$this->trapWarnings();
390
			$ok = ( $source === $dest ) ? true : rename( $source, $dest );
391
			$this->untrapWarnings();
392
			clearstatcache(); // file no longer at source
393
			if ( !$ok ) {
394
				$status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
395
396
				return $status;
397
			}
398
		}
399
400
		return $status;
401
	}
402
403
	protected function doDeleteInternal( array $params ) {
404
		$status = $this->newStatus();
405
406
		$source = $this->resolveToFSPath( $params['src'] );
407
		if ( $source === null ) {
408
			$status->fatal( 'backend-fail-invalidpath', $params['src'] );
409
410
			return $status;
411
		}
412
413 View Code Duplication
		if ( !is_file( $source ) ) {
414
			if ( empty( $params['ignoreMissingSource'] ) ) {
415
				$status->fatal( 'backend-fail-delete', $params['src'] );
416
			}
417
418
			return $status; // do nothing; either OK or bad status
419
		}
420
421
		if ( !empty( $params['async'] ) ) { // deferred
422
			$cmd = implode( ' ', [
423
				$this->isWindows ? 'DEL' : 'unlink',
424
				escapeshellarg( $this->cleanPathSlashes( $source ) )
425
			] );
426
			$handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
427
				if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
428
					$status->fatal( 'backend-fail-delete', $params['src'] );
429
					trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
430
				}
431
			};
432
			$status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
433
		} else { // immediate write
434
			$this->trapWarnings();
435
			$ok = unlink( $source );
436
			$this->untrapWarnings();
437
			if ( !$ok ) {
438
				$status->fatal( 'backend-fail-delete', $params['src'] );
439
440
				return $status;
441
			}
442
		}
443
444
		return $status;
445
	}
446
447
	/**
448
	 * @param string $fullCont
449
	 * @param string $dirRel
450
	 * @param array $params
451
	 * @return StatusValue
452
	 */
453
	protected function doPrepareInternal( $fullCont, $dirRel, array $params ) {
454
		$status = $this->newStatus();
455
		list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
456
		$contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
457
		$dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
458
		$existed = is_dir( $dir ); // already there?
459
		// Create the directory and its parents as needed...
460
		$this->trapWarnings();
461
		if ( !$existed && !mkdir( $dir, $this->dirMode, true ) && !is_dir( $dir ) ) {
462
			$this->logger->error( __METHOD__ . ": cannot create directory $dir" );
463
			$status->fatal( 'directorycreateerror', $params['dir'] ); // fails on races
464
		} elseif ( !is_writable( $dir ) ) {
465
			$this->logger->error( __METHOD__ . ": directory $dir is read-only" );
466
			$status->fatal( 'directoryreadonlyerror', $params['dir'] );
467
		} elseif ( !is_readable( $dir ) ) {
468
			$this->logger->error( __METHOD__ . ": directory $dir is not readable" );
469
			$status->fatal( 'directorynotreadableerror', $params['dir'] );
470
		}
471
		$this->untrapWarnings();
472
		// Respect any 'noAccess' or 'noListing' flags...
473
		if ( is_dir( $dir ) && !$existed ) {
474
			$status->merge( $this->doSecureInternal( $fullCont, $dirRel, $params ) );
475
		}
476
477
		return $status;
478
	}
479
480
	protected function doSecureInternal( $fullCont, $dirRel, array $params ) {
481
		$status = $this->newStatus();
482
		list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
483
		$contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
484
		$dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
485
		// Seed new directories with a blank index.html, to prevent crawling...
486
		if ( !empty( $params['noListing'] ) && !file_exists( "{$dir}/index.html" ) ) {
487
			$this->trapWarnings();
488
			$bytes = file_put_contents( "{$dir}/index.html", $this->indexHtmlPrivate() );
489
			$this->untrapWarnings();
490
			if ( $bytes === false ) {
491
				$status->fatal( 'backend-fail-create', $params['dir'] . '/index.html' );
492
			}
493
		}
494
		// Add a .htaccess file to the root of the container...
495 View Code Duplication
		if ( !empty( $params['noAccess'] ) && !file_exists( "{$contRoot}/.htaccess" ) ) {
496
			$this->trapWarnings();
497
			$bytes = file_put_contents( "{$contRoot}/.htaccess", $this->htaccessPrivate() );
498
			$this->untrapWarnings();
499
			if ( $bytes === false ) {
500
				$storeDir = "mwstore://{$this->name}/{$shortCont}";
501
				$status->fatal( 'backend-fail-create', "{$storeDir}/.htaccess" );
502
			}
503
		}
504
505
		return $status;
506
	}
507
508
	protected function doPublishInternal( $fullCont, $dirRel, array $params ) {
509
		$status = $this->newStatus();
510
		list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
511
		$contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
512
		$dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
513
		// Unseed new directories with a blank index.html, to allow crawling...
514 View Code Duplication
		if ( !empty( $params['listing'] ) && is_file( "{$dir}/index.html" ) ) {
515
			$exists = ( file_get_contents( "{$dir}/index.html" ) === $this->indexHtmlPrivate() );
516
			$this->trapWarnings();
517
			if ( $exists && !unlink( "{$dir}/index.html" ) ) { // reverse secure()
518
				$status->fatal( 'backend-fail-delete', $params['dir'] . '/index.html' );
519
			}
520
			$this->untrapWarnings();
521
		}
522
		// Remove the .htaccess file from the root of the container...
523 View Code Duplication
		if ( !empty( $params['access'] ) && is_file( "{$contRoot}/.htaccess" ) ) {
524
			$exists = ( file_get_contents( "{$contRoot}/.htaccess" ) === $this->htaccessPrivate() );
525
			$this->trapWarnings();
526
			if ( $exists && !unlink( "{$contRoot}/.htaccess" ) ) { // reverse secure()
527
				$storeDir = "mwstore://{$this->name}/{$shortCont}";
528
				$status->fatal( 'backend-fail-delete', "{$storeDir}/.htaccess" );
529
			}
530
			$this->untrapWarnings();
531
		}
532
533
		return $status;
534
	}
535
536 View Code Duplication
	protected function doCleanInternal( $fullCont, $dirRel, array $params ) {
537
		$status = $this->newStatus();
538
		list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
539
		$contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
540
		$dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
541
		$this->trapWarnings();
542
		if ( is_dir( $dir ) ) {
543
			rmdir( $dir ); // remove directory if empty
544
		}
545
		$this->untrapWarnings();
546
547
		return $status;
548
	}
549
550
	protected function doGetFileStat( array $params ) {
551
		$source = $this->resolveToFSPath( $params['src'] );
552
		if ( $source === null ) {
553
			return false; // invalid storage path
554
		}
555
556
		$this->trapWarnings(); // don't trust 'false' if there were errors
557
		$stat = is_file( $source ) ? stat( $source ) : false; // regular files only
558
		$hadError = $this->untrapWarnings();
559
560
		if ( $stat ) {
561
			$ct = new ConvertibleTimestamp( $stat['mtime'] );
562
563
			return [
564
				'mtime' => $ct->getTimestamp( TS_MW ),
565
				'size' => $stat['size']
566
			];
567
		} elseif ( !$hadError ) {
568
			return false; // file does not exist
569
		} else {
570
			return null; // failure
571
		}
572
	}
573
574
	protected function doClearCache( array $paths = null ) {
575
		clearstatcache(); // clear the PHP file stat cache
576
	}
577
578 View Code Duplication
	protected function doDirectoryExists( $fullCont, $dirRel, array $params ) {
579
		list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
580
		$contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
581
		$dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
582
583
		$this->trapWarnings(); // don't trust 'false' if there were errors
584
		$exists = is_dir( $dir );
585
		$hadError = $this->untrapWarnings();
586
587
		return $hadError ? null : $exists;
588
	}
589
590
	/**
591
	 * @see FileBackendStore::getDirectoryListInternal()
592
	 * @param string $fullCont
593
	 * @param string $dirRel
594
	 * @param array $params
595
	 * @return array|FSFileBackendDirList|null
596
	 */
597 View Code Duplication
	public function getDirectoryListInternal( $fullCont, $dirRel, array $params ) {
598
		list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
599
		$contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
600
		$dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
601
		$exists = is_dir( $dir );
602
		if ( !$exists ) {
603
			$this->logger->warning( __METHOD__ . "() given directory does not exist: '$dir'\n" );
604
605
			return []; // nothing under this dir
606
		} elseif ( !is_readable( $dir ) ) {
607
			$this->logger->warning( __METHOD__ . "() given directory is unreadable: '$dir'\n" );
608
609
			return null; // bad permissions?
610
		}
611
612
		return new FSFileBackendDirList( $dir, $params );
613
	}
614
615
	/**
616
	 * @see FileBackendStore::getFileListInternal()
617
	 * @param string $fullCont
618
	 * @param string $dirRel
619
	 * @param array $params
620
	 * @return array|FSFileBackendFileList|null
621
	 */
622 View Code Duplication
	public function getFileListInternal( $fullCont, $dirRel, array $params ) {
623
		list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
624
		$contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
625
		$dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
626
		$exists = is_dir( $dir );
627
		if ( !$exists ) {
628
			$this->logger->warning( __METHOD__ . "() given directory does not exist: '$dir'\n" );
629
630
			return []; // nothing under this dir
631
		} elseif ( !is_readable( $dir ) ) {
632
			$this->logger->warning( __METHOD__ . "() given directory is unreadable: '$dir'\n" );
633
634
			return null; // bad permissions?
635
		}
636
637
		return new FSFileBackendFileList( $dir, $params );
638
	}
639
640
	protected function doGetLocalReferenceMulti( array $params ) {
641
		$fsFiles = []; // (path => FSFile)
642
643
		foreach ( $params['srcs'] as $src ) {
644
			$source = $this->resolveToFSPath( $src );
645
			if ( $source === null || !is_file( $source ) ) {
646
				$fsFiles[$src] = null; // invalid path or file does not exist
647
			} else {
648
				$fsFiles[$src] = new FSFile( $source );
649
			}
650
		}
651
652
		return $fsFiles;
653
	}
654
655
	protected function doGetLocalCopyMulti( array $params ) {
656
		$tmpFiles = []; // (path => TempFSFile)
657
658
		foreach ( $params['srcs'] as $src ) {
659
			$source = $this->resolveToFSPath( $src );
660
			if ( $source === null ) {
661
				$tmpFiles[$src] = null; // invalid path
662
			} else {
663
				// Create a new temporary file with the same extension...
664
				$ext = FileBackend::extensionFromPath( $src );
665
				$tmpFile = TempFSFile::factory( 'localcopy_', $ext, $this->tmpDirectory );
666
				if ( !$tmpFile ) {
667
					$tmpFiles[$src] = null;
668
				} else {
669
					$tmpPath = $tmpFile->getPath();
670
					// Copy the source file over the temp file
671
					$this->trapWarnings();
672
					$ok = copy( $source, $tmpPath );
673
					$this->untrapWarnings();
674
					if ( !$ok ) {
675
						$tmpFiles[$src] = null;
676
					} else {
677
						$this->chmod( $tmpPath );
678
						$tmpFiles[$src] = $tmpFile;
679
					}
680
				}
681
			}
682
		}
683
684
		return $tmpFiles;
685
	}
686
687
	protected function directoriesAreVirtual() {
688
		return false;
689
	}
690
691
	/**
692
	 * @param FSFileOpHandle[] $fileOpHandles
693
	 *
694
	 * @return StatusValue[]
695
	 */
696
	protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
697
		$statuses = [];
698
699
		$pipes = [];
700
		foreach ( $fileOpHandles as $index => $fileOpHandle ) {
701
			$pipes[$index] = popen( "{$fileOpHandle->cmd} 2>&1", 'r' );
702
		}
703
704
		$errs = [];
705
		foreach ( $pipes as $index => $pipe ) {
706
			// Result will be empty on success in *NIX. On Windows,
707
			// it may be something like "        1 file(s) [copied|moved].".
708
			$errs[$index] = stream_get_contents( $pipe );
709
			fclose( $pipe );
710
		}
711
712
		foreach ( $fileOpHandles as $index => $fileOpHandle ) {
713
			$status = $this->newStatus();
714
			$function = $fileOpHandle->call;
715
			$function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd );
716
			$statuses[$index] = $status;
717
			if ( $status->isOK() && $fileOpHandle->chmodPath ) {
718
				$this->chmod( $fileOpHandle->chmodPath );
719
			}
720
		}
721
722
		clearstatcache(); // files changed
723
		return $statuses;
724
	}
725
726
	/**
727
	 * Chmod a file, suppressing the warnings
728
	 *
729
	 * @param string $path Absolute file system path
730
	 * @return bool Success
731
	 */
732
	protected function chmod( $path ) {
733
		$this->trapWarnings();
734
		$ok = chmod( $path, $this->fileMode );
735
		$this->untrapWarnings();
736
737
		return $ok;
738
	}
739
740
	/**
741
	 * Return the text of an index.html file to hide directory listings
742
	 *
743
	 * @return string
744
	 */
745
	protected function indexHtmlPrivate() {
746
		return '';
747
	}
748
749
	/**
750
	 * Return the text of a .htaccess file to make a directory private
751
	 *
752
	 * @return string
753
	 */
754
	protected function htaccessPrivate() {
755
		return "Deny from all\n";
756
	}
757
758
	/**
759
	 * Clean up directory separators for the given OS
760
	 *
761
	 * @param string $path FS path
762
	 * @return string
763
	 */
764
	protected function cleanPathSlashes( $path ) {
765
		return $this->isWindows ? strtr( $path, '/', '\\' ) : $path;
766
	}
767
768
	/**
769
	 * Listen for E_WARNING errors and track whether any happen
770
	 */
771
	protected function trapWarnings() {
772
		$this->hadWarningErrors[] = false; // push to stack
773
		set_error_handler( [ $this, 'handleWarning' ], E_WARNING );
774
	}
775
776
	/**
777
	 * Stop listening for E_WARNING errors and return true if any happened
778
	 *
779
	 * @return bool
780
	 */
781
	protected function untrapWarnings() {
782
		restore_error_handler(); // restore previous handler
783
		return array_pop( $this->hadWarningErrors ); // pop from stack
784
	}
785
786
	/**
787
	 * @param int $errno
788
	 * @param string $errstr
789
	 * @return bool
790
	 * @access private
791
	 */
792
	public function handleWarning( $errno, $errstr ) {
793
		$this->logger->error( $errstr ); // more detailed error logging
794
		$this->hadWarningErrors[count( $this->hadWarningErrors ) - 1] = true;
795
796
		return true; // suppress from PHP handler
797
	}
798
}
799
800
/**
801
 * @see FileBackendStoreOpHandle
802
 */
803
class FSFileOpHandle extends FileBackendStoreOpHandle {
804
	public $cmd; // string; shell command
805
	public $chmodPath; // string; file to chmod
806
807
	/**
808
	 * @param FSFileBackend $backend
809
	 * @param array $params
810
	 * @param callable $call
811
	 * @param string $cmd
812
	 * @param int|null $chmodPath
813
	 */
814
	public function __construct(
815
		FSFileBackend $backend, array $params, $call, $cmd, $chmodPath = null
816
	) {
817
		$this->backend = $backend;
818
		$this->params = $params;
819
		$this->call = $call;
820
		$this->cmd = $cmd;
821
		$this->chmodPath = $chmodPath;
822
	}
823
}
824
825
/**
826
 * Wrapper around RecursiveDirectoryIterator/DirectoryIterator that
827
 * catches exception or does any custom behavoir that we may want.
828
 * Do not use this class from places outside FSFileBackend.
829
 *
830
 * @ingroup FileBackend
831
 */
832
abstract class FSFileBackendList implements Iterator {
833
	/** @var Iterator */
834
	protected $iter;
835
836
	/** @var int */
837
	protected $suffixStart;
838
839
	/** @var int */
840
	protected $pos = 0;
841
842
	/** @var array */
843
	protected $params = [];
844
845
	/**
846
	 * @param string $dir File system directory
847
	 * @param array $params
848
	 */
849
	public function __construct( $dir, array $params ) {
850
		$path = realpath( $dir ); // normalize
851
		if ( $path === false ) {
852
			$path = $dir;
853
		}
854
		$this->suffixStart = strlen( $path ) + 1; // size of "path/to/dir/"
855
		$this->params = $params;
856
857
		try {
858
			$this->iter = $this->initIterator( $path );
859
		} catch ( UnexpectedValueException $e ) {
860
			$this->iter = null; // bad permissions? deleted?
861
		}
862
	}
863
864
	/**
865
	 * Return an appropriate iterator object to wrap
866
	 *
867
	 * @param string $dir File system directory
868
	 * @return Iterator
869
	 */
870
	protected function initIterator( $dir ) {
871
		if ( !empty( $this->params['topOnly'] ) ) { // non-recursive
872
			# Get an iterator that will get direct sub-nodes
873
			return new DirectoryIterator( $dir );
874
		} else { // recursive
875
			# Get an iterator that will return leaf nodes (non-directories)
876
			# RecursiveDirectoryIterator extends FilesystemIterator.
877
			# FilesystemIterator::SKIP_DOTS default is inconsistent in PHP 5.3.x.
878
			$flags = FilesystemIterator::CURRENT_AS_SELF | FilesystemIterator::SKIP_DOTS;
879
880
			return new RecursiveIteratorIterator(
881
				new RecursiveDirectoryIterator( $dir, $flags ),
882
				RecursiveIteratorIterator::CHILD_FIRST // include dirs
883
			);
884
		}
885
	}
886
887
	/**
888
	 * @see Iterator::key()
889
	 * @return int
890
	 */
891
	public function key() {
892
		return $this->pos;
893
	}
894
895
	/**
896
	 * @see Iterator::current()
897
	 * @return string|bool String or false
898
	 */
899
	public function current() {
900
		return $this->getRelPath( $this->iter->current()->getPathname() );
901
	}
902
903
	/**
904
	 * @see Iterator::next()
905
	 * @throws FileBackendError
906
	 */
907
	public function next() {
908
		try {
909
			$this->iter->next();
910
			$this->filterViaNext();
911
		} catch ( UnexpectedValueException $e ) { // bad permissions? deleted?
912
			throw new FileBackendError( "File iterator gave UnexpectedValueException." );
913
		}
914
		++$this->pos;
915
	}
916
917
	/**
918
	 * @see Iterator::rewind()
919
	 * @throws FileBackendError
920
	 */
921
	public function rewind() {
922
		$this->pos = 0;
923
		try {
924
			$this->iter->rewind();
925
			$this->filterViaNext();
926
		} catch ( UnexpectedValueException $e ) { // bad permissions? deleted?
927
			throw new FileBackendError( "File iterator gave UnexpectedValueException." );
928
		}
929
	}
930
931
	/**
932
	 * @see Iterator::valid()
933
	 * @return bool
934
	 */
935
	public function valid() {
936
		return $this->iter && $this->iter->valid();
937
	}
938
939
	/**
940
	 * Filter out items by advancing to the next ones
941
	 */
942
	protected function filterViaNext() {
943
	}
944
945
	/**
946
	 * Return only the relative path and normalize slashes to FileBackend-style.
947
	 * Uses the "real path" since the suffix is based upon that.
948
	 *
949
	 * @param string $dir
950
	 * @return string
951
	 */
952
	protected function getRelPath( $dir ) {
953
		$path = realpath( $dir );
954
		if ( $path === false ) {
955
			$path = $dir;
956
		}
957
958
		return strtr( substr( $path, $this->suffixStart ), '\\', '/' );
959
	}
960
}
961
962
class FSFileBackendDirList extends FSFileBackendList {
963
	protected function filterViaNext() {
964
		while ( $this->iter->valid() ) {
965
			if ( $this->iter->current()->isDot() || !$this->iter->current()->isDir() ) {
966
				$this->iter->next(); // skip non-directories and dot files
967
			} else {
968
				break;
969
			}
970
		}
971
	}
972
}
973
974
class FSFileBackendFileList extends FSFileBackendList {
975
	protected function filterViaNext() {
976
		while ( $this->iter->valid() ) {
977
			if ( !$this->iter->current()->isFile() ) {
978
				$this->iter->next(); // skip non-files and dot files
979
			} else {
980
				break;
981
			}
982
		}
983
	}
984
}
985