Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
24.64% covered (danger)
24.64%
242 / 982
10.71% covered (danger)
10.71%
6 / 56
CRAP
0.00% covered (danger)
0.00%
0 / 1
SwiftFileBackend
24.67% covered (danger)
24.67%
242 / 981
10.71% covered (danger)
10.71%
6 / 56
37497.35
0.00% covered (danger)
0.00%
0 / 1
 __construct
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
4.01
 setLogger
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getFeatures
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 resolveContainerPath
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 isPathUsableInternal
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 extractMutableContentHeaders
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 extractMetadataHeaders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getMetadataFromHeaders
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 doCreateInternal
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
42
 doStoreInternal
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
90
 doCopyInternal
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
56
 doMoveInternal
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
132
 doDeleteInternal
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 doDescribeInternal
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
90
 doPrepareInternal
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
3.24
 doSecureInternal
47.06% covered (danger)
47.06%
8 / 17
0.00% covered (danger)
0.00%
0 / 1
6.37
 doPublishInternal
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 doCleanInternal
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
8.05
 doGetFileStat
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 convertSwiftDate
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 addMissingHashMetadata
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
56
 doGetFileContentsMulti
71.79% covered (warning)
71.79%
28 / 39
0.00% covered (danger)
0.00%
0 / 1
10.82
 doDirectoryExists
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getDirectoryListInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFileListInternal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDirListPageInternal
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
306
 getFileListPageInternal
42.86% covered (danger)
42.86%
9 / 21
0.00% covered (danger)
0.00%
0 / 1
24.11
 buildFileObjectListing
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 loadListingStatInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doGetFileXAttributes
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 doGetFileSha1base36
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 doStreamFile
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
156
 doGetLocalCopyMulti
65.31% covered (warning)
65.31%
32 / 49
0.00% covered (danger)
0.00%
0 / 1
18.01
 addShellboxInputFile
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getFileHttpUrl
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
72
 directoriesAreVirtual
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 headersFromParams
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 doExecuteOpHandlesInternal
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
72
 setContainerAccess
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 getContainerStat
60.00% covered (warning)
60.00%
15 / 25
0.00% covered (danger)
0.00%
0 / 1
10.14
 createContainer
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 deleteContainer
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 objectListing
65.38% covered (warning)
65.38%
17 / 26
0.00% covered (danger)
0.00%
0 / 1
12.36
 doPrimeContainerCache
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 doGetFileStatMulti
42.11% covered (danger)
42.11%
16 / 38
0.00% covered (danger)
0.00%
0 / 1
34.48
 getStatFromHeaders
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getAuthentication
42.11% covered (danger)
42.11%
8 / 19
0.00% covered (danger)
0.00%
0 / 1
20.42
 setAuthCreds
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 refreshAuthentication
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
30
 storageUrl
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 authTokenHeaders
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCredsCacheKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requestWithAuth
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requestMultiWithAuth
39.13% covered (danger)
39.13%
9 / 23
0.00% covered (danger)
0.00%
0 / 1
38.29
 getAuthFailureResponse
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 onError
20.00% covered (danger)
20.00%
4 / 20
0.00% covered (danger)
0.00%
0 / 1
32.09
1<?php
2/**
3 * OpenStack Swift based file backend.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup FileBackend
8 * @author Russ Nelson
9 */
10
11namespace Wikimedia\FileBackend;
12
13use Exception;
14use Psr\Log\LoggerInterface;
15use Shellbox\Command\BoxedCommand;
16use StatusValue;
17use stdClass;
18use Wikimedia\AtEase\AtEase;
19use Wikimedia\FileBackend\FileIteration\SwiftFileBackendDirList;
20use Wikimedia\FileBackend\FileIteration\SwiftFileBackendFileList;
21use Wikimedia\FileBackend\FileOpHandle\SwiftFileOpHandle;
22use Wikimedia\FileBackend\FSFile\TempFSFile;
23use Wikimedia\Http\MultiHttpClient;
24use Wikimedia\LockManager\LockManager;
25use Wikimedia\MapCacheLRU\MapCacheLRU;
26use Wikimedia\ObjectCache\BagOStuff;
27use Wikimedia\ObjectCache\EmptyBagOStuff;
28use Wikimedia\RequestTimeout\TimeoutException;
29use Wikimedia\Timestamp\ConvertibleTimestamp;
30use Wikimedia\Timestamp\TimestampFormat as TS;
31
32/**
33 * @brief Class for an OpenStack Swift (or Ceph RGW) based file backend.
34 *
35 * StatusValue messages should avoid mentioning the Swift account name.
36 * Likewise, error suppression should be used to avoid path disclosure.
37 *
38 * @ingroup FileBackend
39 * @since 1.19
40 */
41class SwiftFileBackend extends FileBackendStore {
42    private const DEFAULT_HTTP_OPTIONS = [ 'httpVersion' => 'v1.1' ];
43    private const AUTH_FAILURE_ERROR = 'Could not connect due to prior authentication failure';
44
45    /** @var MultiHttpClient */
46    protected $http;
47    /** @var int TTL in seconds */
48    protected $authTTL;
49    /** @var string Authentication base URL (without version) */
50    protected $swiftAuthUrl;
51    /** @var string Override of storage base URL */
52    protected $swiftStorageUrl;
53    /** @var string Swift user (account:user) to authenticate as */
54    protected $swiftUser;
55    /** @var string Secret key for user */
56    protected $swiftKey;
57    /** @var string Shared secret value for making temp URLs */
58    protected $swiftTempUrlKey;
59    /** @var bool */
60    protected $canShellboxGetTempUrl;
61    /** @var string|null */
62    protected $shellboxIpRange;
63    /** @var string S3 access key (RADOS Gateway) */
64    protected $rgwS3AccessKey;
65    /** @var string S3 authentication key (RADOS Gateway) */
66    protected $rgwS3SecretKey;
67    /** @var array Additional users (account:user) with read permissions on public containers */
68    protected $readUsers;
69    /** @var array Additional users (account:user) with write permissions on public containers */
70    protected $writeUsers;
71    /** @var array Additional users (account:user) with read permissions on private containers */
72    protected $secureReadUsers;
73    /** @var array Additional users (account:user) with write permissions on private containers */
74    protected $secureWriteUsers;
75
76    /** Persistent cache for authentication credential */
77    protected BagOStuff $credentialCache;
78
79    /** @var MapCacheLRU Container stat cache */
80    protected $containerStatCache;
81
82    /** @var array|null */
83    protected $authCreds;
84    /** @var int|null UNIX timestamp */
85    protected $authErrorTimestamp = null;
86
87    /** @var bool Whether the server is an Ceph RGW */
88    protected $isRGW = false;
89
90    /**
91     * @see FileBackendStore::__construct()
92     * @param array $config Params include:
93     *   - swiftAuthUrl       : Swift authentication server URL
94     *   - swiftUser          : Swift user used by MediaWiki (account:username)
95     *   - swiftKey           : Swift authentication key for the above user
96     *   - swiftAuthTTL       : Swift authentication TTL (seconds)
97     *   - swiftTempUrlKey    : Swift "X-Account-Meta-Temp-URL-Key" value on the account.
98     *                          Do not set this until it has been set in the backend.
99     *   - canShellboxGetTempUrl : Set this to true to generate a TempURL allowing Shellbox to
100     *                          directly fetch files from Swift. swiftTempUrlKey should be set.
101     *   - shellboxIpRange    : An IP range string to use when generating TempURLs for Shellbox.
102     *                          Specifying this will improve security by preventing exfiltrated
103     *                          TempURLs from being usable outside the server.
104     *   - swiftStorageUrl    : Swift storage URL (overrides that of the authentication response).
105     *                          This is useful to set if a TLS proxy is in use.
106     *   - shardViaHashLevels : Map of container names to sharding config with:
107     *                             - base   : base of hash characters, 16 or 36
108     *                             - levels : the number of hash levels (and digits)
109     *                             - repeat : hash subdirectories are prefixed with all the
110     *                                        parent hash directory names (e.g. "a/ab/abc")
111     *   - cacheAuthInfo      : Whether to cache authentication tokens in APC, etc.
112     *                          If those are not available, then the main cache will be used.
113     *                          This is probably insecure in shared hosting environments.
114     *   - rgwS3AccessKey     : Rados Gateway S3 "access key" value on the account.
115     *                          Do not set this until it has been set in the backend.
116     *                          This is used for generating expiring pre-authenticated URLs.
117     *                          Only use this when using rgw and to work around
118     *                          http://tracker.newdream.net/issues/3454.
119     *   - rgwS3SecretKey     : Rados Gateway S3 "secret key" value on the account.
120     *                          Do not set this until it has been set in the backend.
121     *                          This is used for generating expiring pre-authenticated URLs.
122     *                          Only use this when using rgw and to work around
123     *                          http://tracker.newdream.net/issues/3454.
124     *   - readUsers           : Swift users with read access to public containers (account:username)
125     *   - writeUsers          : Swift users with write access to public containers (account:username)
126     *   - secureReadUsers     : Swift users with read access to private containers (account:username)
127     *   - secureWriteUsers    : Swift users with write access to private containers (account:username)
128     *   - connTimeout         : The HTTP connect timeout to use when connecting to Swift, in
129     *                           seconds.
130     *   - reqTimeout          : The HTTP request timeout to use when communicating with Swift, in
131     *                           seconds.
132     */
133    public function __construct( array $config ) {
134        parent::__construct( $config );
135        // Required settings
136        $this->swiftAuthUrl = $config['swiftAuthUrl'];
137        $this->swiftUser = $config['swiftUser'];
138        $this->swiftKey = $config['swiftKey'];
139        // Optional settings
140        $this->authTTL = $config['swiftAuthTTL'] ?? 15 * 60; // some sensible number
141        $this->swiftTempUrlKey = $config['swiftTempUrlKey'] ?? '';
142        $this->canShellboxGetTempUrl = $config['canShellboxGetTempUrl'] ?? false;
143        $this->shellboxIpRange = $config['shellboxIpRange'] ?? null;
144        $this->swiftStorageUrl = $config['swiftStorageUrl'] ?? null;
145        $this->shardViaHashLevels = $config['shardViaHashLevels'] ?? '';
146        $this->rgwS3AccessKey = $config['rgwS3AccessKey'] ?? '';
147        $this->rgwS3SecretKey = $config['rgwS3SecretKey'] ?? '';
148
149        // HTTP helper client
150        $httpOptions = [];
151        foreach ( [ 'connTimeout', 'reqTimeout' ] as $optionName ) {
152            if ( isset( $config[$optionName] ) ) {
153                $httpOptions[$optionName] = $config[$optionName];
154            }
155        }
156        $this->http = new MultiHttpClient( $httpOptions );
157        $this->http->setLogger( $this->logger );
158
159        // Cache container information to mask latency
160        $this->wanStatCache = $this->wanCache;
161        // Process cache for container info
162        $this->containerStatCache = new MapCacheLRU( 300 );
163        // Cache auth token information to avoid RTTs
164        if ( !empty( $config['cacheAuthInfo'] ) ) {
165            $this->credentialCache = $this->srvCache;
166        } else {
167            $this->credentialCache = new EmptyBagOStuff();
168        }
169        $this->readUsers = $config['readUsers'] ?? [];
170        $this->writeUsers = $config['writeUsers'] ?? [];
171        $this->secureReadUsers = $config['secureReadUsers'] ?? [];
172        $this->secureWriteUsers = $config['secureWriteUsers'] ?? [];
173        // Per https://docs.openstack.org/swift/latest/overview_large_objects.html
174        // we need to split objects if they are larger than 5 GB. Support for
175        // splitting objects has not yet been implemented by this class
176        // so limit max file size to 5GiB.
177        $this->maxFileSize = 5 * 1024 * 1024 * 1024;
178    }
179
180    public function setLogger( LoggerInterface $logger ): void {
181        parent::setLogger( $logger );
182        $this->http->setLogger( $logger );
183    }
184
185    /** @inheritDoc */
186    public function getFeatures() {
187        return (
188            self::ATTR_UNICODE_PATHS |
189            self::ATTR_HEADERS |
190            self::ATTR_METADATA
191        );
192    }
193
194    /** @inheritDoc */
195    protected function resolveContainerPath( $container, $relStoragePath ) {
196        if ( !mb_check_encoding( $relStoragePath, 'UTF-8' ) ) {
197            return null; // not UTF-8, makes it hard to use CF and the swift HTTP API
198        } elseif ( strlen( rawurlencode( $relStoragePath ) ) > 1024 ) {
199            return null; // too long for Swift
200        }
201
202        return $relStoragePath;
203    }
204
205    /** @inheritDoc */
206    public function isPathUsableInternal( $storagePath ) {
207        [ $container, $rel ] = $this->resolveStoragePathReal( $storagePath );
208        if ( $rel === null ) {
209            return false; // invalid
210        }
211
212        return is_array( $this->getContainerStat( $container ) );
213    }
214
215    /**
216     * Filter/normalize a header map to only include mutable "content-"/"x-content-" headers
217     *
218     * Mutable headers can be changed via HTTP POST even if the file content is the same
219     *
220     * @see https://docs.openstack.org/api-ref/object-store
221     * @param string[] $headers Map of (header => value) for a swift object
222     * @return string[] Map of (header => value) for Content-* headers mutable via POST
223     */
224    protected function extractMutableContentHeaders( array $headers ) {
225        $contentHeaders = [];
226        // Normalize casing, and strip out illegal headers
227        foreach ( $headers as $name => $value ) {
228            $name = strtolower( $name );
229            if ( $name === 'x-delete-at' && is_numeric( $value ) ) {
230                // Expects a Unix Epoch date
231                $contentHeaders[$name] = $value;
232            } elseif ( $name === 'x-delete-after' && is_numeric( $value ) ) {
233                // Expects number of minutes time to live.
234                $contentHeaders[$name] = $value;
235            } elseif ( preg_match( '/^(x-)?content-(?!length$)/', $name ) ) {
236                // Only allow content-* and x-content-* headers (but not content-length)
237                $contentHeaders[$name] = $value;
238            } elseif ( $name === 'content-type' && $value !== '' ) {
239                // This header can be set to a value but not unset
240                $contentHeaders[$name] = $value;
241            }
242        }
243        // By default, Swift has annoyingly low maximum header value limits
244        if ( isset( $contentHeaders['content-disposition'] ) ) {
245            $maxLength = 255;
246            // @note: assume FileBackend::makeContentDisposition() already used
247            $offset = $maxLength - strlen( $contentHeaders['content-disposition'] );
248            if ( $offset < 0 ) {
249                $pos = strrpos( $contentHeaders['content-disposition'], ';', $offset );
250                $contentHeaders['content-disposition'] = $pos === false
251                    ? ''
252                    : trim( substr( $contentHeaders['content-disposition'], 0, $pos ) );
253            }
254        }
255
256        return $contentHeaders;
257    }
258
259    /**
260     * @see https://docs.openstack.org/api-ref/object-store
261     * @param string[] $headers Map of (header => value) for a swift object
262     * @return string[] Map of (metadata header name => metadata value)
263     */
264    protected function extractMetadataHeaders( array $headers ) {
265        $metadataHeaders = [];
266        foreach ( $headers as $name => $value ) {
267            $name = strtolower( $name );
268            if ( str_starts_with( $name, 'x-object-meta-' ) ) {
269                $metadataHeaders[$name] = $value;
270            }
271        }
272
273        return $metadataHeaders;
274    }
275
276    /**
277     * @see https://docs.openstack.org/api-ref/object-store
278     * @param string[] $headers Map of (header => value) for a swift object
279     * @return string[] Map of (metadata key name => metadata value)
280     */
281    protected function getMetadataFromHeaders( array $headers ) {
282        $prefixLen = strlen( 'x-object-meta-' );
283
284        $metadata = [];
285        foreach ( $this->extractMetadataHeaders( $headers ) as $name => $value ) {
286            $metadata[substr( $name, $prefixLen )] = $value;
287        }
288
289        return $metadata;
290    }
291
292    /** @inheritDoc */
293    protected function doCreateInternal( array $params ) {
294        $status = $this->newStatus();
295
296        [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
297        if ( $dstRel === null ) {
298            $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
299
300            return $status;
301        }
302
303        // Headers that are not strictly a function of the file content
304        $mutableHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
305        // Make sure that the "content-type" header is set to something sensible
306        $mutableHeaders['content-type']
307            ??= $this->getContentType( $params['dst'], $params['content'], null );
308
309        $reqs = [ [
310            'method' => 'PUT',
311            'container' => $dstCont,
312            'relPath' => $dstRel,
313            'headers' => array_merge(
314                $mutableHeaders,
315                [
316                    'etag' => md5( $params['content'] ),
317                    'content-length' => strlen( $params['content'] ),
318                    'x-object-meta-sha1base36' =>
319                        \Wikimedia\base_convert( sha1( $params['content'] ), 16, 36, 31 )
320                ]
321            ),
322            'body' => $params['content']
323        ] ];
324
325        $method = __METHOD__;
326        $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
327            [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
328            if ( $rcode === 201 || $rcode === 202 ) {
329                // good
330            } elseif ( $rcode === 412 ) {
331                $status->fatal( 'backend-fail-contenttype', $params['dst'] );
332            } else {
333                $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
334            }
335
336            return SwiftFileOpHandle::CONTINUE_IF_OK;
337        };
338
339        $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
340        if ( !empty( $params['async'] ) ) { // deferred
341            $status->value = $opHandle;
342        } else { // actually write the object in Swift
343            $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
344        }
345
346        return $status;
347    }
348
349    /** @inheritDoc */
350    protected function doStoreInternal( array $params ) {
351        $status = $this->newStatus();
352
353        [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
354        if ( $dstRel === null ) {
355            $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
356
357            return $status;
358        }
359
360        // Open a handle to the source file so that it can be streamed. The size and hash
361        // will be computed using the handle. In the off chance that the source file changes
362        // during this operation, the PUT will fail due to an ETag mismatch and be aborted.
363        AtEase::suppressWarnings();
364        $srcHandle = fopen( $params['src'], 'rb' );
365        AtEase::restoreWarnings();
366        if ( $srcHandle === false ) { // source doesn't exist?
367            $status->fatal( 'backend-fail-notexists', $params['src'] );
368
369            return $status;
370        }
371
372        // Compute the MD5 and SHA-1 hashes in one pass
373        $srcSize = fstat( $srcHandle )['size'];
374        $md5Context = hash_init( 'md5' );
375        $sha1Context = hash_init( 'sha1' );
376        $hashDigestSize = 0;
377        while ( !feof( $srcHandle ) ) {
378            $buffer = (string)fread( $srcHandle, 131_072 ); // 128 KiB
379            hash_update( $md5Context, $buffer );
380            hash_update( $sha1Context, $buffer );
381            $hashDigestSize += strlen( $buffer );
382        }
383        // Reset the handle back to the beginning so that it can be streamed
384        rewind( $srcHandle );
385
386        if ( $hashDigestSize !== $srcSize ) {
387            $status->fatal( 'backend-fail-hash', $params['src'] );
388
389            return $status;
390        }
391
392        // Headers that are not strictly a function of the file content
393        $mutableHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
394        // Make sure that the "content-type" header is set to something sensible
395        $mutableHeaders['content-type']
396            ??= $this->getContentType( $params['dst'], null, $params['src'] );
397
398        $reqs = [ [
399            'method' => 'PUT',
400            'container' => $dstCont,
401            'relPath' => $dstRel,
402            'headers' => array_merge(
403                $mutableHeaders,
404                [
405                    'content-length' => $srcSize,
406                    'etag' => hash_final( $md5Context ),
407                    'x-object-meta-sha1base36' =>
408                        \Wikimedia\base_convert( hash_final( $sha1Context ), 16, 36, 31 )
409                ]
410            ),
411            'body' => $srcHandle // resource
412        ] ];
413
414        $method = __METHOD__;
415        $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
416            [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
417            if ( $rcode === 201 || $rcode === 202 ) {
418                // good
419            } elseif ( $rcode === 412 ) {
420                $status->fatal( 'backend-fail-contenttype', $params['dst'] );
421            } else {
422                $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
423            }
424
425            return SwiftFileOpHandle::CONTINUE_IF_OK;
426        };
427
428        $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
429        $opHandle->resourcesToClose[] = $srcHandle;
430
431        if ( !empty( $params['async'] ) ) { // deferred
432            $status->value = $opHandle;
433        } else { // actually write the object in Swift
434            $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
435        }
436
437        return $status;
438    }
439
440    /** @inheritDoc */
441    protected function doCopyInternal( array $params ) {
442        $status = $this->newStatus();
443
444        [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
445        if ( $srcRel === null ) {
446            $status->fatal( 'backend-fail-invalidpath', $params['src'] );
447
448            return $status;
449        }
450
451        [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
452        if ( $dstRel === null ) {
453            $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
454
455            return $status;
456        }
457
458        $reqs = [ [
459            'method' => 'PUT',
460            'container' => $dstCont,
461            'relPath' => $dstRel,
462            'headers' => array_merge(
463                $this->extractMutableContentHeaders( $params['headers'] ?? [] ),
464                [
465                    'x-copy-from' => '/' . rawurlencode( $srcCont ) . '/' .
466                        str_replace( "%2F", "/", rawurlencode( $srcRel ) )
467                ]
468            )
469        ] ];
470
471        $method = __METHOD__;
472        $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
473            [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
474            if ( $rcode === 201 ) {
475                // good
476            } elseif ( $rcode === 404 ) {
477                if ( empty( $params['ignoreMissingSource'] ) ) {
478                    $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
479                }
480            } else {
481                $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
482            }
483
484            return SwiftFileOpHandle::CONTINUE_IF_OK;
485        };
486
487        $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
488        if ( !empty( $params['async'] ) ) { // deferred
489            $status->value = $opHandle;
490        } else { // actually write the object in Swift
491            $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
492        }
493
494        return $status;
495    }
496
497    /** @inheritDoc */
498    protected function doMoveInternal( array $params ) {
499        $status = $this->newStatus();
500
501        [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
502        if ( $srcRel === null ) {
503            $status->fatal( 'backend-fail-invalidpath', $params['src'] );
504
505            return $status;
506        }
507
508        [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
509        if ( $dstRel === null ) {
510            $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
511
512            return $status;
513        }
514
515        $reqs = [ [
516            'method' => 'PUT',
517            'container' => $dstCont,
518            'relPath' => $dstRel,
519            'headers' => array_merge(
520                $this->extractMutableContentHeaders( $params['headers'] ?? [] ),
521                [
522                    'x-copy-from' => '/' . rawurlencode( $srcCont ) . '/' .
523                        str_replace( "%2F", "/", rawurlencode( $srcRel ) )
524                ]
525            )
526        ] ];
527        if ( "{$srcCont}/{$srcRel}" !== "{$dstCont}/{$dstRel}" ) {
528            $reqs[] = [
529                'method' => 'DELETE',
530                'container' => $srcCont,
531                'relPath' => $srcRel,
532                'headers' => []
533            ];
534        }
535
536        $method = __METHOD__;
537        $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
538            [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
539            if ( $request['method'] === 'PUT' && $rcode === 201 ) {
540                // good
541            } elseif ( $request['method'] === 'DELETE' && $rcode === 204 ) {
542                // good
543            } elseif ( $rcode === 404 ) {
544                if ( empty( $params['ignoreMissingSource'] ) ) {
545                    $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
546                } else {
547                    // Leave Status as OK but skip the DELETE request
548                    return SwiftFileOpHandle::CONTINUE_NO;
549                }
550            } else {
551                $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
552            }
553
554            return SwiftFileOpHandle::CONTINUE_IF_OK;
555        };
556
557        $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
558        if ( !empty( $params['async'] ) ) { // deferred
559            $status->value = $opHandle;
560        } else { // actually move the object in Swift
561            $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
562        }
563
564        return $status;
565    }
566
567    /** @inheritDoc */
568    protected function doDeleteInternal( array $params ) {
569        $status = $this->newStatus();
570
571        [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
572        if ( $srcRel === null ) {
573            $status->fatal( 'backend-fail-invalidpath', $params['src'] );
574
575            return $status;
576        }
577
578        $reqs = [ [
579            'method' => 'DELETE',
580            'container' => $srcCont,
581            'relPath' => $srcRel,
582            'headers' => []
583        ] ];
584
585        $method = __METHOD__;
586        $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
587            [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
588            if ( $rcode === 204 ) {
589                // good
590            } elseif ( $rcode === 404 ) {
591                if ( empty( $params['ignoreMissingSource'] ) ) {
592                    $status->fatal( 'backend-fail-delete', $params['src'] );
593                }
594            } else {
595                $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
596            }
597
598            return SwiftFileOpHandle::CONTINUE_IF_OK;
599        };
600
601        $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
602        if ( !empty( $params['async'] ) ) { // deferred
603            $status->value = $opHandle;
604        } else { // actually delete the object in Swift
605            $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
606        }
607
608        return $status;
609    }
610
611    /** @inheritDoc */
612    protected function doDescribeInternal( array $params ) {
613        $status = $this->newStatus();
614
615        [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
616        if ( $srcRel === null ) {
617            $status->fatal( 'backend-fail-invalidpath', $params['src'] );
618
619            return $status;
620        }
621
622        // Fetch the old object headers/metadata...this should be in stat cache by now
623        $stat = $this->getFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
624        if ( $stat && !isset( $stat['xattr'] ) ) { // older cache entry
625            $stat = $this->doGetFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
626        }
627        if ( !$stat ) {
628            $status->fatal( 'backend-fail-describe', $params['src'] );
629
630            return $status;
631        }
632
633        // Swift object POST clears any prior headers, so merge the new and old headers here.
634        // Also, during, POST, libcurl adds "Content-Type: application/x-www-form-urlencoded"
635        // if "Content-Type" is not set, which would clobber the header value for the object.
636        $oldMetadataHeaders = [];
637        foreach ( $stat['xattr']['metadata'] as $name => $value ) {
638            $oldMetadataHeaders["x-object-meta-$name"] = $value;
639        }
640        $newContentHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
641        $oldContentHeaders = $stat['xattr']['headers'];
642
643        $reqs = [ [
644            'method' => 'POST',
645            'container' => $srcCont,
646            'relPath' => $srcRel,
647            'headers' => $oldMetadataHeaders + $newContentHeaders + $oldContentHeaders
648        ] ];
649
650        $method = __METHOD__;
651        $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
652            [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
653            if ( $rcode === 202 ) {
654                // good
655            } elseif ( $rcode === 404 ) {
656                $status->fatal( 'backend-fail-describe', $params['src'] );
657            } else {
658                $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
659            }
660        };
661
662        $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
663        if ( !empty( $params['async'] ) ) { // deferred
664            $status->value = $opHandle;
665        } else { // actually change the object in Swift
666            $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
667        }
668
669        return $status;
670    }
671
672    /**
673     * @inheritDoc
674     */
675    protected function doPrepareInternal( $fullCont, $dirRel, array $params ) {
676        $status = $this->newStatus();
677
678        // (a) Check if container already exists
679        $stat = $this->getContainerStat( $fullCont );
680        if ( is_array( $stat ) ) {
681            return $status; // already there
682        } elseif ( $stat === self::RES_ERROR ) {
683            $status->fatal( 'backend-fail-internal', $this->name );
684            $this->logger->error( __METHOD__ . ': cannot get container stat' );
685        } else {
686            // (b) Create container as needed with proper ACLs
687            $params['op'] = 'prepare';
688            $status->merge( $this->createContainer( $fullCont, $params ) );
689        }
690
691        return $status;
692    }
693
694    /** @inheritDoc */
695    protected function doSecureInternal( $fullCont, $dirRel, array $params ) {
696        $status = $this->newStatus();
697        if ( empty( $params['noAccess'] ) ) {
698            return $status; // nothing to do
699        }
700
701        $stat = $this->getContainerStat( $fullCont );
702        if ( is_array( $stat ) ) {
703            $readUsers = array_merge( $this->secureReadUsers, [ $this->swiftUser ] );
704            $writeUsers = array_merge( $this->secureWriteUsers, [ $this->swiftUser ] );
705            // Make container private to end-users...
706            $status->merge( $this->setContainerAccess(
707                $fullCont,
708                $readUsers,
709                $writeUsers
710            ) );
711        } elseif ( $stat === self::RES_ABSENT ) {
712            $status->fatal( 'backend-fail-usable', $params['dir'] );
713        } else {
714            $status->fatal( 'backend-fail-internal', $this->name );
715            $this->logger->error( __METHOD__ . ': cannot get container stat' );
716        }
717
718        return $status;
719    }
720
721    /** @inheritDoc */
722    protected function doPublishInternal( $fullCont, $dirRel, array $params ) {
723        $status = $this->newStatus();
724        if ( empty( $params['access'] ) ) {
725            return $status; // nothing to do
726        }
727
728        $stat = $this->getContainerStat( $fullCont );
729        if ( is_array( $stat ) ) {
730            $readUsers = array_merge( $this->readUsers, [ $this->swiftUser, '.r:*' ] );
731            if ( !empty( $params['listing'] ) ) {
732                $readUsers[] = '.rlistings';
733            }
734            $writeUsers = array_merge( $this->writeUsers, [ $this->swiftUser ] );
735
736            // Make container public to end-users...
737            $status->merge( $this->setContainerAccess(
738                $fullCont,
739                $readUsers,
740                $writeUsers
741            ) );
742        } elseif ( $stat === self::RES_ABSENT ) {
743            $status->fatal( 'backend-fail-usable', $params['dir'] );
744        } else {
745            $status->fatal( 'backend-fail-internal', $this->name );
746            $this->logger->error( __METHOD__ . ': cannot get container stat' );
747        }
748
749        return $status;
750    }
751
752    /** @inheritDoc */
753    protected function doCleanInternal( $fullCont, $dirRel, array $params ) {
754        $status = $this->newStatus();
755
756        // Only containers themselves can be removed, all else is virtual
757        if ( $dirRel != '' ) {
758            return $status; // nothing to do
759        }
760
761        // (a) Check the container
762        $stat = $this->getContainerStat( $fullCont, true );
763        if ( $stat === self::RES_ABSENT ) {
764            return $status; // ok, nothing to do
765        } elseif ( $stat === self::RES_ERROR ) {
766            $status->fatal( 'backend-fail-internal', $this->name );
767            $this->logger->error( __METHOD__ . ': cannot get container stat' );
768        } elseif ( is_array( $stat ) && $stat['count'] == 0 ) {
769            // (b) Delete the container if empty
770            $params['op'] = 'clean';
771            $status->merge( $this->deleteContainer( $fullCont, $params ) );
772        }
773
774        return $status;
775    }
776
777    /** @inheritDoc */
778    protected function doGetFileStat( array $params ) {
779        $params = [ 'srcs' => [ $params['src'] ], 'concurrency' => 1 ] + $params;
780        unset( $params['src'] );
781        $stats = $this->doGetFileStatMulti( $params );
782
783        return reset( $stats );
784    }
785
786    /**
787     * Convert dates like "Tue, 03 Jan 2012 22:01:04 GMT"/"2013-05-11T07:37:27.678360Z".
788     * Dates might also come in like "2013-05-11T07:37:27.678360" from Swift listings,
789     * missing the timezone suffix (though Ceph RGW does not appear to have this bug).
790     *
791     * @param string $ts
792     * @param int|TS $format Output format (TS::* constant)
793     * @return string
794     * @throws FileBackendError
795     */
796    protected function convertSwiftDate( $ts, $format = TS::MW ) {
797        try {
798            $timestamp = new ConvertibleTimestamp( $ts );
799
800            return $timestamp->getTimestamp( $format );
801        } catch ( TimeoutException $e ) {
802            throw $e;
803        } catch ( Exception $e ) {
804            throw new FileBackendError( $e->getMessage() );
805        }
806    }
807
808    /**
809     * Fill in any missing object metadata and save it to Swift
810     *
811     * @param array $objHdrs Object response headers
812     * @param string $path Storage path to object
813     * @return array New headers
814     */
815    protected function addMissingHashMetadata( array $objHdrs, $path ) {
816        if ( isset( $objHdrs['x-object-meta-sha1base36'] ) ) {
817            return $objHdrs; // nothing to do
818        }
819
820        /** @noinspection PhpUnusedLocalVariableInspection */
821        $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
822        $this->logger->error( __METHOD__ . ": {path} was not stored with SHA-1 metadata.",
823            [ 'path' => $path ] );
824
825        $objHdrs['x-object-meta-sha1base36'] = false;
826
827        // Find prior custom HTTP headers
828        $postHeaders = $this->extractMutableContentHeaders( $objHdrs );
829        // Find prior metadata headers
830        $postHeaders += $this->extractMetadataHeaders( $objHdrs );
831
832        $status = $this->newStatus();
833        /** @noinspection PhpUnusedLocalVariableInspection */
834        $scopeLockS = $this->getScopedFileLocks( [ $path ], LockManager::LOCK_UW, $status );
835        if ( $status->isOK() ) {
836            $tmpFile = $this->getLocalCopy( [ 'src' => $path, 'latest' => 1 ] );
837            if ( $tmpFile ) {
838                $hash = $tmpFile->getSha1Base36();
839                if ( $hash !== false ) {
840                    $objHdrs['x-object-meta-sha1base36'] = $hash;
841                    // Merge new SHA1 header into the old ones
842                    $postHeaders['x-object-meta-sha1base36'] = $hash;
843                    [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
844                    [ $rcode ] = $this->requestWithAuth( [
845                        'method' => 'POST',
846                        'container' => $srcCont,
847                        'relPath' => $srcRel,
848                        'headers' => $postHeaders
849                    ] );
850                    if ( $rcode >= 200 && $rcode <= 299 ) {
851                        $this->deleteFileCache( $path );
852
853                        return $objHdrs; // success
854                    }
855                }
856            }
857        }
858
859        $this->logger->error( __METHOD__ . ': unable to set SHA-1 metadata for {path}',
860            [ 'path' => $path ] );
861
862        return $objHdrs; // failed
863    }
864
865    /** @inheritDoc */
866    protected function doGetFileContentsMulti( array $params ) {
867        $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
868        // Blindly create tmp files and stream to them, catching any exception
869        // if the file does not exist. Do not waste time doing file stats here.
870        $reqs = []; // (path => op)
871
872        // Initial dummy values to preserve path order
873        $contents = array_fill_keys( $params['srcs'], self::RES_ERROR );
874        foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
875            [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
876            if ( $srcRel === null ) {
877                continue; // invalid storage path
878            }
879            // Create a new temporary memory file...
880            $handle = fopen( 'php://temp', 'wb' );
881            if ( $handle ) {
882                $reqs[$path] = [
883                    'method'  => 'GET',
884                    'container' => $srcCont,
885                    'relPath' => $srcRel,
886                    'headers' => $this->headersFromParams( $params ),
887                    'stream'  => $handle,
888                ];
889            }
890        }
891
892        $reqs = $this->requestMultiWithAuth(
893            $reqs,
894            [ 'maxConnsPerHost' => $params['concurrency'] ]
895        );
896        foreach ( $reqs as $path => $op ) {
897            [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
898            if ( $rcode >= 200 && $rcode <= 299 ) {
899                rewind( $op['stream'] ); // start from the beginning
900                $content = (string)stream_get_contents( $op['stream'] );
901                $size = strlen( $content );
902                // Make sure that stream finished
903                if ( $size === (int)$rhdrs['content-length'] ) {
904                    $contents[$path] = $content;
905                } else {
906                    $contents[$path] = self::RES_ERROR;
907                    $rerr = "Got {$size}/{$rhdrs['content-length']} bytes";
908                    $this->onError( null, __METHOD__,
909                        [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
910                }
911            } elseif ( $rcode === 404 ) {
912                $contents[$path] = self::RES_ABSENT;
913            } else {
914                $contents[$path] = self::RES_ERROR;
915                $this->onError( null, __METHOD__,
916                    [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc, $rbody );
917            }
918            fclose( $op['stream'] ); // close open handle
919        }
920
921        return $contents;
922    }
923
924    /** @inheritDoc */
925    protected function doDirectoryExists( $fullCont, $dirRel, array $params ) {
926        $prefix = ( $dirRel == '' ) ? null : "{$dirRel}/";
927        $status = $this->objectListing( $fullCont, 'names', 1, null, $prefix );
928        if ( $status->isOK() ) {
929            return ( count( $status->value ) ) > 0;
930        }
931
932        return self::RES_ERROR;
933    }
934
935    /**
936     * @see FileBackendStore::getDirectoryListInternal()
937     * @param string $fullCont
938     * @param string $dirRel
939     * @param array $params
940     * @return SwiftFileBackendDirList
941     */
942    public function getDirectoryListInternal( $fullCont, $dirRel, array $params ) {
943        return new SwiftFileBackendDirList( $this, $fullCont, $dirRel, $params );
944    }
945
946    /**
947     * @see FileBackendStore::getFileListInternal()
948     * @param string $fullCont
949     * @param string $dirRel
950     * @param array $params
951     * @return SwiftFileBackendFileList
952     */
953    public function getFileListInternal( $fullCont, $dirRel, array $params ) {
954        return new SwiftFileBackendFileList( $this, $fullCont, $dirRel, $params );
955    }
956
957    /**
958     * Do not call this function outside of SwiftFileBackendFileList
959     *
960     * @param string $fullCont Resolved container name
961     * @param string $dir Resolved storage directory with no trailing slash
962     * @param string|null &$after Resolved container relative path used for continuation paging
963     * @param int $limit Max number of items to list
964     * @param array $params Parameters for {@link getDirectoryList()}
965     * @return string[] List of resolved container relative directories directly under $dir
966     * @throws FileBackendError
967     */
968    public function getDirListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
969        $dirs = [];
970        if ( $after === INF ) {
971            return $dirs; // nothing more
972        }
973
974        /** @noinspection PhpUnusedLocalVariableInspection */
975        $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
976
977        $prefix = ( $dir == '' ) ? null : "{$dir}/";
978        // Non-recursive: only list dirs right under $dir
979        if ( !empty( $params['topOnly'] ) ) {
980            $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
981            if ( !$status->isOK() ) {
982                throw new FileBackendError( "Iterator page I/O error." );
983            }
984            $objects = $status->value;
985            foreach ( $objects as $object ) { // files and directories
986                if ( str_ends_with( $object, '/' ) ) {
987                    $dirs[] = $object; // directories end in '/'
988                }
989            }
990        } else {
991            // Recursive: list all dirs under $dir and its subdirs
992            $getParentDir = static function ( $path ) {
993                return ( $path !== null && str_contains( $path, '/' ) ) ? dirname( $path ) : false;
994            };
995
996            // Get directory from last item of prior page
997            $lastDir = $getParentDir( $after ); // must be first page
998            $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
999
1000            if ( !$status->isOK() ) {
1001                throw new FileBackendError( "Iterator page I/O error." );
1002            }
1003
1004            $objects = $status->value;
1005
1006            foreach ( $objects as $object ) { // files
1007                $objectDir = $getParentDir( $object ); // directory of object
1008
1009                if ( $objectDir !== false && $objectDir !== $dir ) {
1010                    // Swift stores paths in UTF-8, using binary sorting.
1011                    // See function "create_container_table" in common/db.py.
1012                    // If a directory is not "greater" than the last one,
1013                    // then it was already listed by the calling iterator.
1014                    if ( strcmp( $objectDir, $lastDir ) > 0 ) {
1015                        $pDir = $objectDir;
1016                        do { // add dir and all its parent dirs
1017                            $dirs[] = "{$pDir}/";
1018                            $pDir = $getParentDir( $pDir );
1019                        } while ( $pDir !== false
1020                            && strcmp( $pDir, $lastDir ) > 0 // not done already
1021                            && strlen( $pDir ) > strlen( $dir ) // within $dir
1022                        );
1023                    }
1024                    $lastDir = $objectDir;
1025                }
1026            }
1027        }
1028        // Page on the unfiltered directory listing (what is returned may be filtered)
1029        if ( count( $objects ) < $limit ) {
1030            $after = INF; // avoid a second RTT
1031        } else {
1032            $after = end( $objects ); // update last item
1033        }
1034
1035        return $dirs;
1036    }
1037
1038    /**
1039     * Do not call this function outside of SwiftFileBackendFileList
1040     *
1041     * @param string $fullCont Resolved container name
1042     * @param string $dir Resolved storage directory with no trailing slash
1043     * @param string|null &$after Resolved container relative path of file to list items after
1044     * @param int $limit Max number of items to list
1045     * @param array $params Parameters for {@link getFileList()}
1046     * @return array[] List of (name, stat map or null) tuples under $dir
1047     * @throws FileBackendError
1048     */
1049    public function getFileListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
1050        $files = []; // list of (path, stat map or null) entries
1051        if ( $after === INF ) {
1052            return $files; // nothing more
1053        }
1054
1055        /** @noinspection PhpUnusedLocalVariableInspection */
1056        $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
1057
1058        $prefix = ( $dir == '' ) ? null : "{$dir}/";
1059        // $objects will contain a list of unfiltered names or stdClass items
1060        // Non-recursive: only list files right under $dir
1061        if ( !empty( $params['topOnly'] ) ) {
1062            if ( !empty( $params['adviseStat'] ) ) {
1063                $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix, '/' );
1064            } else {
1065                $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
1066            }
1067        } else {
1068            // Recursive: list all files under $dir and its subdirs
1069            if ( !empty( $params['adviseStat'] ) ) {
1070                $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix );
1071            } else {
1072                $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
1073            }
1074        }
1075
1076        // Reformat this list into a list of (name, stat map or null) entries
1077        if ( !$status->isOK() ) {
1078            throw new FileBackendError( "Iterator page I/O error." );
1079        }
1080
1081        $objects = $status->value;
1082        $files = $this->buildFileObjectListing( $objects );
1083
1084        // Page on the unfiltered object listing (what is returned may be filtered)
1085        if ( count( $objects ) < $limit ) {
1086            $after = INF; // avoid a second RTT
1087        } else {
1088            $after = end( $objects ); // update last item
1089            $after = is_object( $after ) ? $after->name : $after;
1090        }
1091
1092        return $files;
1093    }
1094
1095    /**
1096     * Build a list of file objects, filtering out any directories
1097     * and extracting any stat info if provided in $objects
1098     *
1099     * @param stdClass[]|string[] $objects List of stdClass items or object names
1100     * @return array[] List of (name, stat map or null) entries
1101     */
1102    private function buildFileObjectListing( array $objects ) {
1103        $names = [];
1104        foreach ( $objects as $object ) {
1105            if ( is_object( $object ) ) {
1106                if ( isset( $object->subdir ) || !isset( $object->name ) ) {
1107                    continue; // virtual directory entry; ignore
1108                }
1109                $stat = [
1110                    // Convert various random Swift dates to TS::MW
1111                    'mtime'  => $this->convertSwiftDate( $object->last_modified, TS::MW ),
1112                    'size'   => (int)$object->bytes,
1113                    'sha1'   => null,
1114                    // Note: manifest ETags are not an MD5 of the file
1115                    'md5'    => ctype_xdigit( $object->hash ) ? $object->hash : null,
1116                    'latest' => false // eventually consistent
1117                ];
1118                $names[] = [ $object->name, $stat ];
1119            } elseif ( !str_ends_with( $object, '/' ) ) {
1120                // Omit directories, which end in '/' in listings
1121                $names[] = [ $object, null ];
1122            }
1123        }
1124
1125        return $names;
1126    }
1127
1128    /**
1129     * Do not call this function outside of SwiftFileBackendFileList
1130     *
1131     * @param string $path Storage path
1132     * @param array $val Stat value
1133     */
1134    public function loadListingStatInternal( $path, array $val ) {
1135        $this->cheapCache->setField( $path, 'stat', $val );
1136    }
1137
1138    /** @inheritDoc */
1139    protected function doGetFileXAttributes( array $params ) {
1140        $stat = $this->getFileStat( $params );
1141        // Stat entries filled by file listings don't include metadata/headers
1142        if ( is_array( $stat ) && !isset( $stat['xattr'] ) ) {
1143            $this->clearCache( [ $params['src'] ] );
1144            $stat = $this->getFileStat( $params );
1145        }
1146
1147        if ( is_array( $stat ) ) {
1148            return $stat['xattr'];
1149        }
1150
1151        return $stat === self::RES_ERROR ? self::RES_ERROR : self::RES_ABSENT;
1152    }
1153
1154    /** @inheritDoc */
1155    protected function doGetFileSha1base36( array $params ) {
1156        // Avoid using stat entries from file listings, which never include the SHA-1 hash.
1157        // Also, recompute the hash if it's not part of the metadata headers for some reason.
1158        $params['requireSHA1'] = true;
1159
1160        $stat = $this->getFileStat( $params );
1161        if ( is_array( $stat ) ) {
1162            return $stat['sha1'];
1163        }
1164
1165        return $stat === self::RES_ERROR ? self::RES_ERROR : self::RES_ABSENT;
1166    }
1167
1168    /** @inheritDoc */
1169    protected function doStreamFile( array $params ) {
1170        $status = $this->newStatus();
1171
1172        $flags = !empty( $params['headless'] ) ? HTTPFileStreamer::STREAM_HEADLESS : 0;
1173
1174        [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
1175        if ( $srcRel === null ) {
1176            HTTPFileStreamer::send404Message( $params['src'], $flags );
1177            $status->fatal( 'backend-fail-invalidpath', $params['src'] );
1178
1179            return $status;
1180        }
1181
1182        if ( !is_array( $this->getContainerStat( $srcCont ) ) ) {
1183            HTTPFileStreamer::send404Message( $params['src'], $flags );
1184            $status->fatal( 'backend-fail-stream', $params['src'] );
1185
1186            return $status;
1187        }
1188
1189        // If "headers" is set, we only want to send them if the file is there.
1190        // Do not bother checking if the file exists if headers are not set though.
1191        if ( $params['headers'] && !$this->fileExists( $params ) ) {
1192            HTTPFileStreamer::send404Message( $params['src'], $flags );
1193            $status->fatal( 'backend-fail-stream', $params['src'] );
1194
1195            return $status;
1196        }
1197
1198        // Send the requested additional headers
1199        if ( empty( $params['headless'] ) ) {
1200            foreach ( $params['headers'] as $header ) {
1201                $this->header( $header );
1202            }
1203        }
1204
1205        if ( empty( $params['allowOB'] ) ) {
1206            // Cancel output buffering and gzipping if set
1207            $this->resetOutputBuffer();
1208        }
1209
1210        $handle = fopen( 'php://output', 'wb' );
1211        [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1212            'method' => 'GET',
1213            'container' => $srcCont,
1214            'relPath' => $srcRel,
1215            'headers' => $this->headersFromParams( $params ) + $params['options'],
1216            'stream' => $handle,
1217            'flags'  => [ 'relayResponseHeaders' => empty( $params['headless'] ) ]
1218        ] );
1219
1220        if ( $rcode >= 200 && $rcode <= 299 ) {
1221            // good
1222        } elseif ( $rcode === 404 ) {
1223            $status->fatal( 'backend-fail-stream', $params['src'] );
1224            // Per T43113, nasty things can happen if bad cache entries get
1225            // stuck in cache. It's also possible that this error can come up
1226            // with simple race conditions. Clear out the stat cache to be safe.
1227            $this->clearCache( [ $params['src'] ] );
1228            $this->deleteFileCache( $params['src'] );
1229        } else {
1230            $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1231        }
1232
1233        return $status;
1234    }
1235
1236    /** @inheritDoc */
1237    protected function doGetLocalCopyMulti( array $params ) {
1238        $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
1239        // Blindly create tmp files and stream to them, catching any exception
1240        // if the file does not exist. Do not waste time doing file stats here.
1241        $reqs = []; // (path => op)
1242
1243        // Initial dummy values to preserve path order
1244        $tmpFiles = array_fill_keys( $params['srcs'], self::RES_ERROR );
1245        foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
1246            [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
1247            if ( $srcRel === null ) {
1248                continue; // invalid storage path
1249            }
1250            // Get source file extension
1251            $ext = FileBackend::extensionFromPath( $path );
1252            // Create a new temporary file...
1253            $tmpFile = $this->tmpFileFactory->newTempFSFile( 'localcopy_', $ext );
1254            $handle = $tmpFile ? fopen( $tmpFile->getPath(), 'wb' ) : false;
1255            if ( $handle ) {
1256                $reqs[$path] = [
1257                    'method'  => 'GET',
1258                    'container' => $srcCont,
1259                    'relPath' => $srcRel,
1260                    'headers' => $this->headersFromParams( $params ),
1261                    'stream'  => $handle,
1262                ];
1263                $tmpFiles[$path] = $tmpFile;
1264            }
1265        }
1266
1267        // Ceph RADOS Gateway is in use (strong consistency) or X-Newest will be used
1268        $latest = ( $this->isRGW || !empty( $params['latest'] ) );
1269
1270        $reqs = $this->requestMultiWithAuth(
1271            $reqs,
1272            [ 'maxConnsPerHost' => $params['concurrency'] ]
1273        );
1274        foreach ( $reqs as $path => $op ) {
1275            [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
1276            fclose( $op['stream'] ); // close open handle
1277            if ( $rcode >= 200 && $rcode <= 299 ) {
1278                /** @var TempFSFile $tmpFile */
1279                $tmpFile = $tmpFiles[$path];
1280                // Make sure that the stream finished and fully wrote to disk
1281                $size = $tmpFile->getSize();
1282                if ( $size !== (int)$rhdrs['content-length'] ) {
1283                    $tmpFiles[$path] = self::RES_ERROR;
1284                    $rerr = "Got {$size}/{$rhdrs['content-length']} bytes";
1285                    $this->onError( null, __METHOD__,
1286                        [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
1287                }
1288                // Set the file stat process cache in passing
1289                $stat = $this->getStatFromHeaders( $rhdrs );
1290                $stat['latest'] = $latest;
1291                $this->cheapCache->setField( $path, 'stat', $stat );
1292            } elseif ( $rcode === 404 ) {
1293                $tmpFiles[$path] = self::RES_ABSENT;
1294                $this->cheapCache->setField(
1295                    $path,
1296                    'stat',
1297                    $latest ? self::ABSENT_LATEST : self::ABSENT_NORMAL
1298                );
1299            } else {
1300                $tmpFiles[$path] = self::RES_ERROR;
1301                $this->onError( null, __METHOD__,
1302                    [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc, $rbody );
1303            }
1304        }
1305
1306        return $tmpFiles;
1307    }
1308
1309    /** @inheritDoc */
1310    public function addShellboxInputFile( BoxedCommand $command, string $boxedName,
1311        array $params
1312    ) {
1313        if ( $this->canShellboxGetTempUrl ) {
1314            $urlParams = [ 'src' => $params['src'] ];
1315            if ( $this->shellboxIpRange !== null ) {
1316                $urlParams['ipRange'] = $this->shellboxIpRange;
1317            }
1318            $url = $this->getFileHttpUrl( $urlParams );
1319            if ( $url ) {
1320                $command->inputFileFromUrl( $boxedName, $url );
1321                return $this->newStatus();
1322            }
1323        }
1324        return parent::addShellboxInputFile( $command, $boxedName, $params );
1325    }
1326
1327    /** @inheritDoc */
1328    public function getFileHttpUrl( array $params ) {
1329        if ( $this->swiftTempUrlKey == '' &&
1330            ( $this->rgwS3AccessKey == '' || $this->rgwS3SecretKey != '' )
1331        ) {
1332            $this->logger->debug( "Can't get Swift file URL: no key available" );
1333            return self::TEMPURL_ERROR;
1334        }
1335
1336        [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
1337        if ( $srcRel === null ) {
1338            $this->logger->debug( "Can't get Swift file URL: can't resolve path" );
1339            return self::TEMPURL_ERROR; // invalid path
1340        }
1341
1342        $auth = $this->getAuthentication();
1343        if ( !$auth ) {
1344            $this->logger->debug( "Can't get Swift file URL: authentication failed" );
1345            return self::TEMPURL_ERROR;
1346        }
1347
1348        $method = $params['method'] ?? 'GET';
1349        $ttl = $params['ttl'] ?? 86400;
1350        $expires = time() + $ttl;
1351
1352        if ( $this->swiftTempUrlKey != '' ) {
1353            $url = $this->storageUrl( $auth, $srcCont, $srcRel );
1354            // Swift wants the signature based on the unencoded object name
1355            $contPath = parse_url( $this->storageUrl( $auth, $srcCont ), PHP_URL_PATH );
1356            $messageParts = [
1357                $method,
1358                $expires,
1359                "{$contPath}/{$srcRel}"
1360            ];
1361            $query = [
1362                'temp_url_expires' => $expires,
1363            ];
1364            if ( isset( $params['ipRange'] ) ) {
1365                array_unshift( $messageParts, "ip={$params['ipRange']}" );
1366                $query['temp_url_ip_range'] = $params['ipRange'];
1367            }
1368
1369            $signature = hash_hmac( 'sha1',
1370                implode( "\n", $messageParts ),
1371                $this->swiftTempUrlKey
1372            );
1373            $query = [ 'temp_url_sig' => $signature ] + $query;
1374
1375            return $url . '?' . http_build_query( $query );
1376        } else { // give S3 API URL for rgw
1377            // Path for signature starts with the bucket
1378            $spath = '/' . rawurlencode( $srcCont ) . '/' .
1379                str_replace( '%2F', '/', rawurlencode( $srcRel ) );
1380            // Calculate the hash
1381            $signature = base64_encode( hash_hmac(
1382                'sha1',
1383                "{$method}\n\n\n{$expires}\n{$spath}",
1384                $this->rgwS3SecretKey,
1385                true // raw
1386            ) );
1387            // See https://s3.amazonaws.com/doc/s3-developer-guide/RESTAuthentication.html.
1388            // Note: adding a newline for empty CanonicalizedAmzHeaders does not work.
1389            // Note: S3 API is the rgw default; remove the /swift/ URL bit.
1390            return str_replace( '/swift/v1', '', $this->storageUrl( $auth ) . $spath ) .
1391                '?' .
1392                http_build_query( [
1393                    'Signature' => $signature,
1394                    'Expires' => $expires,
1395                    'AWSAccessKeyId' => $this->rgwS3AccessKey
1396                ] );
1397        }
1398    }
1399
1400    /** @inheritDoc */
1401    protected function directoriesAreVirtual() {
1402        return true;
1403    }
1404
1405    /**
1406     * Get headers to send to Swift when reading a file based
1407     * on a FileBackend params array, e.g. that of getLocalCopy().
1408     * $params is currently only checked for a 'latest' flag.
1409     *
1410     * @param array $params
1411     * @return array
1412     */
1413    protected function headersFromParams( array $params ) {
1414        $hdrs = [];
1415        if ( !empty( $params['latest'] ) ) {
1416            $hdrs['x-newest'] = 'true';
1417        }
1418
1419        return $hdrs;
1420    }
1421
1422    /** @inheritDoc */
1423    protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
1424        /** @var SwiftFileOpHandle[] $fileOpHandles */
1425        '@phan-var SwiftFileOpHandle[] $fileOpHandles';
1426
1427        /** @var StatusValue[] $statuses */
1428        $statuses = [];
1429
1430        // Split the HTTP requests into stages that can be done concurrently
1431        $httpReqsByStage = []; // map of (stage => index => HTTP request)
1432        foreach ( $fileOpHandles as $index => $fileOpHandle ) {
1433            $reqs = $fileOpHandle->httpOp;
1434            foreach ( $reqs as $stage => $req ) {
1435                $httpReqsByStage[$stage][$index] = $req;
1436            }
1437            $statuses[$index] = $this->newStatus();
1438        }
1439
1440        // Run all requests for the first stage, then the next, and so on
1441        $reqCount = count( $httpReqsByStage );
1442        for ( $stage = 0; $stage < $reqCount; ++$stage ) {
1443            $httpReqs = $this->requestMultiWithAuth( $httpReqsByStage[$stage] );
1444            foreach ( $httpReqs as $index => $httpReq ) {
1445                /** @var SwiftFileOpHandle $fileOpHandle */
1446                $fileOpHandle = $fileOpHandles[$index];
1447                // Run the callback for each request of this operation
1448                $status = $statuses[$index];
1449                ( $fileOpHandle->callback )( $httpReq, $status );
1450                // On failure, abort all remaining requests for this operation. This is used
1451                // in "move" operations to abort the DELETE request if the PUT request fails.
1452                if (
1453                    !$status->isOK() ||
1454                    $fileOpHandle->state === $fileOpHandle::CONTINUE_NO
1455                ) {
1456                    $stages = count( $fileOpHandle->httpOp );
1457                    for ( $s = ( $stage + 1 ); $s < $stages; ++$s ) {
1458                        unset( $httpReqsByStage[$s][$index] );
1459                    }
1460                }
1461            }
1462        }
1463
1464        return $statuses;
1465    }
1466
1467    /**
1468     * Set read/write permissions for a Swift container.
1469     *
1470     * @see http://docs.openstack.org/developer/swift/misc.html#acls
1471     *
1472     * In general, we don't allow listings to end-users. It's not useful, isn't well-defined
1473     * (lists are truncated to 10000 item with no way to page), and is just a performance risk.
1474     *
1475     * @param string $container Resolved Swift container
1476     * @param array $readUsers List of the possible criteria for a request to have
1477     * access to read a container. Each item is one of the following formats:
1478     *   - account:user        : Grants access if the request is by the given user
1479     *   - ".r:<regex>"        : Grants access if the request is from a referrer host that
1480     *                           matches the expression and the request is not for a listing.
1481     *                           Setting this to '*' effectively makes a container public.
1482     *   -".rlistings:<regex>" : Grants access if the request is from a referrer host that
1483     *                           matches the expression and the request is for a listing.
1484     * @param array $writeUsers A list of the possible criteria for a request to have
1485     * access to write to a container. Each item is of the following format:
1486     *   - account:user       : Grants access if the request is by the given user
1487     * @return StatusValue Good status without value for success, fatal otherwise.
1488     */
1489    protected function setContainerAccess( $container, array $readUsers, array $writeUsers ) {
1490        $status = $this->newStatus();
1491
1492        [ $rcode, , , , ] = $this->requestWithAuth( [
1493            'method' => 'POST',
1494            'container' => $container,
1495            'headers' => [
1496                'x-container-read' => implode( ',', $readUsers ),
1497                'x-container-write' => implode( ',', $writeUsers )
1498            ]
1499        ] );
1500
1501        if ( $rcode != 204 && $rcode !== 202 ) {
1502            $status->fatal( 'backend-fail-internal', $this->name );
1503            $this->logger->error( __METHOD__ . ': unexpected rcode value ({rcode})',
1504                [ 'rcode' => $rcode ] );
1505        }
1506
1507        return $status;
1508    }
1509
1510    /**
1511     * Get a Swift container stat map, possibly from process cache.
1512     * Use $bypassCache if the file count or byte count is needed.
1513     *
1514     * @param string $container Container name
1515     * @param bool $bypassCache Bypass all caches and load from Swift
1516     * @return array|false|null False on 404, null on failure
1517     */
1518    protected function getContainerStat( $container, $bypassCache = false ) {
1519        /** @noinspection PhpUnusedLocalVariableInspection */
1520        $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
1521
1522        if ( $bypassCache ) { // purge cache
1523            $this->containerStatCache->clear( $container );
1524        } elseif ( !$this->containerStatCache->hasField( $container, 'stat' ) ) {
1525            $this->primeContainerCache( [ $container ] ); // check persistent cache
1526        }
1527        if ( !$this->containerStatCache->hasField( $container, 'stat' ) ) {
1528            [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $this->requestWithAuth( [
1529                'method' => 'HEAD',
1530                'container' => $container
1531            ] );
1532
1533            if ( $rcode === 204 ) {
1534                $stat = [
1535                    'count' => $rhdrs['x-container-object-count'],
1536                    'bytes' => $rhdrs['x-container-bytes-used']
1537                ];
1538                if ( $bypassCache ) {
1539                    return $stat;
1540                } else {
1541                    $this->containerStatCache->setField( $container, 'stat', $stat ); // cache it
1542                    $this->setContainerCache( $container, $stat ); // update persistent cache
1543                }
1544            } elseif ( $rcode === 404 ) {
1545                return self::RES_ABSENT;
1546            } else {
1547                $this->onError( null, __METHOD__,
1548                    [ 'cont' => $container ], $rerr, $rcode, $rdesc, $rbody );
1549
1550                return self::RES_ERROR;
1551            }
1552        }
1553
1554        return $this->containerStatCache->getField( $container, 'stat' );
1555    }
1556
1557    /**
1558     * Create a Swift container
1559     *
1560     * @param string $container Container name
1561     * @param array $params
1562     * @return StatusValue Good status without value for success, fatal otherwise.
1563     */
1564    protected function createContainer( $container, array $params ) {
1565        $status = $this->newStatus();
1566
1567        // @see SwiftFileBackend::setContainerAccess()
1568        if ( empty( $params['noAccess'] ) ) {
1569            // public
1570            $readUsers = array_merge( $this->readUsers, [ '.r:*', $this->swiftUser ] );
1571            if ( empty( $params['noListing'] ) ) {
1572                $readUsers[] = '.rlistings';
1573            }
1574            $writeUsers = array_merge( $this->writeUsers, [ $this->swiftUser ] );
1575        } else {
1576            // private
1577            $readUsers = array_merge( $this->secureReadUsers, [ $this->swiftUser ] );
1578            $writeUsers = array_merge( $this->secureWriteUsers, [ $this->swiftUser ] );
1579        }
1580
1581        [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1582            'method' => 'PUT',
1583            'container' => $container,
1584            'headers' => [
1585                'x-container-read' => implode( ',', $readUsers ),
1586                'x-container-write' => implode( ',', $writeUsers )
1587            ]
1588        ] );
1589
1590        if ( $rcode === 201 ) { // new
1591            // good
1592        } elseif ( $rcode === 202 ) { // already there
1593            // this shouldn't really happen, but is OK
1594        } else {
1595            $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1596        }
1597
1598        return $status;
1599    }
1600
1601    /**
1602     * Delete a Swift container
1603     *
1604     * @param string $container Container name
1605     * @param array $params
1606     * @return StatusValue
1607     */
1608    protected function deleteContainer( $container, array $params ) {
1609        $status = $this->newStatus();
1610
1611        [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1612            'method' => 'DELETE',
1613            'container' => $container
1614        ] );
1615
1616        if ( $rcode >= 200 && $rcode <= 299 ) { // deleted
1617            $this->containerStatCache->clear( $container ); // purge
1618        } elseif ( $rcode === 404 ) { // not there
1619            // this shouldn't really happen, but is OK
1620        } elseif ( $rcode === 409 ) { // not empty
1621            $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); // race?
1622        } else {
1623            $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1624        }
1625
1626        return $status;
1627    }
1628
1629    /**
1630     * Get a list of objects under a container.
1631     * Either just the names or a list of stdClass objects with details can be returned.
1632     *
1633     * @param string $fullCont
1634     * @param string $type ('info' for a list of object detail maps, 'names' for names only)
1635     * @param int $limit
1636     * @param string|null $after
1637     * @param string|null $prefix
1638     * @param string|null $delim
1639     * @return StatusValue With the list as value
1640     */
1641    private function objectListing(
1642        $fullCont, $type, $limit, $after = null, $prefix = null, $delim = null
1643    ) {
1644        $status = $this->newStatus();
1645
1646        $query = [ 'limit' => $limit ];
1647        if ( $type === 'info' ) {
1648            $query['format'] = 'json';
1649        }
1650        if ( $after !== null ) {
1651            $query['marker'] = $after;
1652        }
1653        if ( $prefix !== null ) {
1654            $query['prefix'] = $prefix;
1655        }
1656        if ( $delim !== null ) {
1657            $query['delimiter'] = $delim;
1658        }
1659
1660        [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1661            'method' => 'GET',
1662            'container' => $fullCont,
1663            'query' => $query,
1664        ] );
1665
1666        $params = [ 'cont' => $fullCont, 'prefix' => $prefix, 'delim' => $delim ];
1667        if ( $rcode === 200 ) { // good
1668            if ( $type === 'info' ) {
1669                $status->value = json_decode( trim( $rbody ) );
1670            } else {
1671                $status->value = explode( "\n", trim( $rbody ) );
1672            }
1673        } elseif ( $rcode === 204 ) {
1674            $status->value = []; // empty container
1675        } elseif ( $rcode === 404 ) {
1676            $status->value = []; // no container
1677        } else {
1678            $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1679        }
1680
1681        return $status;
1682    }
1683
1684    /** @inheritDoc */
1685    protected function doPrimeContainerCache( array $containerInfo ) {
1686        foreach ( $containerInfo as $container => $info ) {
1687            $this->containerStatCache->setField( $container, 'stat', $info );
1688        }
1689    }
1690
1691    /** @inheritDoc */
1692    protected function doGetFileStatMulti( array $params ) {
1693        $stats = [];
1694
1695        $reqs = []; // (path => op)
1696        // (a) Check the containers of the paths...
1697        foreach ( $params['srcs'] as $path ) {
1698            [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
1699            if ( $srcRel === null ) {
1700                // invalid storage path
1701                $stats[$path] = self::RES_ERROR;
1702                continue;
1703            }
1704
1705            $cstat = $this->getContainerStat( $srcCont );
1706            if ( $cstat === self::RES_ABSENT ) {
1707                $stats[$path] = self::RES_ABSENT;
1708                continue; // ok, nothing to do
1709            } elseif ( $cstat === self::RES_ERROR ) {
1710                $stats[$path] = self::RES_ERROR;
1711                continue;
1712            }
1713
1714            $reqs[$path] = [
1715                'method'  => 'HEAD',
1716                'container' => $srcCont,
1717                'relPath' => $srcRel,
1718                'headers' => $this->headersFromParams( $params )
1719            ];
1720        }
1721
1722        // (b) Check the files themselves...
1723        $reqs = $this->requestMultiWithAuth(
1724            $reqs,
1725            [ 'maxConnsPerHost' => $params['concurrency'] ]
1726        );
1727        foreach ( $reqs as $path => $op ) {
1728            [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
1729            if ( $rcode === 200 || $rcode === 204 ) {
1730                // Update the object if it is missing some headers
1731                if ( !empty( $params['requireSHA1'] ) ) {
1732                    $rhdrs = $this->addMissingHashMetadata( $rhdrs, $path );
1733                }
1734                // Load the stat map from the headers
1735                $stat = $this->getStatFromHeaders( $rhdrs );
1736                if ( $this->isRGW ) {
1737                    $stat['latest'] = true; // strong consistency
1738                }
1739            } elseif ( $rcode === 404 ) {
1740                $stat = self::RES_ABSENT;
1741            } else {
1742                $stat = self::RES_ERROR;
1743                $this->onError( null, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1744            }
1745            $stats[$path] = $stat;
1746        }
1747
1748        return $stats;
1749    }
1750
1751    /**
1752     * @param array $rhdrs
1753     * @return array
1754     */
1755    protected function getStatFromHeaders( array $rhdrs ) {
1756        // Fetch all of the custom metadata headers
1757        $metadata = $this->getMetadataFromHeaders( $rhdrs );
1758        // Fetch all of the custom raw HTTP headers
1759        $headers = $this->extractMutableContentHeaders( $rhdrs );
1760
1761        return [
1762            // Convert various random Swift dates to TS::MW
1763            'mtime' => $this->convertSwiftDate( $rhdrs['last-modified'], TS::MW ),
1764            // Empty objects actually return no content-length header in Ceph
1765            'size'  => isset( $rhdrs['content-length'] ) ? (int)$rhdrs['content-length'] : 0,
1766            'sha1'  => $metadata['sha1base36'] ?? null,
1767            // Note: manifest ETags are not an MD5 of the file
1768            'md5'   => ctype_xdigit( $rhdrs['etag'] ) ? $rhdrs['etag'] : null,
1769            'xattr' => [ 'metadata' => $metadata, 'headers' => $headers ]
1770        ];
1771    }
1772
1773    /**
1774     * Get the cached auth token.
1775     *
1776     * @return array|null Credential map
1777     */
1778    protected function getAuthentication() {
1779        if ( $this->authErrorTimestamp !== null ) {
1780            $interval = time() - $this->authErrorTimestamp;
1781            if ( $interval < 60 ) {
1782                $this->logger->debug(
1783                    'rejecting request since auth failure occurred {interval} seconds ago',
1784                    [ 'interval' => $interval ]
1785                );
1786                return null;
1787            } else { // actually retry this time
1788                $this->authErrorTimestamp = null;
1789            }
1790        }
1791        // Authenticate with proxy and get a session key...
1792        if ( !$this->authCreds ) {
1793            $cacheKey = $this->getCredsCacheKey( $this->swiftUser );
1794            $creds = $this->credentialCache->get( $cacheKey );
1795            if (
1796                isset( $creds['auth_token'] ) &&
1797                isset( $creds['storage_url'] ) &&
1798                isset( $creds['expiry_time'] ) &&
1799                $creds['expiry_time'] > time()
1800            ) {
1801                // Cache hit; reuse the cached credentials cache
1802                $this->setAuthCreds( $creds );
1803            } else {
1804                // Cache miss; re-authenticate to get the credentials
1805                $this->refreshAuthentication();
1806            }
1807        }
1808
1809        return $this->authCreds;
1810    }
1811
1812    /**
1813     * Update the auth credentials
1814     *
1815     * @param array|null $creds
1816     */
1817    private function setAuthCreds( ?array $creds ) {
1818        $this->logger->debug( 'Using auth token with expiry_time={expiry_time}',
1819            [
1820                'expiry_time' => isset( $creds['expiry_time'] )
1821                    ? gmdate( 'c', $creds['expiry_time'] ) : 'null'
1822            ]
1823        );
1824        $this->authCreds = $creds;
1825        // Ceph RGW does not use <account> in URLs (OpenStack Swift uses "/v1/<account>")
1826        if ( $creds && str_ends_with( $creds['storage_url'], '/v1' ) ) {
1827            $this->isRGW = true; // take advantage of strong consistency in Ceph
1828        }
1829    }
1830
1831    /**
1832     * Fetch the auth token from the server, without caching.
1833     *
1834     * @return array|null Credential map
1835     */
1836    private function refreshAuthentication() {
1837        [ $rcode, , $rhdrs, $rbody, ] = $this->http->run( [
1838            'method' => 'GET',
1839            'url' => "{$this->swiftAuthUrl}/v1.0",
1840            'headers' => [
1841                'x-auth-user' => $this->swiftUser,
1842                'x-auth-key' => $this->swiftKey
1843            ]
1844        ], self::DEFAULT_HTTP_OPTIONS );
1845
1846        if ( $rcode >= 200 && $rcode <= 299 ) { // OK
1847            if ( isset( $rhdrs['x-auth-token-expires'] ) ) {
1848                $ttl = intval( $rhdrs['x-auth-token-expires'] );
1849            } else {
1850                $ttl = $this->authTTL;
1851            }
1852            $expiryTime = time() + $ttl;
1853            $creds = [
1854                'auth_token' => $rhdrs['x-auth-token'],
1855                'storage_url' => $this->swiftStorageUrl ?? $rhdrs['x-storage-url'],
1856                'expiry_time' => $expiryTime,
1857            ];
1858            $this->credentialCache->set(
1859                $this->getCredsCacheKey( $this->swiftUser ),
1860                $creds,
1861                $expiryTime
1862            );
1863        } elseif ( $rcode === 401 ) {
1864            $this->onError( null, __METHOD__, [], "Authentication failed.", $rcode );
1865            $this->authErrorTimestamp = time();
1866            $creds = null;
1867        } else {
1868            $this->onError( null, __METHOD__, [], "HTTP return code: $rcode", $rcode, $rbody );
1869            $this->authErrorTimestamp = time();
1870            $creds = null;
1871        }
1872        $this->setAuthCreds( $creds );
1873        return $creds;
1874    }
1875
1876    /**
1877     * @param array $creds From getAuthentication()
1878     * @param string|null $container
1879     * @param string|null $object
1880     * @return string
1881     */
1882    protected function storageUrl( array $creds, $container = null, $object = null ) {
1883        $parts = [ $creds['storage_url'] ];
1884        if ( ( $container ?? '' ) !== '' ) {
1885            $parts[] = rawurlencode( $container );
1886        }
1887        if ( ( $object ?? '' ) !== '' ) {
1888            $parts[] = str_replace( "%2F", "/", rawurlencode( $object ) );
1889        }
1890
1891        return implode( '/', $parts );
1892    }
1893
1894    /**
1895     * @param array $creds From getAuthentication()
1896     * @return array
1897     */
1898    protected function authTokenHeaders( array $creds ) {
1899        return [ 'x-auth-token' => $creds['auth_token'] ];
1900    }
1901
1902    /**
1903     * Get the cache key for a container
1904     *
1905     * @param string $username
1906     * @return string
1907     */
1908    private function getCredsCacheKey( $username ) {
1909        return 'swiftcredentials:' . md5( $username . ':' . $this->swiftAuthUrl );
1910    }
1911
1912    /**
1913     * Perform an authenticated HTTP request
1914     *
1915     * @param array $req The request data, including:
1916     *   - container: The name of the container (required)
1917     *   - relPath: The relative path under the container. If this is omitted,
1918     *     the request will refer to the container itself.
1919     *   - headers: An array of request headers to send, in addition to the
1920     *     auth headers.
1921     *   - Other keys to be passed through to MultiHttpClient::run()
1922     * @return array The response array from MultiHttpClient::run()
1923     */
1924    private function requestWithAuth( array $req ) {
1925        return $this->requestMultiWithAuth( [ $req ] )[0]['response'];
1926    }
1927
1928    /**
1929     * Perform a batch of authenticated HTTP requests
1930     *
1931     * @param array $reqs An array of request data arrays. See self::requestWithAuth()
1932     * @param array $options Options to pass through to MultiHttpClient, in
1933     *    addition to the default options DEFAULT_HTTP_OPTIONS
1934     * @return array The request array with responses populated, as returned by
1935     *   MultiHttpClient::runMulti()
1936     */
1937    private function requestMultiWithAuth( array $reqs, $options = [] ) {
1938        $remainingTries = 2;
1939        $auth = $this->getAuthentication();
1940        while ( true ) {
1941            if ( !$auth ) {
1942                foreach ( $reqs as &$req ) {
1943                    if ( !isset( $req['response'] ) ) {
1944                        $req['response'] = $this->getAuthFailureResponse();
1945                    }
1946                }
1947                break;
1948            }
1949            foreach ( $reqs as &$req ) {
1950                '@phan-var array $req'; // Not array[]
1951                if ( isset( $req['response'] ) ) {
1952                    // Request was attempted before
1953                    // Retry only if it gave a 401 response code
1954                    if ( $req['response']['code'] !== 401 ) {
1955                        continue;
1956                    }
1957                }
1958                $req['headers'] = $this->authTokenHeaders( $auth ) + ( $req['headers'] ?? [] );
1959                $req['url'] = $this->storageUrl( $auth, $req['container'], $req['relPath'] ?? null );
1960            }
1961            unset( $req );
1962            $reqs = $this->http->runMulti( $reqs, $options + self::DEFAULT_HTTP_OPTIONS );
1963            if ( --$remainingTries > 0 ) {
1964                // Retry if any request failed with 401 "not authorized"
1965                foreach ( $reqs as $req ) {
1966                    if ( $req['response']['code'] === 401 ) {
1967                        $auth = $this->refreshAuthentication();
1968                        continue 2;
1969                    }
1970                }
1971            }
1972            break;
1973        }
1974        return $reqs;
1975    }
1976
1977    /**
1978     * Get a synthetic response to return from requestWithAuth() or requestMultiWithAuth()
1979     * if the request could not be issued due to failure of a prior authentication request.
1980     * This failure should not be logged as an HTTP error since the original failure would
1981     * have been logged.
1982     *
1983     * @return array
1984     */
1985    private function getAuthFailureResponse() {
1986        return [
1987            'code' => 0,
1988            0 => 0,
1989            'reason' => '',
1990            1 => '',
1991            'headers' => [],
1992            2 => [],
1993            'body' => '',
1994            3 => '',
1995            'error' => self::AUTH_FAILURE_ERROR,
1996            4 => self::AUTH_FAILURE_ERROR
1997        ];
1998    }
1999
2000    /**
2001     * Log an unexpected exception for this backend.
2002     * This also sets the StatusValue object to have a fatal error.
2003     *
2004     * @param StatusValue|null $status To add fatal errors to
2005     * @param string $func
2006     * @param array $params
2007     * @param string $err Error string
2008     * @param int $code HTTP status
2009     * @param string $desc HTTP StatusValue description
2010     * @param string $body HTTP body
2011     */
2012    public function onError( $status, $func, array $params, $err = '', $code = 0, $desc = '', $body = '' ) {
2013        if ( $code === 0 && $err === self::AUTH_FAILURE_ERROR ) {
2014            if ( $status instanceof StatusValue ) {
2015                $status->fatal( 'backend-fail-connect', $this->name );
2016            }
2017            // Already logged
2018            return;
2019        }
2020        if ( $status instanceof StatusValue ) {
2021            $status->fatal( 'backend-fail-internal', $this->name );
2022        }
2023        $msg = "HTTP {code} ({desc}) in '{func}'";
2024        $msgParams = [
2025            'code'   => $code,
2026            'desc'   => $desc,
2027            'func'   => $func,
2028            'req_params' => $params,
2029        ];
2030        if ( $err ) {
2031            $msg .= ': {err}';
2032            $msgParams['err'] = $err;
2033        }
2034        if ( $code == 502 ) {
2035            $msg .= ' ({truncatedBody})';
2036            $msgParams['truncatedBody'] = substr( strip_tags( $body ), 0, 100 );
2037        }
2038        $this->logger->error( $msg, $msgParams );
2039    }
2040}
2041
2042/** @deprecated class alias since 1.43 */
2043class_alias( SwiftFileBackend::class, 'SwiftFileBackend' );