Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.10% covered (warning)
68.10%
474 / 696
47.44% covered (danger)
47.44%
37 / 78
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileBackendStore
68.20% covered (warning)
68.20%
474 / 695
47.44% covered (danger)
47.44%
37 / 78
2744.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 maxFileSizeInternal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isPathUsableInternal
n/a
0 / 0
n/a
0 / 0
0
 createInternal
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 doCreateInternal
n/a
0 / 0
n/a
0 / 0
0
 storeInternal
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 doStoreInternal
n/a
0 / 0
n/a
0 / 0
0
 copyInternal
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 doCopyInternal
n/a
0 / 0
n/a
0 / 0
0
 deleteInternal
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 doDeleteInternal
n/a
0 / 0
n/a
0 / 0
0
 moveInternal
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 doMoveInternal
n/a
0 / 0
n/a
0 / 0
0
 describeInternal
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 doDescribeInternal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 nullInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 concatenate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 doConcatenate
53.85% covered (warning)
53.85%
21 / 39
0.00% covered (danger)
0.00%
0 / 1
26.16
 doPrepare
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
4.59
 doPrepareInternal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doSecure
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
6.00
 doSecureInternal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doPublish
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
6.00
 doPublishInternal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doClean
68.00% covered (warning)
68.00%
17 / 25
0.00% covered (danger)
0.00%
0 / 1
11.65
 doCleanInternal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fileExists
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getFileTimestamp
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getFileSize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getFileStat
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
17
 ingestFreshFileStats
75.56% covered (warning)
75.56%
34 / 45
0.00% covered (danger)
0.00%
0 / 1
7.72
 doGetFileStat
n/a
0 / 0
n/a
0 / 0
0
 getFileContentsMulti
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 doGetFileContentsMulti
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getFileXAttributes
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 doGetFileXAttributes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFileSha1Base36
65.22% covered (warning)
65.22%
15 / 23
0.00% covered (danger)
0.00%
0 / 1
9.06
 doGetFileSha1Base36
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 getFileProps
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getLocalReferenceMulti
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
8
 doGetLocalReferenceMulti
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLocalCopyMulti
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 doGetLocalCopyMulti
n/a
0 / 0
n/a
0 / 0
0
 getFileHttpUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addShellboxInputFile
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 streamFile
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
4.59
 doStreamFile
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 directoryExists
25.00% covered (danger)
25.00%
4 / 16
0.00% covered (danger)
0.00%
0 / 1
21.19
 doDirectoryExists
n/a
0 / 0
n/a
0 / 0
0
 getDirectoryList
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
4.54
 getDirectoryListInternal
n/a
0 / 0
n/a
0 / 0
0
 getFileList
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
3.79
 getFileListInternal
n/a
0 / 0
n/a
0 / 0
0
 getOperationsInternal
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
3.00
 getPathsToLockForOpsInternal
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getScopedLocksForOps
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 doOperationsInternal
73.33% covered (warning)
73.33%
22 / 30
0.00% covered (danger)
0.00%
0 / 1
7.93
 doQuickOperationsInternal
75.00% covered (warning)
75.00%
24 / 32
0.00% covered (danger)
0.00%
0 / 1
11.56
 executeOpHandlesInternal
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
9.29
 doExecuteOpHandlesInternal
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 sanitizeOpHeaders
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 preloadCache
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 clearCache
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 doClearCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 preloadFileStat
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 doGetFileStatMulti
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 directoriesAreVirtual
n/a
0 / 0
n/a
0 / 0
0
 isValidShortContainerName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidContainerName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveStoragePath
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 resolveStoragePathReal
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getContainerShard
23.08% covered (danger)
23.08%
3 / 13
0.00% covered (danger)
0.00%
0 / 1
29.30
 isSingleShardPathInternal
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getContainerHashLevels
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
21.19
 getContainerSuffixes
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 fullContainerName
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 resolveContainerName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveContainerPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 containerCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setContainerCache
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 deleteContainerCache
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
3.69
 primeContainerCache
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
7.01
 doPrimeContainerCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fileCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFileCache
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
3.79
 deleteFileCache
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
 primeFileCache
44.83% covered (danger)
44.83%
13 / 29
0.00% covered (danger)
0.00%
0 / 1
31.32
 normalizeXAttributes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 setConcurrencyFlags
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
8.12
 getContentType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Base class for all backends using particular storage medium.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup FileBackend
8 */
9
10namespace Wikimedia\FileBackend;
11
12use InvalidArgumentException;
13use Shellbox\Command\BoxedCommand;
14use StatusValue;
15use Traversable;
16use Wikimedia\FileBackend\FileIteration\FileBackendStoreShardDirIterator;
17use Wikimedia\FileBackend\FileIteration\FileBackendStoreShardFileIterator;
18use Wikimedia\FileBackend\FileOpHandle\FileBackendStoreOpHandle;
19use Wikimedia\FileBackend\FileOps\CopyFileOp;
20use Wikimedia\FileBackend\FileOps\CreateFileOp;
21use Wikimedia\FileBackend\FileOps\DeleteFileOp;
22use Wikimedia\FileBackend\FileOps\DescribeFileOp;
23use Wikimedia\FileBackend\FileOps\FileOp;
24use Wikimedia\FileBackend\FileOps\MoveFileOp;
25use Wikimedia\FileBackend\FileOps\NullFileOp;
26use Wikimedia\FileBackend\FileOps\StoreFileOp;
27use Wikimedia\FileBackend\FSFile\FSFile;
28use Wikimedia\LockManager\LockManager;
29use Wikimedia\ObjectCache\BagOStuff;
30use Wikimedia\ObjectCache\EmptyBagOStuff;
31use Wikimedia\ObjectCache\MapCacheLRU;
32use Wikimedia\ObjectCache\WANObjectCache;
33use Wikimedia\Timestamp\ConvertibleTimestamp;
34use Wikimedia\Timestamp\TimestampFormat as TS;
35
36/**
37 * @brief Base class for all backends using particular storage medium.
38 *
39 * This class defines the methods as abstract that subclasses must implement.
40 * Outside callers should *not* use functions with "Internal" in the name.
41 *
42 * The FileBackend operations are implemented using basic functions
43 * such as storeInternal(), copyInternal(), deleteInternal() and the like.
44 * This class is also responsible for path resolution and sanitization.
45 *
46 * @stable to extend
47 * @ingroup FileBackend
48 * @since 1.19
49 */
50abstract class FileBackendStore extends FileBackend {
51    /** Persistent cache accessible to all relevant datacenters */
52    protected WANObjectCache $wanCache;
53    /** Persistent local server/host cache (e.g. APCu) */
54    protected BagOStuff $srvCache;
55    /** In-memory map of paths to small (RAM/disk) cache items */
56    protected MapCacheLRU $cheapCache;
57    /** In-memory map of paths to large (RAM/disk) cache items */
58    protected MapCacheLRU $expensiveCache;
59
60    /** Cache used for persistent file/container stat entries */
61    protected WANObjectCache $wanStatCache;
62    /** @deprecated Since 1.45 */
63    protected $memCache;
64
65    /** @var array<string,array> Map of container names to sharding config */
66    protected $shardViaHashLevels = [];
67
68    /** @var callable|null Method to get the MIME type of files */
69    protected $mimeCallback;
70
71    /** @var int Size in bytes, defaults to 32 GiB */
72    protected $maxFileSize = 32 * 1024 * 1024 * 1024;
73
74    protected const CACHE_TTL = 10; // integer; TTL in seconds for process cache entries
75    protected const CACHE_CHEAP_SIZE = 500; // integer; max entries in "cheap cache"
76    protected const CACHE_EXPENSIVE_SIZE = 5; // integer; max entries in "expensive cache"
77
78    /** @var false Idiom for "no result due to missing file" (since 1.34) */
79    protected const RES_ABSENT = false;
80    /** @var null Idiom for "no result due to I/O errors" (since 1.34) */
81    protected const RES_ERROR = null;
82
83    /** @var string File does not exist according to a normal stat query */
84    protected const ABSENT_NORMAL = 'FNE-N';
85    /** @var string File does not exist according to a "latest"-mode stat query */
86    protected const ABSENT_LATEST = 'FNE-L';
87
88    /**
89     * @see FileBackend::__construct()
90     * Additional $config params include:
91     *   - srvCache     : BagOStuff object to use for server-local persistent caching.
92     *   - wanCache     : WANObjectCache object to use for server-shared persistent caching.
93     *   - mimeCallback : Callback that takes (storage path, content, file system path) and
94     *                    returns the MIME type of the file or 'unknown/unknown'. The file
95     *                    system path parameter should be used if the content one is null.
96     *
97     * @stable to call
98     *
99     * @param array $config
100     */
101    public function __construct( array $config ) {
102        parent::__construct( $config );
103        $this->mimeCallback = $config['mimeCallback'] ?? null;
104        $this->srvCache = $config['srvCache'] ?? new EmptyBagOStuff();
105        $this->wanCache = $config['wanCache'] ?? WANObjectCache::newEmpty();
106        $this->wanStatCache = WANObjectCache::newEmpty(); // disabled by default
107        $this->memCache =& $this->wanStatCache; // compatability alias
108        $this->cheapCache = new MapCacheLRU( self::CACHE_CHEAP_SIZE );
109        $this->expensiveCache = new MapCacheLRU( self::CACHE_EXPENSIVE_SIZE );
110    }
111
112    /**
113     * Get the maximum allowable file size given backend
114     * medium restrictions and basic performance constraints.
115     * Do not call this function from places outside FileBackend and FileOp.
116     *
117     * @return int Bytes
118     */
119    final public function maxFileSizeInternal() {
120        return min( $this->maxFileSize, PHP_INT_MAX );
121    }
122
123    /**
124     * Check if a file can be created or changed at a given storage path in the backend
125     *
126     * This quickly checks to see if the path is already prepared for use to store files.
127     * FS backends should check that the parent directory exists and that files can be written
128     * to it for the current system user. Backends using key/value stores should check if the
129     * container exists and that objects can be written to it for the configured account/user.
130     *
131     * @param string $storagePath
132     * @return bool
133     */
134    abstract public function isPathUsableInternal( $storagePath );
135
136    /**
137     * Create a file in the backend with the given contents.
138     * This will overwrite any file that exists at the destination.
139     * Do not call this function from places outside FileBackend and FileOp.
140     *
141     * $params include:
142     *   - content     : the raw file contents
143     *   - dst         : destination storage path
144     *   - headers     : HTTP header name/value map
145     *   - async       : StatusValue will be returned immediately if supported.
146     *                   If the StatusValue is OK, then its value field will be
147     *                   set to a FileBackendStoreOpHandle object.
148     *   - dstExists   : Whether a file exists at the destination (optimization).
149     *                   Callers can use "false" if no existing file is being changed.
150     *
151     * @param array $params
152     * @return StatusValue
153     */
154    final public function createInternal( array $params ) {
155        if ( strlen( $params['content'] ) > $this->maxFileSizeInternal() ) {
156            $status = $this->newStatus( 'backend-fail-maxsize',
157                $params['dst'], $this->maxFileSizeInternal() );
158        } else {
159            $status = $this->doCreateInternal( $params );
160            $this->clearCache( [ $params['dst'] ] );
161            if ( $params['dstExists'] ?? true ) {
162                $this->deleteFileCache( $params['dst'] ); // persistent cache
163            }
164        }
165
166        return $status;
167    }
168
169    /**
170     * @see FileBackendStore::createInternal()
171     * @param array $params
172     * @return StatusValue
173     */
174    abstract protected function doCreateInternal( array $params );
175
176    /**
177     * Store a file into the backend from a file on disk.
178     * This will overwrite any file that exists at the destination.
179     * Do not call this function from places outside FileBackend and FileOp.
180     *
181     * $params include:
182     *   - src         : source path on disk
183     *   - dst         : destination storage path
184     *   - headers     : HTTP header name/value map
185     *   - async       : StatusValue will be returned immediately if supported.
186     *                   If the StatusValue is OK, then its value field will be
187     *                   set to a FileBackendStoreOpHandle object.
188     *   - dstExists   : Whether a file exists at the destination (optimization).
189     *                   Callers can use "false" if no existing file is being changed.
190     *
191     * @param array $params
192     * @return StatusValue
193     */
194    final public function storeInternal( array $params ) {
195        if ( filesize( $params['src'] ) > $this->maxFileSizeInternal() ) {
196            $status = $this->newStatus( 'backend-fail-maxsize',
197                $params['dst'], $this->maxFileSizeInternal() );
198        } else {
199            $status = $this->doStoreInternal( $params );
200            $this->clearCache( [ $params['dst'] ] );
201            if ( $params['dstExists'] ?? true ) {
202                $this->deleteFileCache( $params['dst'] ); // persistent cache
203            }
204        }
205
206        return $status;
207    }
208
209    /**
210     * @see FileBackendStore::storeInternal()
211     * @param array $params
212     * @return StatusValue
213     */
214    abstract protected function doStoreInternal( array $params );
215
216    /**
217     * Copy a file from one storage path to another in the backend.
218     * This will overwrite any file that exists at the destination.
219     * Do not call this function from places outside FileBackend and FileOp.
220     *
221     * $params include:
222     *   - src                 : source storage path
223     *   - dst                 : destination storage path
224     *   - ignoreMissingSource : do nothing if the source file does not exist
225     *   - headers             : HTTP header name/value map
226     *   - async               : StatusValue will be returned immediately if supported.
227     *                           If the StatusValue is OK, then its value field will be
228     *                           set to a FileBackendStoreOpHandle object.
229     *   - dstExists           : Whether a file exists at the destination (optimization).
230     *                           Callers can use "false" if no existing file is being changed.
231     *
232     * @param array $params
233     * @return StatusValue
234     */
235    final public function copyInternal( array $params ) {
236        $status = $this->doCopyInternal( $params );
237        $this->clearCache( [ $params['dst'] ] );
238        if ( $params['dstExists'] ?? true ) {
239            $this->deleteFileCache( $params['dst'] ); // persistent cache
240        }
241
242        return $status;
243    }
244
245    /**
246     * @see FileBackendStore::copyInternal()
247     * @param array $params
248     * @return StatusValue
249     */
250    abstract protected function doCopyInternal( array $params );
251
252    /**
253     * Delete a file at the storage path.
254     * Do not call this function from places outside FileBackend and FileOp.
255     *
256     * $params include:
257     *   - src                 : source storage path
258     *   - ignoreMissingSource : do nothing if the source file does not exist
259     *   - async               : StatusValue will be returned immediately if supported.
260     *                           If the StatusValue is OK, then its value field will be
261     *                           set to a FileBackendStoreOpHandle object.
262     *
263     * @param array $params
264     * @return StatusValue
265     */
266    final public function deleteInternal( array $params ) {
267        $status = $this->doDeleteInternal( $params );
268        $this->clearCache( [ $params['src'] ] );
269        $this->deleteFileCache( $params['src'] ); // persistent cache
270        return $status;
271    }
272
273    /**
274     * @see FileBackendStore::deleteInternal()
275     * @param array $params
276     * @return StatusValue
277     */
278    abstract protected function doDeleteInternal( array $params );
279
280    /**
281     * Move a file from one storage path to another in the backend.
282     * This will overwrite any file that exists at the destination.
283     * Do not call this function from places outside FileBackend and FileOp.
284     *
285     * $params include:
286     *   - src                 : source storage path
287     *   - dst                 : destination storage path
288     *   - ignoreMissingSource : do nothing if the source file does not exist
289     *   - headers             : HTTP header name/value map
290     *   - async               : StatusValue will be returned immediately if supported.
291     *                           If the StatusValue is OK, then its value field will be
292     *                           set to a FileBackendStoreOpHandle object.
293     *   - dstExists           : Whether a file exists at the destination (optimization).
294     *                           Callers can use "false" if no existing file is being changed.
295     *
296     * @param array $params
297     * @return StatusValue
298     */
299    final public function moveInternal( array $params ) {
300        $status = $this->doMoveInternal( $params );
301        $this->clearCache( [ $params['src'], $params['dst'] ] );
302        $this->deleteFileCache( $params['src'] ); // persistent cache
303        if ( $params['dstExists'] ?? true ) {
304            $this->deleteFileCache( $params['dst'] ); // persistent cache
305        }
306
307        return $status;
308    }
309
310    /**
311     * @see FileBackendStore::moveInternal()
312     * @param array $params
313     * @return StatusValue
314     */
315    abstract protected function doMoveInternal( array $params );
316
317    /**
318     * Alter metadata for a file at the storage path.
319     * Do not call this function from places outside FileBackend and FileOp.
320     *
321     * $params include:
322     *   - src           : source storage path
323     *   - headers       : HTTP header name/value map
324     *   - async         : StatusValue will be returned immediately if supported.
325     *                     If the StatusValue is OK, then its value field will be
326     *                     set to a FileBackendStoreOpHandle object.
327     *
328     * @param array $params
329     * @return StatusValue
330     */
331    final public function describeInternal( array $params ) {
332        if ( count( $params['headers'] ) ) {
333            $status = $this->doDescribeInternal( $params );
334            $this->clearCache( [ $params['src'] ] );
335            $this->deleteFileCache( $params['src'] ); // persistent cache
336        } else {
337            $status = $this->newStatus(); // nothing to do
338        }
339
340        return $status;
341    }
342
343    /**
344     * @see FileBackendStore::describeInternal()
345     * @stable to override
346     * @param array $params
347     * @return StatusValue
348     */
349    protected function doDescribeInternal( array $params ) {
350        return $this->newStatus();
351    }
352
353    /**
354     * No-op file operation that does nothing.
355     * Do not call this function from places outside FileBackend and FileOp.
356     *
357     * @param array $params
358     * @return StatusValue
359     */
360    final public function nullInternal( array $params ) {
361        return $this->newStatus();
362    }
363
364    /** @inheritDoc */
365    final public function concatenate( array $params ) {
366        $status = $this->newStatus();
367        // Try to lock the source files for the scope of this function
368        /** @noinspection PhpUnusedLocalVariableInspection */
369        $scopeLockS = $this->getScopedFileLocks( $params['srcs'], LockManager::LOCK_UW, $status );
370        if ( $status->isOK() ) {
371            // Actually do the file concatenation...
372            $hrStart = hrtime( true );
373            $status->merge( $this->doConcatenate( $params ) );
374            $sec = ( hrtime( true ) - $hrStart ) / 1e9;
375            if ( !$status->isOK() ) {
376                $this->logger->error( static::class . "-{$this->name}" .
377                    " failed to concatenate " . count( $params['srcs'] ) . " file(s) [$sec sec]" );
378            }
379        }
380
381        return $status;
382    }
383
384    /**
385     * @see FileBackendStore::concatenate()
386     * @stable to override
387     * @param array $params
388     * @return StatusValue
389     */
390    protected function doConcatenate( array $params ) {
391        $status = $this->newStatus();
392        $tmpPath = $params['dst'];
393        unset( $params['latest'] );
394
395        // Check that the specified temp file is valid...
396        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
397        $ok = ( @is_file( $tmpPath ) && @filesize( $tmpPath ) == 0 );
398        if ( !$ok ) { // not present or not empty
399            $status->fatal( 'backend-fail-opentemp', $tmpPath );
400
401            return $status;
402        }
403
404        // Get local FS versions of the chunks needed for the concatenation...
405        $fsFiles = $this->getLocalReferenceMulti( $params );
406        foreach ( $fsFiles as $path => &$fsFile ) {
407            if ( !$fsFile ) { // chunk failed to download?
408                $fsFile = $this->getLocalReference( [ 'src' => $path ] );
409                if ( !$fsFile ) { // retry failed?
410                    $status->fatal(
411                        $fsFile === self::RES_ERROR ? 'backend-fail-read' : 'backend-fail-notexists',
412                        $path
413                    );
414
415                    return $status;
416                }
417            }
418        }
419        unset( $fsFile ); // unset reference so we can reuse $fsFile
420
421        // Get a handle for the destination temp file
422        $tmpHandle = fopen( $tmpPath, 'ab' );
423        if ( $tmpHandle === false ) {
424            $status->fatal( 'backend-fail-opentemp', $tmpPath );
425
426            return $status;
427        }
428
429        // Build up the temp file using the source chunks (in order)...
430        foreach ( $fsFiles as $virtualSource => $fsFile ) {
431            // Get a handle to the local FS version
432            $sourceHandle = fopen( $fsFile->getPath(), 'rb' );
433            if ( $sourceHandle === false ) {
434                fclose( $tmpHandle );
435                $status->fatal( 'backend-fail-read', $virtualSource );
436
437                return $status;
438            }
439            // Append chunk to file (pass chunk size to avoid magic quotes)
440            if ( !stream_copy_to_stream( $sourceHandle, $tmpHandle ) ) {
441                fclose( $sourceHandle );
442                fclose( $tmpHandle );
443                $status->fatal( 'backend-fail-writetemp', $tmpPath );
444
445                return $status;
446            }
447            fclose( $sourceHandle );
448        }
449        if ( !fclose( $tmpHandle ) ) {
450            $status->fatal( 'backend-fail-closetemp', $tmpPath );
451
452            return $status;
453        }
454
455        clearstatcache(); // temp file changed
456
457        return $status;
458    }
459
460    /**
461     * @inheritDoc
462     */
463    final protected function doPrepare( array $params ) {
464        $status = $this->newStatus();
465
466        [ $fullCont, $dir, $shard ] = $this->resolveStoragePath( $params['dir'] );
467        if ( $dir === null ) {
468            $status->fatal( 'backend-fail-invalidpath', $params['dir'] );
469
470            return $status; // invalid storage path
471        }
472
473        if ( $shard !== null ) { // confined to a single container/shard
474            $status->merge( $this->doPrepareInternal( $fullCont, $dir, $params ) );
475        } else { // directory is on several shards
476            $this->logger->debug( __METHOD__ . ": iterating over all container shards." );
477            [ , $shortCont, ] = self::splitStoragePath( $params['dir'] );
478            foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
479                $status->merge( $this->doPrepareInternal( "{$fullCont}{$suffix}", $dir, $params ) );
480            }
481        }
482
483        return $status;
484    }
485
486    /**
487     * @see FileBackendStore::doPrepare()
488     * @stable to override
489     * @param string $fullCont
490     * @param string $dirRel
491     * @param array $params
492     * @return StatusValue Good status without value for success, fatal otherwise.
493     */
494    protected function doPrepareInternal( $fullCont, $dirRel, array $params ) {
495        return $this->newStatus();
496    }
497
498    /** @inheritDoc */
499    final protected function doSecure( array $params ) {
500        $status = $this->newStatus();
501
502        [ $fullCont, $dir, $shard ] = $this->resolveStoragePath( $params['dir'] );
503        if ( $dir === null ) {
504            $status->fatal( 'backend-fail-invalidpath', $params['dir'] );
505
506            return $status; // invalid storage path
507        }
508
509        if ( $shard !== null ) { // confined to a single container/shard
510            $status->merge( $this->doSecureInternal( $fullCont, $dir, $params ) );
511        } else { // directory is on several shards
512            $this->logger->debug( __METHOD__ . ": iterating over all container shards." );
513            [ , $shortCont, ] = self::splitStoragePath( $params['dir'] );
514            foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
515                $status->merge( $this->doSecureInternal( "{$fullCont}{$suffix}", $dir, $params ) );
516            }
517        }
518
519        return $status;
520    }
521
522    /**
523     * @see FileBackendStore::doSecure()
524     * @stable to override
525     * @param string $fullCont
526     * @param string $dirRel
527     * @param array $params
528     * @return StatusValue Good status without value for success, fatal otherwise.
529     */
530    protected function doSecureInternal( $fullCont, $dirRel, array $params ) {
531        return $this->newStatus();
532    }
533
534    /** @inheritDoc */
535    final protected function doPublish( array $params ) {
536        $status = $this->newStatus();
537
538        [ $fullCont, $dir, $shard ] = $this->resolveStoragePath( $params['dir'] );
539        if ( $dir === null ) {
540            $status->fatal( 'backend-fail-invalidpath', $params['dir'] );
541
542            return $status; // invalid storage path
543        }
544
545        if ( $shard !== null ) { // confined to a single container/shard
546            $status->merge( $this->doPublishInternal( $fullCont, $dir, $params ) );
547        } else { // directory is on several shards
548            $this->logger->debug( __METHOD__ . ": iterating over all container shards." );
549            [ , $shortCont, ] = self::splitStoragePath( $params['dir'] );
550            foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
551                $status->merge( $this->doPublishInternal( "{$fullCont}{$suffix}", $dir, $params ) );
552            }
553        }
554
555        return $status;
556    }
557
558    /**
559     * @see FileBackendStore::doPublish()
560     * @stable to override
561     * @param string $fullCont
562     * @param string $dirRel
563     * @param array $params
564     * @return StatusValue
565     */
566    protected function doPublishInternal( $fullCont, $dirRel, array $params ) {
567        return $this->newStatus();
568    }
569
570    /** @inheritDoc */
571    final protected function doClean( array $params ) {
572        $status = $this->newStatus();
573
574        // Recursive: first delete all empty subdirs recursively
575        if ( !empty( $params['recursive'] ) && !$this->directoriesAreVirtual() ) {
576            $subDirsRel = $this->getTopDirectoryList( [ 'dir' => $params['dir'] ] );
577            if ( $subDirsRel !== null ) { // no errors
578                foreach ( $subDirsRel as $subDirRel ) {
579                    $subDir = $params['dir'] . "/{$subDirRel}"; // full path
580                    $status->merge( $this->doClean( [ 'dir' => $subDir ] + $params ) );
581                }
582                unset( $subDirsRel ); // free directory for rmdir() on Windows (for FS backends)
583            }
584        }
585
586        [ $fullCont, $dir, $shard ] = $this->resolveStoragePath( $params['dir'] );
587        if ( $dir === null ) {
588            $status->fatal( 'backend-fail-invalidpath', $params['dir'] );
589
590            return $status; // invalid storage path
591        }
592
593        // Attempt to lock this directory...
594        $filesLockEx = [ $params['dir'] ];
595        /** @noinspection PhpUnusedLocalVariableInspection */
596        $scopedLockE = $this->getScopedFileLocks( $filesLockEx, LockManager::LOCK_EX, $status );
597        if ( !$status->isOK() ) {
598            return $status; // abort
599        }
600
601        if ( $shard !== null ) { // confined to a single container/shard
602            $status->merge( $this->doCleanInternal( $fullCont, $dir, $params ) );
603            $this->deleteContainerCache( $fullCont ); // purge cache
604        } else { // directory is on several shards
605            $this->logger->debug( __METHOD__ . ": iterating over all container shards." );
606            [ , $shortCont, ] = self::splitStoragePath( $params['dir'] );
607            foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
608                $status->merge( $this->doCleanInternal( "{$fullCont}{$suffix}", $dir, $params ) );
609                $this->deleteContainerCache( "{$fullCont}{$suffix}" ); // purge cache
610            }
611        }
612
613        return $status;
614    }
615
616    /**
617     * @see FileBackendStore::doClean()
618     * @stable to override
619     * @param string $fullCont
620     * @param string $dirRel
621     * @param array $params
622     * @return StatusValue
623     */
624    protected function doCleanInternal( $fullCont, $dirRel, array $params ) {
625        return $this->newStatus();
626    }
627
628    /** @inheritDoc */
629    final public function fileExists( array $params ) {
630        $stat = $this->getFileStat( $params );
631        if ( is_array( $stat ) ) {
632            return true;
633        }
634
635        return $stat === self::RES_ABSENT ? false : self::EXISTENCE_ERROR;
636    }
637
638    /** @inheritDoc */
639    final public function getFileTimestamp( array $params ) {
640        $stat = $this->getFileStat( $params );
641        if ( is_array( $stat ) ) {
642            return $stat['mtime'];
643        }
644
645        return self::TIMESTAMP_FAIL; // all failure cases
646    }
647
648    /** @inheritDoc */
649    final public function getFileSize( array $params ) {
650        $stat = $this->getFileStat( $params );
651        if ( is_array( $stat ) ) {
652            return $stat['size'];
653        }
654
655        return self::SIZE_FAIL; // all failure cases
656    }
657
658    /** @inheritDoc */
659    final public function getFileStat( array $params ) {
660        $path = self::normalizeStoragePath( $params['src'] );
661        if ( $path === null ) {
662            return self::STAT_ERROR; // invalid storage path
663        }
664
665        // Whether to bypass cache except for process cache entries loaded directly from
666        // high consistency backend queries (caller handles any cache flushing and locking)
667        $latest = !empty( $params['latest'] );
668        // Whether to ignore cache entries missing the SHA-1 field for existing files
669        $requireSHA1 = !empty( $params['requireSHA1'] );
670
671        $stat = $this->cheapCache->getField( $path, 'stat', self::CACHE_TTL );
672        // Load the persistent stat cache into process cache if needed
673        if ( !$latest ) {
674            if (
675                // File stat is not in process cache
676                $stat === null ||
677                // Key/value store backends might opportunistically set file stat process
678                // cache entries from object listings that do not include the SHA-1. In that
679                // case, loading the persistent stat cache will likely yield the SHA-1.
680                ( $requireSHA1 && is_array( $stat ) && !isset( $stat['sha1'] ) )
681            ) {
682                $this->primeFileCache( [ $path ] );
683                // Get any newly process-cached entry
684                $stat = $this->cheapCache->getField( $path, 'stat', self::CACHE_TTL );
685            }
686        }
687
688        if ( is_array( $stat ) ) {
689            if (
690                ( !$latest || !empty( $stat['latest'] ) ) &&
691                ( !$requireSHA1 || isset( $stat['sha1'] ) )
692            ) {
693                return $stat;
694            }
695        } elseif ( $stat === self::ABSENT_LATEST ) {
696            return self::STAT_ABSENT;
697        } elseif ( $stat === self::ABSENT_NORMAL ) {
698            if ( !$latest ) {
699                return self::STAT_ABSENT;
700            }
701        }
702
703        // Load the file stat from the backend and update caches
704        $stat = $this->doGetFileStat( $params );
705        $this->ingestFreshFileStats( [ $path => $stat ], $latest );
706
707        if ( is_array( $stat ) ) {
708            return $stat;
709        }
710
711        return $stat === self::RES_ERROR ? self::STAT_ERROR : self::STAT_ABSENT;
712    }
713
714    /**
715     * Ingest file stat entries that just came from querying the backend (not cache)
716     *
717     * @param array<string,array|false|null> $stats Map of storage path => {@see doGetFileStat} result
718     * @param bool $latest Whether doGetFileStat()/doGetFileStatMulti() had the 'latest' flag
719     * @return bool Whether all files have non-error stat replies
720     */
721    final protected function ingestFreshFileStats( array $stats, $latest ) {
722        $success = true;
723
724        foreach ( $stats as $path => $stat ) {
725            if ( is_array( $stat ) ) {
726                // Strongly consistent backends might automatically set this flag
727                $stat['latest'] ??= $latest;
728
729                $this->cheapCache->setField( $path, 'stat', $stat );
730                if ( isset( $stat['sha1'] ) ) {
731                    // Some backends store the SHA-1 hash as metadata
732                    $this->cheapCache->setField(
733                        $path,
734                        'sha1',
735                        [ 'hash' => $stat['sha1'], 'latest' => $latest ]
736                    );
737                }
738                if ( isset( $stat['xattr'] ) ) {
739                    // Some backends store custom headers/metadata
740                    $stat['xattr'] = self::normalizeXAttributes( $stat['xattr'] );
741                    $this->cheapCache->setField(
742                        $path,
743                        'xattr',
744                        [ 'map' => $stat['xattr'], 'latest' => $latest ]
745                    );
746                }
747                // Update persistent cache (@TODO: set all entries in one batch)
748                $this->setFileCache( $path, $stat );
749            } elseif ( $stat === self::RES_ABSENT ) {
750                $this->cheapCache->setField(
751                    $path,
752                    'stat',
753                    $latest ? self::ABSENT_LATEST : self::ABSENT_NORMAL
754                );
755                $this->cheapCache->setField(
756                    $path,
757                    'xattr',
758                    [ 'map' => self::XATTRS_FAIL, 'latest' => $latest ]
759                );
760                $this->cheapCache->setField(
761                    $path,
762                    'sha1',
763                    [ 'hash' => self::SHA1_FAIL, 'latest' => $latest ]
764                );
765                $this->logger->debug(
766                    __METHOD__ . ': File {path} does not exist',
767                    [ 'path' => $path ]
768                );
769            } else {
770                $success = false;
771                $this->logger->error(
772                    __METHOD__ . ': Could not stat file {path}',
773                    [ 'path' => $path ]
774                );
775            }
776        }
777
778        return $success;
779    }
780
781    /**
782     * @see FileBackendStore::getFileStat()
783     * @param array $params
784     * @return array|false|null
785     */
786    abstract protected function doGetFileStat( array $params );
787
788    /** @inheritDoc */
789    public function getFileContentsMulti( array $params ) {
790        $params = $this->setConcurrencyFlags( $params );
791        $contents = $this->doGetFileContentsMulti( $params );
792        foreach ( $contents as $path => $content ) {
793            if ( !is_string( $content ) ) {
794                $contents[$path] = self::CONTENT_FAIL; // used for all failure cases
795            }
796        }
797
798        return $contents;
799    }
800
801    /**
802     * @see FileBackendStore::getFileContentsMulti()
803     * @stable to override
804     * @param array $params
805     * @return string[]|bool[]|null[] Map of (path => string, false (missing), or null (error))
806     */
807    protected function doGetFileContentsMulti( array $params ) {
808        $contents = [];
809        foreach ( $this->doGetLocalReferenceMulti( $params ) as $path => $fsFile ) {
810            if ( $fsFile instanceof FSFile ) {
811                // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
812                $content = @file_get_contents( $fsFile->getPath() );
813                $contents[$path] = is_string( $content ) ? $content : self::RES_ERROR;
814            } else {
815                // self::RES_ERROR or self::RES_ABSENT
816                $contents[$path] = $fsFile;
817            }
818        }
819
820        return $contents;
821    }
822
823    /** @inheritDoc */
824    final public function getFileXAttributes( array $params ) {
825        $path = self::normalizeStoragePath( $params['src'] );
826        if ( $path === null ) {
827            return self::XATTRS_FAIL; // invalid storage path
828        }
829        $latest = !empty( $params['latest'] ); // use latest data?
830        if ( $this->cheapCache->hasField( $path, 'xattr', self::CACHE_TTL ) ) {
831            $stat = $this->cheapCache->getField( $path, 'xattr' );
832            // If we want the latest data, check that this cached
833            // value was in fact fetched with the latest available data.
834            if ( !$latest || $stat['latest'] ) {
835                return $stat['map'];
836            }
837        }
838        $fields = $this->doGetFileXAttributes( $params );
839        if ( is_array( $fields ) ) {
840            $fields = self::normalizeXAttributes( $fields );
841            $this->cheapCache->setField(
842                $path,
843                'xattr',
844                [ 'map' => $fields, 'latest' => $latest ]
845            );
846        } elseif ( $fields === self::RES_ABSENT ) {
847            $this->cheapCache->setField(
848                $path,
849                'xattr',
850                [ 'map' => self::XATTRS_FAIL, 'latest' => $latest ]
851            );
852        } else {
853            $fields = self::XATTRS_FAIL; // used for all failure cases
854        }
855
856        return $fields;
857    }
858
859    /**
860     * @see FileBackendStore::getFileXAttributes()
861     * @stable to override
862     * @param array $params
863     * @return array[][]|false|null Attributes, false (missing file), or null (error)
864     */
865    protected function doGetFileXAttributes( array $params ) {
866        return [ 'headers' => [], 'metadata' => [] ]; // not supported
867    }
868
869    /** @inheritDoc */
870    final public function getFileSha1Base36( array $params ) {
871        $path = self::normalizeStoragePath( $params['src'] );
872        if ( $path === null ) {
873            return self::SHA1_FAIL; // invalid storage path
874        }
875        $latest = !empty( $params['latest'] ); // use latest data?
876        if ( $this->cheapCache->hasField( $path, 'sha1', self::CACHE_TTL ) ) {
877            $stat = $this->cheapCache->getField( $path, 'sha1' );
878            // If we want the latest data, check that this cached
879            // value was in fact fetched with the latest available data.
880            if ( !$latest || $stat['latest'] ) {
881                return $stat['hash'];
882            }
883        }
884        $sha1 = $this->doGetFileSha1Base36( $params );
885        if ( is_string( $sha1 ) ) {
886            $this->cheapCache->setField(
887                $path,
888                'sha1',
889                [ 'hash' => $sha1, 'latest' => $latest ]
890            );
891        } elseif ( $sha1 === self::RES_ABSENT ) {
892            $this->cheapCache->setField(
893                $path,
894                'sha1',
895                [ 'hash' => self::SHA1_FAIL, 'latest' => $latest ]
896            );
897        } else {
898            $sha1 = self::SHA1_FAIL; // used for all failure cases
899        }
900
901        return $sha1;
902    }
903
904    /**
905     * @see FileBackendStore::getFileSha1Base36()
906     * @stable to override
907     * @param array $params
908     * @return bool|string|null SHA1, false (missing file), or null (error)
909     */
910    protected function doGetFileSha1Base36( array $params ) {
911        $fsFile = $this->getLocalReference( $params );
912        if ( $fsFile instanceof FSFile ) {
913            $sha1 = $fsFile->getSha1Base36();
914
915            return is_string( $sha1 ) ? $sha1 : self::RES_ERROR;
916        }
917
918        return $fsFile === self::RES_ERROR ? self::RES_ERROR : self::RES_ABSENT;
919    }
920
921    /** @inheritDoc */
922    final public function getFileProps( array $params ) {
923        $fsFile = $this->getLocalReference( $params );
924
925        return $fsFile ? $fsFile->getProps() : FSFile::placeholderProps();
926    }
927
928    /** @inheritDoc */
929    final public function getLocalReferenceMulti( array $params ) {
930        $params = $this->setConcurrencyFlags( $params );
931
932        $fsFiles = []; // (path => FSFile)
933        $latest = !empty( $params['latest'] ); // use latest data?
934        // Reuse any files already in process cache...
935        foreach ( $params['srcs'] as $src ) {
936            $path = self::normalizeStoragePath( $src );
937            if ( $path === null ) {
938                $fsFiles[$src] = self::RES_ERROR; // invalid storage path
939            } elseif ( $this->expensiveCache->hasField( $path, 'localRef' ) ) {
940                $val = $this->expensiveCache->getField( $path, 'localRef' );
941                // If we want the latest data, check that this cached
942                // value was in fact fetched with the latest available data.
943                if ( !$latest || $val['latest'] ) {
944                    $fsFiles[$src] = $val['object'];
945                }
946            }
947        }
948        // Fetch local references of any remaining files...
949        $params['srcs'] = array_diff( $params['srcs'], array_keys( $fsFiles ) );
950        foreach ( $this->doGetLocalReferenceMulti( $params ) as $path => $fsFile ) {
951            $fsFiles[$path] = $fsFile;
952            if ( $fsFile instanceof FSFile ) {
953                $this->expensiveCache->setField(
954                    $path,
955                    'localRef',
956                    [ 'object' => $fsFile, 'latest' => $latest ]
957                );
958            }
959        }
960
961        return $fsFiles;
962    }
963
964    /**
965     * @see FileBackendStore::getLocalReferenceMulti()
966     * @stable to override
967     * @param array $params
968     * @return string[]|bool[]|null[] Map of (path => FSFile, false (missing), or null (error))
969     */
970    protected function doGetLocalReferenceMulti( array $params ) {
971        return $this->doGetLocalCopyMulti( $params );
972    }
973
974    /** @inheritDoc */
975    final public function getLocalCopyMulti( array $params ) {
976        $params = $this->setConcurrencyFlags( $params );
977
978        return $this->doGetLocalCopyMulti( $params );
979    }
980
981    /**
982     * @see FileBackendStore::getLocalCopyMulti()
983     * @param array $params
984     * @return string[]|bool[]|null[] Map of (path => TempFSFile, false (missing), or null (error))
985     */
986    abstract protected function doGetLocalCopyMulti( array $params );
987
988    /**
989     * @see FileBackend::getFileHttpUrl()
990     * @stable to override
991     * @param array $params
992     * @return string|null
993     */
994    public function getFileHttpUrl( array $params ) {
995        return self::TEMPURL_ERROR; // not supported
996    }
997
998    /** @inheritDoc */
999    public function addShellboxInputFile( BoxedCommand $command, string $boxedName,
1000        array $params
1001    ) {
1002        $ref = $this->getLocalReference( [ 'src' => $params['src'] ] );
1003        if ( $ref === false ) {
1004            return $this->newStatus( 'backend-fail-notexists', $params['src'] );
1005        } elseif ( $ref === null ) {
1006            return $this->newStatus( 'backend-fail-read', $params['src'] );
1007        } else {
1008            $file = $command->newInputFileFromFile( $ref->getPath() )
1009                ->userData( __CLASS__, $ref );
1010            $command->inputFile( $boxedName, $file );
1011            return $this->newStatus();
1012        }
1013    }
1014
1015    /** @inheritDoc */
1016    final public function streamFile( array $params ) {
1017        $status = $this->newStatus();
1018
1019        // Always set some fields for subclass convenience
1020        $params['options'] ??= [];
1021        $params['headers'] ??= [];
1022
1023        // Don't stream it out as text/html if there was a PHP error
1024        if ( ( empty( $params['headless'] ) || $params['headers'] ) && headers_sent() ) {
1025            print "Headers already sent, terminating.\n";
1026            $status->fatal( 'backend-fail-stream', $params['src'] );
1027            return $status;
1028        }
1029
1030        $status->merge( $this->doStreamFile( $params ) );
1031
1032        return $status;
1033    }
1034
1035    /**
1036     * @see FileBackendStore::streamFile()
1037     * @stable to override
1038     * @param array $params
1039     * @return StatusValue
1040     */
1041    protected function doStreamFile( array $params ) {
1042        $status = $this->newStatus();
1043
1044        $flags = 0;
1045        $flags |= !empty( $params['headless'] ) ? HTTPFileStreamer::STREAM_HEADLESS : 0;
1046        $flags |= !empty( $params['allowOB'] ) ? HTTPFileStreamer::STREAM_ALLOW_OB : 0;
1047
1048        $fsFile = $this->getLocalReference( $params );
1049        if ( $fsFile ) {
1050            $streamer = new HTTPFileStreamer(
1051                $fsFile->getPath(),
1052                $this->getStreamerOptions()
1053            );
1054            $res = $streamer->stream( $params['headers'], true, $params['options'], $flags );
1055        } else {
1056            $res = false;
1057            HTTPFileStreamer::send404Message( $params['src'], $flags );
1058        }
1059
1060        if ( !$res ) {
1061            $status->fatal( 'backend-fail-stream', $params['src'] );
1062        }
1063
1064        return $status;
1065    }
1066
1067    /** @inheritDoc */
1068    final public function directoryExists( array $params ) {
1069        [ $fullCont, $dir, $shard ] = $this->resolveStoragePath( $params['dir'] );
1070        if ( $dir === null ) {
1071            return self::EXISTENCE_ERROR; // invalid storage path
1072        }
1073        if ( $shard !== null ) { // confined to a single container/shard
1074            return $this->doDirectoryExists( $fullCont, $dir, $params );
1075        } else { // directory is on several shards
1076            $this->logger->debug( __METHOD__ . ": iterating over all container shards." );
1077            [ , $shortCont, ] = self::splitStoragePath( $params['dir'] );
1078            $res = false; // response
1079            foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
1080                $exists = $this->doDirectoryExists( "{$fullCont}{$suffix}", $dir, $params );
1081                if ( $exists === true ) {
1082                    $res = true;
1083                    break; // found one!
1084                } elseif ( $exists === self::RES_ERROR ) {
1085                    $res = self::EXISTENCE_ERROR;
1086                }
1087            }
1088
1089            return $res;
1090        }
1091    }
1092
1093    /**
1094     * @see FileBackendStore::directoryExists()
1095     *
1096     * @param string $fullCont Resolved container name
1097     * @param string $dirRel Resolved path relative to container
1098     * @param array $params
1099     * @return bool|null
1100     */
1101    abstract protected function doDirectoryExists( $fullCont, $dirRel, array $params );
1102
1103    /** @inheritDoc */
1104    final public function getDirectoryList( array $params ) {
1105        [ $fullCont, $dir, $shard ] = $this->resolveStoragePath( $params['dir'] );
1106        if ( $dir === null ) {
1107            return self::EXISTENCE_ERROR; // invalid storage path
1108        }
1109        if ( $shard !== null ) {
1110            // File listing is confined to a single container/shard
1111            return $this->getDirectoryListInternal( $fullCont, $dir, $params );
1112        } else {
1113            $this->logger->debug( __METHOD__ . ": iterating over all container shards." );
1114            // File listing spans multiple containers/shards
1115            [ , $shortCont, ] = self::splitStoragePath( $params['dir'] );
1116
1117            return new FileBackendStoreShardDirIterator( $this,
1118                $fullCont, $dir, $this->getContainerSuffixes( $shortCont ), $params );
1119        }
1120    }
1121
1122    /**
1123     * Do not call this function from places outside FileBackend
1124     *
1125     * @see FileBackendStore::getDirectoryList()
1126     *
1127     * @param string $fullCont Resolved container name
1128     * @param string $dirRel Resolved path relative to container
1129     * @param array $params
1130     * @return Traversable|array|null Iterable list or null (error)
1131     */
1132    abstract public function getDirectoryListInternal( $fullCont, $dirRel, array $params );
1133
1134    /** @inheritDoc */
1135    final public function getFileList( array $params ) {
1136        [ $fullCont, $dir, $shard ] = $this->resolveStoragePath( $params['dir'] );
1137        if ( $dir === null ) {
1138            return self::LIST_ERROR; // invalid storage path
1139        }
1140        if ( $shard !== null ) {
1141            // File listing is confined to a single container/shard
1142            return $this->getFileListInternal( $fullCont, $dir, $params );
1143        } else {
1144            $this->logger->debug( __METHOD__ . ": iterating over all container shards." );
1145            // File listing spans multiple containers/shards
1146            [ , $shortCont, ] = self::splitStoragePath( $params['dir'] );
1147
1148            return new FileBackendStoreShardFileIterator( $this,
1149                $fullCont, $dir, $this->getContainerSuffixes( $shortCont ), $params );
1150        }
1151    }
1152
1153    /**
1154     * Do not call this function from places outside FileBackend
1155     *
1156     * @see FileBackendStore::getFileList()
1157     *
1158     * @param string $fullCont Resolved container name
1159     * @param string $dirRel Resolved path relative to container
1160     * @param array $params
1161     * @return Traversable|string[]|null Iterable list or null (error)
1162     */
1163    abstract public function getFileListInternal( $fullCont, $dirRel, array $params );
1164
1165    /**
1166     * Return a list of FileOp objects from a list of operations.
1167     * Do not call this function from places outside FileBackend.
1168     *
1169     * The result must have the same number of items as the input.
1170     * An exception is thrown if an unsupported operation is requested.
1171     *
1172     * @param array[] $ops Same format as doOperations()
1173     * @return FileOp[]
1174     * @throws FileBackendError
1175     */
1176    final public function getOperationsInternal( array $ops ) {
1177        $supportedOps = [
1178            'store' => StoreFileOp::class,
1179            'copy' => CopyFileOp::class,
1180            'move' => MoveFileOp::class,
1181            'delete' => DeleteFileOp::class,
1182            'create' => CreateFileOp::class,
1183            'describe' => DescribeFileOp::class,
1184            'null' => NullFileOp::class
1185        ];
1186
1187        $performOps = []; // array of FileOp objects
1188        // Build up ordered array of FileOps...
1189        foreach ( $ops as $operation ) {
1190            $opName = $operation['op'];
1191            if ( isset( $supportedOps[$opName] ) ) {
1192                $class = $supportedOps[$opName];
1193                // Get params for this operation
1194                $params = $operation;
1195                // Append the FileOp class
1196                $performOps[] = new $class( $this, $params, $this->logger );
1197            } else {
1198                throw new FileBackendError( "Operation '$opName' is not supported." );
1199            }
1200        }
1201
1202        return $performOps;
1203    }
1204
1205    /**
1206     * Get a list of storage paths to lock for a list of operations
1207     * Returns an array with LockManager::LOCK_UW (shared locks) and
1208     * LockManager::LOCK_EX (exclusive locks) keys, each corresponding
1209     * to a list of storage paths to be locked. All returned paths are
1210     * normalized.
1211     *
1212     * @param FileOp[] $performOps List of FileOp objects
1213     * @return string[][] (LockManager::LOCK_UW => path list, LockManager::LOCK_EX => path list)
1214     */
1215    final public function getPathsToLockForOpsInternal( array $performOps ) {
1216        // Build up a list of files to lock...
1217        $paths = [ 'sh' => [], 'ex' => [] ];
1218        foreach ( $performOps as $fileOp ) {
1219            $paths['sh'] = array_merge( $paths['sh'], $fileOp->storagePathsRead() );
1220            $paths['ex'] = array_merge( $paths['ex'], $fileOp->storagePathsChanged() );
1221        }
1222        // Optimization: if doing an EX lock anyway, don't also set an SH one
1223        $paths['sh'] = array_diff( $paths['sh'], $paths['ex'] );
1224        // Get a shared lock on the parent directory of each path changed
1225        $paths['sh'] = array_merge( $paths['sh'], array_map( 'dirname', $paths['ex'] ) );
1226
1227        return [
1228            LockManager::LOCK_UW => $paths['sh'],
1229            LockManager::LOCK_EX => $paths['ex']
1230        ];
1231    }
1232
1233    /** @inheritDoc */
1234    public function getScopedLocksForOps( array $ops, StatusValue $status ) {
1235        $paths = $this->getPathsToLockForOpsInternal( $this->getOperationsInternal( $ops ) );
1236
1237        return $this->getScopedFileLocks( $paths, 'mixed', $status );
1238    }
1239
1240    /** @inheritDoc */
1241    final protected function doOperationsInternal( array $ops, array $opts ) {
1242        $status = $this->newStatus();
1243
1244        // Fix up custom header name/value pairs
1245        $ops = array_map( $this->sanitizeOpHeaders( ... ), $ops );
1246        // Build up a list of FileOps and involved paths
1247        $fileOps = $this->getOperationsInternal( $ops );
1248        $pathsUsed = [];
1249        foreach ( $fileOps as $fileOp ) {
1250            $pathsUsed = array_merge( $pathsUsed, $fileOp->storagePathsReadOrChanged() );
1251        }
1252
1253        // Acquire any locks as needed for the scope of this function
1254        if ( empty( $opts['nonLocking'] ) ) {
1255            $pathsByLockType = $this->getPathsToLockForOpsInternal( $fileOps );
1256            /** @noinspection PhpUnusedLocalVariableInspection */
1257            $scopeLock = $this->getScopedFileLocks( $pathsByLockType, 'mixed', $status );
1258            if ( !$status->isOK() ) {
1259                return $status; // abort
1260            }
1261        }
1262
1263        // Clear any file cache entries (after locks acquired)
1264        if ( empty( $opts['preserveCache'] ) ) {
1265            $this->clearCache( $pathsUsed );
1266        }
1267
1268        // Enlarge the cache to fit the stat entries of these files
1269        $this->cheapCache->setMaxSize( max( 2 * count( $pathsUsed ), self::CACHE_CHEAP_SIZE ) );
1270
1271        // Load from the persistent container caches
1272        $this->primeContainerCache( $pathsUsed );
1273        // Get the latest stat info for all the files (having locked them)
1274        $ok = $this->preloadFileStat( [ 'srcs' => $pathsUsed, 'latest' => true ] );
1275
1276        if ( $ok ) {
1277            // Actually attempt the operation batch...
1278            $opts = $this->setConcurrencyFlags( $opts );
1279            $subStatus = FileOpBatch::attempt( $fileOps, $opts );
1280        } else {
1281            // If we could not even stat some files, then bail out
1282            $subStatus = $this->newStatus( 'backend-fail-internal', $this->name );
1283            foreach ( $ops as $i => $op ) { // mark each op as failed
1284                $subStatus->success[$i] = false;
1285                ++$subStatus->failCount;
1286            }
1287            $this->logger->error( static::class . "-{$this->name} stat failure",
1288                [ 'aborted_operations' => $ops ]
1289            );
1290        }
1291
1292        // Merge errors into StatusValue fields
1293        $status->merge( $subStatus );
1294        $status->success = $subStatus->success; // not done in merge()
1295
1296        // Shrink the stat cache back to normal size
1297        $this->cheapCache->setMaxSize( self::CACHE_CHEAP_SIZE );
1298
1299        return $status;
1300    }
1301
1302    /** @inheritDoc */
1303    final protected function doQuickOperationsInternal( array $ops, array $opts ) {
1304        $status = $this->newStatus();
1305
1306        // Fix up custom header name/value pairs
1307        $ops = array_map( $this->sanitizeOpHeaders( ... ), $ops );
1308        // Build up a list of FileOps and involved paths
1309        $fileOps = $this->getOperationsInternal( $ops );
1310        $pathsUsed = [];
1311        foreach ( $fileOps as $fileOp ) {
1312            $pathsUsed = array_merge( $pathsUsed, $fileOp->storagePathsReadOrChanged() );
1313        }
1314
1315        // Clear any file cache entries for involved paths
1316        $this->clearCache( $pathsUsed );
1317
1318        // Parallel ops may be disabled in config due to dependencies (e.g. needing popen())
1319        $async = ( $this->parallelize === 'implicit' && count( $ops ) > 1 );
1320        $maxConcurrency = $this->concurrency; // throttle
1321        /** @var StatusValue[] $statuses */
1322        $statuses = []; // array of (index => StatusValue)
1323        /** @var FileBackendStoreOpHandle[] $batch */
1324        $batch = [];
1325        foreach ( $fileOps as $index => $fileOp ) {
1326            $subStatus = $async
1327                ? $fileOp->attemptAsyncQuick()
1328                : $fileOp->attemptQuick();
1329            if ( $subStatus->value instanceof FileBackendStoreOpHandle ) { // async
1330                if ( count( $batch ) >= $maxConcurrency ) {
1331                    // Execute this batch. Don't queue any more ops since they contain
1332                    // open filehandles which are a limited resource (T230245).
1333                    $statuses += $this->executeOpHandlesInternal( $batch );
1334                    $batch = [];
1335                }
1336                $batch[$index] = $subStatus->value; // keep index
1337            } else { // error or completed
1338                $statuses[$index] = $subStatus; // keep index
1339            }
1340        }
1341        if ( count( $batch ) ) {
1342            $statuses += $this->executeOpHandlesInternal( $batch );
1343        }
1344        // Marshall and merge all the responses...
1345        foreach ( $statuses as $index => $subStatus ) {
1346            $status->merge( $subStatus );
1347            if ( $subStatus->isOK() ) {
1348                $status->success[$index] = true;
1349                ++$status->successCount;
1350            } else {
1351                $status->success[$index] = false;
1352                ++$status->failCount;
1353            }
1354        }
1355
1356        $this->clearCache( $pathsUsed );
1357
1358        return $status;
1359    }
1360
1361    /**
1362     * Execute a list of FileBackendStoreOpHandle handles in parallel.
1363     * The resulting StatusValue object fields will correspond
1364     * to the order in which the handles where given.
1365     *
1366     * @param FileBackendStoreOpHandle[] $fileOpHandles
1367     * @return StatusValue[] Map of StatusValue objects
1368     * @throws FileBackendError
1369     */
1370    final public function executeOpHandlesInternal( array $fileOpHandles ) {
1371        foreach ( $fileOpHandles as $fileOpHandle ) {
1372            if ( !( $fileOpHandle instanceof FileBackendStoreOpHandle ) ) {
1373                throw new InvalidArgumentException( "Expected FileBackendStoreOpHandle object." );
1374            } elseif ( $fileOpHandle->backend->getName() !== $this->getName() ) {
1375                throw new InvalidArgumentException( "Expected handle for this file backend." );
1376            }
1377        }
1378
1379        $statuses = $this->doExecuteOpHandlesInternal( $fileOpHandles );
1380        foreach ( $fileOpHandles as $fileOpHandle ) {
1381            $fileOpHandle->closeResources();
1382        }
1383
1384        return $statuses;
1385    }
1386
1387    /**
1388     * @see FileBackendStore::executeOpHandlesInternal()
1389     * @stable to override
1390     *
1391     * @param FileBackendStoreOpHandle[] $fileOpHandles
1392     *
1393     * @throws FileBackendError
1394     * @return StatusValue[] List of corresponding StatusValue objects
1395     */
1396    protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
1397        if ( count( $fileOpHandles ) ) {
1398            throw new FileBackendError( "Backend does not support asynchronous operations." );
1399        }
1400
1401        return [];
1402    }
1403
1404    /**
1405     * Normalize and filter HTTP headers from a file operation
1406     *
1407     * This normalizes and strips long HTTP headers from a file operation.
1408     * Most headers are just numbers, but some are allowed to be long.
1409     * This function is useful for cleaning up headers and avoiding backend
1410     * specific errors, especially in the middle of batch file operations.
1411     *
1412     * @param array $op Same format as doOperation()
1413     * @return array
1414     */
1415    protected function sanitizeOpHeaders( array $op ) {
1416        static $longs = [ 'content-disposition' ];
1417
1418        if ( isset( $op['headers'] ) ) { // op sets HTTP headers
1419            $newHeaders = [];
1420            foreach ( $op['headers'] as $name => $value ) {
1421                $name = strtolower( $name );
1422                $maxHVLen = in_array( $name, $longs ) ? INF : 255;
1423                if ( strlen( $name ) > 255 || strlen( $value ) > $maxHVLen ) {
1424                    $this->logger->error( "Header '{header}' is too long.", [
1425                        'filebackend' => $this->name,
1426                        'header' => "$name$value",
1427                    ] );
1428                } else {
1429                    $newHeaders[$name] = strlen( $value ) ? $value : ''; // null/false => ""
1430                }
1431            }
1432            $op['headers'] = $newHeaders;
1433        }
1434
1435        return $op;
1436    }
1437
1438    final public function preloadCache( array $paths ) {
1439        $fullConts = []; // full container names
1440        foreach ( $paths as $path ) {
1441            [ $fullCont, , ] = $this->resolveStoragePath( $path );
1442            $fullConts[] = $fullCont;
1443        }
1444        // Load from the persistent file and container caches
1445        $this->primeContainerCache( $fullConts );
1446        $this->primeFileCache( $paths );
1447    }
1448
1449    final public function clearCache( ?array $paths = null ) {
1450        if ( is_array( $paths ) ) {
1451            $paths = array_map( FileBackend::normalizeStoragePath( ... ), $paths );
1452            $paths = array_filter( $paths, 'strlen' ); // remove nulls
1453        }
1454        if ( $paths === null ) {
1455            $this->cheapCache->clear();
1456            $this->expensiveCache->clear();
1457        } else {
1458            foreach ( $paths as $path ) {
1459                $this->cheapCache->clear( $path );
1460                $this->expensiveCache->clear( $path );
1461            }
1462        }
1463        $this->doClearCache( $paths );
1464    }
1465
1466    /**
1467     * Clears any additional stat caches for storage paths
1468     * @stable to override
1469     *
1470     * @see FileBackend::clearCache()
1471     *
1472     * @param string[]|null $paths Storage paths (optional)
1473     */
1474    protected function doClearCache( ?array $paths = null ) {
1475    }
1476
1477    /** @inheritDoc */
1478    final public function preloadFileStat( array $params ) {
1479        $params['concurrency'] = ( $this->parallelize !== 'off' ) ? $this->concurrency : 1;
1480        $stats = $this->doGetFileStatMulti( $params );
1481        if ( $stats === null ) {
1482            return true; // not supported
1483        }
1484
1485        // Whether this queried the backend in high consistency mode
1486        $latest = !empty( $params['latest'] );
1487
1488        return $this->ingestFreshFileStats( $stats, $latest );
1489    }
1490
1491    /**
1492     * Get file stat information (concurrently if possible) for several files
1493     * @stable to override
1494     *
1495     * @see FileBackend::getFileStat()
1496     *
1497     * @param array $params Parameters include:
1498     *   - srcs        : list of source storage paths
1499     *   - latest      : use the latest available data
1500     * @return array<string,array|false|null>|null Null if not supported. Otherwise a map of storage
1501     *  path to attribute map, false (missing file), or null (I/O error).
1502     * @since 1.23
1503     */
1504    protected function doGetFileStatMulti( array $params ) {
1505        return null; // not supported
1506    }
1507
1508    /**
1509     * Whether this a key/value store where directories are merely virtual
1510     *
1511     * Virtual directories exists in so much as files exists that are
1512     * prefixed with the directory path followed by a forward slash.
1513     *
1514     * @return bool
1515     */
1516    abstract protected function directoriesAreVirtual();
1517
1518    /**
1519     * Check if a short container name is valid
1520     *
1521     * This checks for length and illegal characters.
1522     * This may disallow certain characters that can appear
1523     * in the prefix used to make the full container name.
1524     *
1525     * @param string $container
1526     * @return bool
1527     */
1528    final protected static function isValidShortContainerName( $container ) {
1529        // Suffixes like '.xxx' (hex shard chars) or '.seg' (file segments)
1530        // might be used by subclasses. Reserve the dot character.
1531        // The only way dots end up in containers (e.g. resolveStoragePath)
1532        // is due to the wikiId container prefix or the above suffixes.
1533        return self::isValidContainerName( $container ) && !preg_match( '/[.]/', $container );
1534    }
1535
1536    /**
1537     * Check if a full container name is valid
1538     *
1539     * This checks for length and illegal characters.
1540     * Limiting the characters makes migrations to other stores easier.
1541     *
1542     * @param string $container
1543     * @return bool
1544     */
1545    final protected static function isValidContainerName( $container ) {
1546        // This accounts for NTFS, Swift, and Ceph restrictions
1547        // and disallows directory separators or traversal characters.
1548        // Note that matching strings URL encode to the same string;
1549        // in Swift/Ceph, the length restriction is *after* URL encoding.
1550        return (bool)preg_match( '/^[a-z0-9][a-z0-9-_.]{0,199}$/i', $container );
1551    }
1552
1553    /**
1554     * Splits a storage path into an internal container name,
1555     * an internal relative file name, and a container shard suffix.
1556     * Any shard suffix is already appended to the internal container name.
1557     * This also checks that the storage path is valid and within this backend.
1558     *
1559     * If the container is sharded but a suffix could not be determined,
1560     * this means that the path can only refer to a directory and can only
1561     * be scanned by looking in all the container shards.
1562     *
1563     * @param string $storagePath
1564     * @return array (container, path, container suffix) or (null, null, null) if invalid
1565     */
1566    final protected function resolveStoragePath( $storagePath ) {
1567        [ $backend, $shortCont, $relPath ] = self::splitStoragePath( $storagePath );
1568        if ( $backend === $this->name && $relPath !== null ) { // must be for this backend
1569            $relPath = self::normalizeContainerPath( $relPath );
1570            if ( $relPath !== null && self::isValidShortContainerName( $shortCont ) ) {
1571                // Get shard for the normalized path if this container is sharded
1572                $cShard = $this->getContainerShard( $shortCont, $relPath );
1573                // Validate and sanitize the relative path (backend-specific)
1574                $relPath = $this->resolveContainerPath( $shortCont, $relPath );
1575                if ( $relPath !== null ) {
1576                    // Prepend any domain ID prefix to the container name
1577                    $container = $this->fullContainerName( $shortCont );
1578                    if ( self::isValidContainerName( $container ) ) {
1579                        // Validate and sanitize the container name (backend-specific)
1580                        $container = $this->resolveContainerName( "{$container}{$cShard}" );
1581                        if ( $container !== null ) {
1582                            return [ $container, $relPath, $cShard ];
1583                        }
1584                    }
1585                }
1586            }
1587        }
1588
1589        return [ null, null, null ];
1590    }
1591
1592    /**
1593     * Like resolveStoragePath() except null values are returned if
1594     * the container is sharded and the shard could not be determined
1595     * or if the path ends with '/'. The latter case is illegal for FS
1596     * backends and can confuse listings for object store backends.
1597     *
1598     * This function is used when resolving paths that must be valid
1599     * locations for files. Directory and listing functions should
1600     * generally just use resolveStoragePath() instead.
1601     *
1602     * @see FileBackendStore::resolveStoragePath()
1603     *
1604     * @param string $storagePath
1605     * @return array (container, path) or (null, null) if invalid
1606     */
1607    final protected function resolveStoragePathReal( $storagePath ) {
1608        [ $container, $relPath, $cShard ] = $this->resolveStoragePath( $storagePath );
1609        if ( $cShard !== null && !str_ends_with( $relPath, '/' ) ) {
1610            return [ $container, $relPath ];
1611        }
1612
1613        return [ null, null ];
1614    }
1615
1616    /**
1617     * Get the container name shard suffix for a given path.
1618     * Any empty suffix means the container is not sharded.
1619     *
1620     * @param string $container Container name
1621     * @param string $relPath Storage path relative to the container
1622     * @return string|null Returns null if shard could not be determined
1623     */
1624    final protected function getContainerShard( $container, $relPath ) {
1625        [ $levels, $base, $repeat ] = $this->getContainerHashLevels( $container );
1626        if ( $levels == 1 || $levels == 2 ) {
1627            // Hash characters are either base 16 or 36
1628            $char = ( $base == 36 ) ? '[0-9a-z]' : '[0-9a-f]';
1629            // Get a regex that represents the shard portion of paths.
1630            // The concatenation of the captures gives us the shard.
1631            if ( $levels === 1 ) { // 16 or 36 shards per container
1632                $hashDirRegex = '(' . $char . ')';
1633            } else { // 256 or 1296 shards per container
1634                if ( $repeat ) { // verbose hash dir format (e.g. "a/ab/abc")
1635                    $hashDirRegex = $char . '/(' . $char . '{2})';
1636                } else { // short hash dir format (e.g. "a/b/c")
1637                    $hashDirRegex = '(' . $char . ')/(' . $char . ')';
1638                }
1639            }
1640            // Allow certain directories to be above the hash dirs so as
1641            // to work with FileRepo (e.g. "archive/a/ab" or "temp/a/ab").
1642            // They must be 2+ chars to avoid any hash directory ambiguity.
1643            $m = [];
1644            if ( preg_match( "!^(?:[^/]{2,}/)*$hashDirRegex(?:/|$)!", $relPath, $m ) ) {
1645                return '.' . implode( '', array_slice( $m, 1 ) );
1646            }
1647
1648            return null; // failed to match
1649        }
1650
1651        return ''; // no sharding
1652    }
1653
1654    /**
1655     * Check if a storage path maps to a single shard.
1656     * Container dirs like "a", where the container shards on "x/xy",
1657     * can reside on several shards. Such paths are tricky to handle.
1658     *
1659     * @param string $storagePath
1660     * @return bool
1661     */
1662    final public function isSingleShardPathInternal( $storagePath ) {
1663        [ , , $shard ] = $this->resolveStoragePath( $storagePath );
1664
1665        return ( $shard !== null );
1666    }
1667
1668    /**
1669     * Get the sharding config for a container.
1670     * If greater than 0, then all file storage paths within
1671     * the container are required to be hashed accordingly.
1672     *
1673     * @param string $container
1674     * @return array (integer levels, integer base, repeat flag) or (0, 0, false)
1675     */
1676    final protected function getContainerHashLevels( $container ) {
1677        if ( isset( $this->shardViaHashLevels[$container] ) ) {
1678            $config = $this->shardViaHashLevels[$container];
1679            $hashLevels = (int)$config['levels'];
1680            if ( $hashLevels == 1 || $hashLevels == 2 ) {
1681                $hashBase = (int)$config['base'];
1682                if ( $hashBase == 16 || $hashBase == 36 ) {
1683                    return [ $hashLevels, $hashBase, $config['repeat'] ];
1684                }
1685            }
1686        }
1687
1688        return [ 0, 0, false ]; // no sharding
1689    }
1690
1691    /**
1692     * Get a list of full container shard suffixes for a container
1693     *
1694     * @param string $container
1695     * @return array
1696     */
1697    final protected function getContainerSuffixes( $container ) {
1698        $shards = [];
1699        [ $digits, $base ] = $this->getContainerHashLevels( $container );
1700        if ( $digits > 0 ) {
1701            $numShards = $base ** $digits;
1702            for ( $index = 0; $index < $numShards; $index++ ) {
1703                $shards[] = '.' . \Wikimedia\base_convert( (string)$index, 10, $base, $digits );
1704            }
1705        }
1706
1707        return $shards;
1708    }
1709
1710    /**
1711     * Get the full container name, including the domain ID prefix
1712     *
1713     * @param string $container
1714     * @return string
1715     */
1716    final protected function fullContainerName( $container ) {
1717        if ( $this->domainId != '' ) {
1718            return "{$this->domainId}-$container";
1719        } else {
1720            return $container;
1721        }
1722    }
1723
1724    /**
1725     * Resolve a container name, checking if it's allowed by the backend.
1726     * This is intended for internal use, such as encoding illegal chars.
1727     * Subclasses can override this to be more restrictive.
1728     * @stable to override
1729     *
1730     * @param string $container
1731     * @return string|null
1732     */
1733    protected function resolveContainerName( $container ) {
1734        return $container;
1735    }
1736
1737    /**
1738     * Resolve a relative storage path, checking if it's allowed by the backend.
1739     * This is intended for internal use, such as encoding illegal chars or perhaps
1740     * getting absolute paths (e.g. FS based backends). Note that the relative path
1741     * may be the empty string (e.g. the path is simply to the container).
1742     * @stable to override
1743     *
1744     * @param string $container Container name
1745     * @param string $relStoragePath Storage path relative to the container
1746     * @return string|null Path or null if not valid
1747     */
1748    protected function resolveContainerPath( $container, $relStoragePath ) {
1749        return $relStoragePath;
1750    }
1751
1752    /**
1753     * Get the cache key for a container
1754     *
1755     * @param string $container Resolved container name
1756     * @return string
1757     */
1758    private function containerCacheKey( $container ) {
1759        return "filebackend:{$this->name}:{$this->domainId}:container:{$container}";
1760    }
1761
1762    /**
1763     * Set the cached info for a container
1764     *
1765     * @param string $container Resolved container name
1766     * @param array $val Information to cache
1767     */
1768    final protected function setContainerCache( $container, array $val ) {
1769        if ( !$this->wanStatCache->set(
1770            $this->containerCacheKey( $container ),
1771            $val,
1772            14 * 86400
1773        ) ) {
1774            $this->logger->warning( "Unable to set stat cache for container {container}.",
1775                [ 'filebackend' => $this->name, 'container' => $container ]
1776            );
1777        }
1778    }
1779
1780    /**
1781     * Delete the cached info for a container.
1782     * The cache key is salted for a while to prevent race conditions.
1783     *
1784     * @param string $container Resolved container name
1785     */
1786    final protected function deleteContainerCache( $container ) {
1787        if ( !$this->wanStatCache->delete( $this->containerCacheKey( $container ), 300 ) ) {
1788            $this->logger->warning( "Unable to delete stat cache for container {container}.",
1789                [ 'filebackend' => $this->name, 'container' => $container ]
1790            );
1791        }
1792    }
1793
1794    /**
1795     * Do a batch lookup from cache for container stats for all containers
1796     * used in a list of container names or storage paths objects.
1797     * This loads the persistent cache values into the process cache.
1798     */
1799    final protected function primeContainerCache( array $items ) {
1800        $paths = []; // list of storage paths
1801        $contNames = []; // (cache key => resolved container name)
1802        // Get all the paths/containers from the items...
1803        foreach ( $items as $item ) {
1804            if ( self::isStoragePath( $item ) ) {
1805                $paths[] = $item;
1806            } elseif ( is_string( $item ) ) { // full container name
1807                $contNames[$this->containerCacheKey( $item )] = $item;
1808            }
1809        }
1810        // Get all the corresponding cache keys for paths...
1811        foreach ( $paths as $path ) {
1812            [ $fullCont, , ] = $this->resolveStoragePath( $path );
1813            if ( $fullCont !== null ) { // valid path for this backend
1814                $contNames[$this->containerCacheKey( $fullCont )] = $fullCont;
1815            }
1816        }
1817
1818        $contInfo = []; // (resolved container name => cache value)
1819        // Get all cache entries for these container cache keys...
1820        $values = $this->wanStatCache->getMulti( array_keys( $contNames ) );
1821        foreach ( $values as $cacheKey => $val ) {
1822            $contInfo[$contNames[$cacheKey]] = $val;
1823        }
1824
1825        // Populate the container process cache for the backend...
1826        $this->doPrimeContainerCache( array_filter( $contInfo, 'is_array' ) );
1827    }
1828
1829    /**
1830     * Fill the backend-specific process cache given an array of
1831     * resolved container names and their corresponding cached info.
1832     * Only containers that actually exist should appear in the map.
1833     * @stable to override
1834     *
1835     * @param array $containerInfo Map of resolved container names to cached info
1836     */
1837    protected function doPrimeContainerCache( array $containerInfo ) {
1838    }
1839
1840    /**
1841     * Get the cache key for a file path
1842     *
1843     * @param string $path Normalized storage path
1844     * @return string
1845     */
1846    private function fileCacheKey( $path ) {
1847        return "filebackend:{$this->name}:{$this->domainId}:file:" . sha1( $path );
1848    }
1849
1850    /**
1851     * Set the cached stat info for a file path.
1852     * Negatives (404s) are not cached. By not caching negatives, we can skip cache
1853     * salting for the case when a file is created at a path were there was none before.
1854     *
1855     * @param string $path Storage path
1856     * @param array $val Stat information to cache
1857     */
1858    final protected function setFileCache( $path, array $val ) {
1859        $path = FileBackend::normalizeStoragePath( $path );
1860        if ( $path === null ) {
1861            return; // invalid storage path
1862        }
1863        $mtime = (int)ConvertibleTimestamp::convert( TS::UNIX, $val['mtime'] );
1864        $ttl = $this->wanStatCache->adaptiveTTL( $mtime, 7 * 86400, 300, 0.1 );
1865        // Set the cache unless it is currently salted.
1866        if ( !$this->wanStatCache->set( $this->fileCacheKey( $path ), $val, $ttl ) ) {
1867            $this->logger->warning( "Unable to set stat cache for file {path}.",
1868                [ 'filebackend' => $this->name, 'path' => $path ]
1869            );
1870        }
1871    }
1872
1873    /**
1874     * Delete the cached stat info for a file path.
1875     * The cache key is salted for a while to prevent race conditions.
1876     * Since negatives (404s) are not cached, this does not need to be called when
1877     * a file is created at a path were there was none before.
1878     *
1879     * @param string $path Storage path
1880     */
1881    final protected function deleteFileCache( $path ) {
1882        $path = FileBackend::normalizeStoragePath( $path );
1883        if ( $path === null ) {
1884            return; // invalid storage path
1885        }
1886        if ( !$this->wanStatCache->delete( $this->fileCacheKey( $path ), 300 ) ) {
1887            $this->logger->warning( "Unable to delete stat cache for file {path}.",
1888                [ 'filebackend' => $this->name, 'path' => $path ]
1889            );
1890        }
1891    }
1892
1893    /**
1894     * Do a batch lookup from cache for file stats for all paths
1895     * used in a list of storage paths or FileOp objects.
1896     * This loads the persistent cache values into the process cache.
1897     *
1898     * @param array $items List of storage paths
1899     */
1900    final protected function primeFileCache( array $items ) {
1901        $paths = []; // list of storage paths
1902        $pathNames = []; // (cache key => storage path)
1903        // Get all the paths/containers from the items...
1904        foreach ( $items as $item ) {
1905            if ( self::isStoragePath( $item ) ) {
1906                $path = FileBackend::normalizeStoragePath( $item );
1907                if ( $path !== null ) {
1908                    $paths[] = $path;
1909                }
1910            }
1911        }
1912        // Get all the corresponding cache keys for paths...
1913        foreach ( $paths as $path ) {
1914            [ , $rel, ] = $this->resolveStoragePath( $path );
1915            if ( $rel !== null ) { // valid path for this backend
1916                $pathNames[$this->fileCacheKey( $path )] = $path;
1917            }
1918        }
1919        // Get all cache entries for these file cache keys.
1920        // Note that negatives are not cached by getFileStat()/preloadFileStat().