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