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