Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.90% covered (warning)
54.90%
28 / 51
22.22% covered (danger)
22.22%
2 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileSource
54.90% covered (warning)
54.90%
28 / 51
22.22% covered (danger)
22.22%
2 / 9
43.51
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 allowsStaleLoad
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
6.00
 getExpiryTtl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExpiryWeight
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHashKey
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
2.75
 __toString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 readAndDecode
64.00% covered (warning)
64.00%
16 / 25
0.00% covered (danger)
0.00%
0 / 1
6.17
 locateInclude
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Settings\Source;
4
5use MediaWiki\Settings\Cache\CacheableSource;
6use MediaWiki\Settings\SettingsBuilderException;
7use MediaWiki\Settings\Source\Format\JsonFormat;
8use MediaWiki\Settings\Source\Format\SettingsFormat;
9use MediaWiki\Settings\Source\Format\YamlFormat;
10use UnexpectedValueException;
11use Wikimedia\AtEase\AtEase;
12
13/**
14 * Settings loaded from a local file path.
15 *
16 * @since 1.38
17 */
18class FileSource implements CacheableSource, SettingsIncludeLocator {
19    private const BUILT_IN_FORMATS = [
20        JsonFormat::class,
21        YamlFormat::class,
22    ];
23
24    /**
25     * Cache expiry TTL for file sources (24 hours).
26     *
27     * @see getExpiryTtl()
28     * @see CacheableSource::getExpiryTtl()
29     */
30    private const EXPIRY_TTL = 60 * 60 * 24;
31
32    /**
33     * Early expiry weight. This value influences the margin by which
34     * processes are selected to expire cached local-file settings early to
35     * avoid cache stampedes. Changes to this value are not likely to be
36     * necessary as time spent loading from local files should not have much
37     * variation and should already be well served by the default early expiry
38     * calculation.
39     *
40     * @see getExpiryWeight()
41     * @see CacheableSource::getExpiryWeight()
42     */
43    private const EXPIRY_WEIGHT = 1.0;
44
45    /**
46     * Format to use for reading the file, if given.
47     *
48     * @var ?SettingsFormat
49     */
50    private $format;
51
52    /**
53     * Path to local file.
54     * @var string
55     */
56    private $path;
57
58    /**
59     * Constructs a new FileSource for the given path and possibly a custom format
60     * to decode the contents. If no format is given, the built-in formats will be
61     * tried and the first one that supports the file extension will be used.
62     *
63     * Built-in formats:
64     *  - JsonFormat
65     *  - YamlFormat
66     *
67     * <code>
68     * <?php
69     * $source = new FileSource( 'my/settings.json' );
70     * $source->load();
71     * </code>
72     *
73     * While a specialized caller may want to pass a specialized format
74     *
75     * <code>
76     * <?php
77     * $source = new FileSource(
78     *     'my/settings.toml',
79     *     new TomlFormat()
80     * );
81     * $source->load();
82     * </code>
83     *
84     * @param string $path
85     * @param SettingsFormat|null $format
86     */
87    public function __construct( string $path, SettingsFormat $format = null ) {
88        $this->path = $path;
89        $this->format = $format;
90    }
91
92    /**
93     * Disallow stale results from file sources in the case of load failure as
94     * failing to read from disk would be quite catastrophic and worthy of
95     * propagation.
96     *
97     * @return bool
98     */
99    public function allowsStaleLoad(): bool {
100        return false;
101    }
102
103    /**
104     * Loads contents from the file and decodes them using the first format
105     * to claim support for the file's extension.
106     *
107     * @throws SettingsBuilderException
108     * @return array
109     */
110    public function load(): array {
111        $ext = pathinfo( $this->path, PATHINFO_EXTENSION );
112
113        // If there's only one format, don't bother to match the file
114        // extension.
115        if ( $this->format ) {
116            return $this->readAndDecode( $this->format );
117        }
118
119        foreach ( self::BUILT_IN_FORMATS as $format ) {
120            if ( call_user_func( [ $format, 'supportsFileExtension' ], $ext ) ) {
121                return $this->readAndDecode( new $format() );
122            }
123        }
124
125        throw new SettingsBuilderException(
126            "None of the built-in formats are suitable for '{path}'",
127            [
128                'path' => $this->path,
129            ]
130        );
131    }
132
133    /**
134     * The cache expiry TTL (in seconds) for this file source.
135     *
136     * @return int
137     */
138    public function getExpiryTtl(): int {
139        return self::EXPIRY_TTL;
140    }
141
142    /**
143     * Coefficient used in determining early expiration of cached settings to
144     * avoid stampedes.
145     *
146     * @return float
147     */
148    public function getExpiryWeight(): float {
149        return self::EXPIRY_WEIGHT;
150    }
151
152    /**
153     * Returns a hash key computed from the file's inode, size, and last
154     * modified timestamp.
155     *
156     * @return string
157     */
158    public function getHashKey(): string {
159        $stat = stat( $this->path );
160
161        if ( $stat === false ) {
162            throw new SettingsBuilderException(
163                "Failed to stat file '{path}'",
164                [ 'path' => $this->path ]
165            );
166        }
167
168        return sprintf( '%x-%x-%x', $stat['ino'], $stat['size'], $stat['mtime'] );
169    }
170
171    /**
172     * Returns this file source as a string.
173     *
174     * @return string
175     */
176    public function __toString(): string {
177        return $this->path;
178    }
179
180    /**
181     * Reads and decodes the file contents using the given format.
182     *
183     * @param SettingsFormat $format
184     *
185     * @return array
186     * @throws SettingsBuilderException
187     */
188    private function readAndDecode( SettingsFormat $format ): array {
189        $contents = AtEase::quietCall( 'file_get_contents', $this->path );
190
191        if ( $contents === false ) {
192            if ( !is_readable( $this->path ) ) {
193                throw new SettingsBuilderException(
194                    "File '{path}' is not readable",
195                    [ 'path' => $this->path ]
196                );
197            }
198
199            if ( is_dir( $this->path ) ) {
200                throw new SettingsBuilderException(
201                    "'{path}' is a directory, not a file",
202                    [ 'path' => $this->path ]
203                );
204            }
205
206            throw new SettingsBuilderException(
207                "Failed to read file '{path}'",
208                [ 'path' => $this->path ]
209            );
210        }
211
212        try {
213            return $format->decode( $contents );
214        } catch ( UnexpectedValueException $e ) {
215            throw new SettingsBuilderException(
216                "Failed to decode file '{path}': {message}",
217                [
218                    'path' => $this->path,
219                    'message' => $e->getMessage()
220                ]
221            );
222        }
223    }
224
225    public function locateInclude( string $location ): string {
226        return SettingsFileUtils::resolveRelativeLocation( $location, dirname( $this->path ) );
227    }
228}