• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Cecilapp / Cecil / 21832161424

09 Feb 2026 03:55PM UTC coverage: 82.346% (-0.02%) from 82.365%
21832161424

push

github

ArnaudLigny
Remove MD5 mention from Asset hash comment

1 of 1 new or added line in 1 file covered. (100.0%)

35 existing lines in 1 file now uncovered.

3321 of 4033 relevant lines covered (82.35%)

0.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

54.96
/src/Cache.php
1
<?php
2

3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <arnaud@ligny.fr>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
declare(strict_types=1);
13

14
namespace Cecil;
15

16
use Cecil\Builder;
17
use Cecil\Collection\Page\Page;
18
use Cecil\Exception\RuntimeException;
19
use Cecil\Util;
20
use Psr\SimpleCache\CacheInterface;
21

22
/**
23
 * Cache class.
24
 *
25
 * Provides methods to manage cache files for assets, pages, and other data.
26
 */
27
class Cache implements CacheInterface
28
{
29
    /** Reserved characters that cannot be used in a key */
30
    public const RESERVED_CHARACTERS = '{}()/\@:';
31

32
    /** @var Builder */
33
    protected $builder;
34

35
    /** @var string */
36
    protected $cacheDir;
37

38
    public function __construct(Builder $builder, string $pool = '')
39
    {
40
        $this->builder = $builder;
1✔
41
        $this->cacheDir = Util::joinFile($builder->getConfig()->getCachePath(), $pool);
1✔
42
    }
43

44
    /**
45
     * {@inheritdoc}
46
     */
47
    public function set($key, $value, $ttl = null): bool
48
    {
49
        try {
50
            $key = self::sanitizeKey($key);
1✔
51
            $this->prune($key);
1✔
52
            // put file content in a dedicated file
53
            if (\is_array($value) && !empty($value['content']) && !empty($value['path'])) {
1✔
54
                Util\File::getFS()->dumpFile($this->getContentFile($value['path']), $value['content']);
1✔
55
                unset($value['content']);
1✔
56
            }
57
            // serialize data
58
            $data = serialize([
1✔
59
                'value'      => $value,
1✔
60
                'expiration' => $ttl === null ? null : time() + $this->duration($ttl),
1✔
61
            ]);
1✔
62
            Util\File::getFS()->dumpFile($this->getFile($key), $data);
1✔
63
        } catch (\Exception $e) {
×
UNCOV
64
            $this->builder->getLogger()->error($e->getMessage());
×
65

UNCOV
66
            return false;
×
67
        }
68

69
        return true;
1✔
70
    }
71

72
    /**
73
     * {@inheritdoc}
74
     */
75
    public function has($key): bool
76
    {
77
        $key = self::sanitizeKey($key);
1✔
78
        if (!Util\File::getFS()->exists($this->getFile($key))) {
1✔
79
            return false;
1✔
80
        }
81

82
        return true;
1✔
83
    }
84

85
    /**
86
     * {@inheritdoc}
87
     */
88
    public function get($key, $default = null): mixed
89
    {
90
        try {
91
            $key = self::sanitizeKey($key);
1✔
92
            // return default value if file doesn't exists
93
            if (false === $content = Util\File::fileGetContents($this->getFile($key))) {
1✔
UNCOV
94
                return $default;
×
95
            }
96
            // unserialize data
97
            $data = unserialize($content);
1✔
98
            // check expiration
99
            if ($data['expiration'] !== null && $data['expiration'] <= time()) {
1✔
UNCOV
100
                $this->builder->getLogger()->debug(\sprintf('Cache expired: "%s"', $key));
×
101
                // remove expired cache file
102
                $this->delete($key);
×
103

UNCOV
104
                return $default;
×
105
            }
106
            // get content from dedicated file
107
            if (\is_array($data['value']) && isset($data['value']['path'])) {
1✔
108
                if (false !== $content = Util\File::fileGetContents($this->getContentFile($data['value']['path']))) {
1✔
109
                    $data['value']['content'] = $content;
1✔
110
                }
111
            }
UNCOV
112
        } catch (\Exception $e) {
×
UNCOV
113
            $this->builder->getLogger()->error($e->getMessage());
×
114

115
            return $default;
×
116
        }
117

118
        return $data['value'];
1✔
119
    }
120

121
    /**
122
     * {@inheritdoc}
123
     */
124
    public function delete($key): bool
125
    {
126
        try {
UNCOV
127
            $key = self::sanitizeKey($key);
×
UNCOV
128
            Util\File::getFS()->remove($this->getFile($key));
×
129
            $this->prune($key);
×
130
            // remove content dedicated file
131
            $value = $this->get($key);
×
132
            if (!empty($value['path'])) {
×
133
                $this->deleteContentFile($value['path']);
×
134
            }
135
        } catch (\Exception $e) {
×
UNCOV
136
            $this->builder->getLogger()->error($e->getMessage());
×
137

138
            return false;
×
139
        }
140

UNCOV
141
        return true;
×
142
    }
143

144
    /**
145
     * {@inheritdoc}
146
     */
147
    public function clear(): bool
148
    {
149
        try {
UNCOV
150
            Util\File::getFS()->remove($this->cacheDir);
×
151
        } catch (\Exception $e) {
×
UNCOV
152
            $this->builder->getLogger()->error($e->getMessage());
×
153

154
            return false;
×
155
        }
156

UNCOV
157
        return true;
×
158
    }
159

160
    /**
161
     * {@inheritdoc}
162
     */
163
    public function getMultiple($keys, $default = null): iterable
164
    {
UNCOV
165
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
×
166
    }
167

168
    /**
169
     * {@inheritdoc}
170
     */
171
    public function setMultiple($values, $ttl = null): bool
172
    {
UNCOV
173
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
×
174
    }
175

176
    /**
177
     * {@inheritdoc}
178
     */
179
    public function deleteMultiple($keys): bool
180
    {
UNCOV
181
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
×
182
    }
183

184
    /**
185
     * Creates key: "$name_$tags__HASH__VERSION".
186
     *
187
     * The $name is generated from the $value (string, Asset, or file) and can be customized with the $name parameter.
188
     * The $tags are generated from the $tags parameter and can be used to add extra information to the key (e.g., options used to process the value). They are optional and can be empty.
189
     * The $hash is generated from the $value and is used to identify the content. It is generated with a fast non-cryptographic hash function (xxh128) to ensure good performance.
190
     * The $version is the Cecil version, used to invalidate cache when Cecil is updated.
191
     * The key is sanitized to remove reserved characters and ensure it is a valid file name. It is also truncated to 200 characters to avoid issues with file system limits.
192
     *
193
     * @throws \InvalidArgumentException if the $value type is not supported or if the generated key contains reserved characters.
194
     */
195
    public function createKey(mixed $value, ?string $name = null, ?array $tags = null): string
196
    {
197
        // string
198
        if (\is_string($value)) {
1✔
199
            $name .= '-' . hash('adler32', $value);
1✔
200
            $hash = hash('xxh128', $value);
1✔
201
        }
202

203
        // asset
204
        if ($value instanceof Asset) {
1✔
205
            $name = "{$value['_path']}_{$value['ext']}";
1✔
206
            $hash = $value['hash'];
1✔
207
        }
208

209
        // file
210
        if ($value instanceof \Symfony\Component\Finder\SplFileInfo) {
1✔
211
            $name = $value->getRelativePathname();
1✔
212
            $hash = hash_file('xxh128', $value->getRealPath());
1✔
213
        }
214

215
        if (empty($name) or empty($hash)) {
1✔
UNCOV
216
            throw new \InvalidArgumentException(\sprintf('Unable to create cache key: invalid value type "%s".', get_debug_type($value)));
×
217
        }
218

219
        // tags
220
        $t = $tags;
1✔
221
        $tags = [];
1✔
222
        if ($t !== null) {
1✔
223
            foreach ($t as $key => $value) {
1✔
224
                switch (\gettype($value)) {
1✔
225
                    case 'boolean':
1✔
226
                        if ($value === true) {
1✔
227
                            $tags[] = str_replace('_', '', $key);
1✔
228
                        }
229
                        break;
1✔
230
                    case 'string':
1✔
231
                    case 'integer':
1✔
232
                        if (!empty($value)) {
1✔
233
                            $tags[] = substr($key, 0, 1) . $value;
1✔
234
                        }
235
                        break;
1✔
236
                }
237
            }
238
        }
239
        if (\count($tags) > 0) {
1✔
240
            $name .= '_' . implode('_', $tags);
1✔
241
        }
242

243
        $name = self::sanitizeKey($name);
1✔
244

245
        return \sprintf('%s__%s__%s', $name, $hash, $this->builder->getVersion());
1✔
246
    }
247

248
    /**
249
     * Clear cache by pattern.
250
     */
251
    public function clearByPattern(string $pattern): int
252
    {
253
        try {
UNCOV
254
            if (!Util\File::getFS()->exists($this->cacheDir)) {
×
UNCOV
255
                throw new RuntimeException(\sprintf('The cache directory "%s" does not exists.', $this->cacheDir));
×
256
            }
UNCOV
257
            $fileCount = 0;
×
UNCOV
258
            $iterator = new \RecursiveIteratorIterator(
×
UNCOV
259
                new \RecursiveDirectoryIterator($this->cacheDir),
×
260
                \RecursiveIteratorIterator::SELF_FIRST
×
261
            );
×
UNCOV
262
            foreach ($iterator as $file) {
×
263
                if ($file->isFile()) {
×
264
                    if (preg_match('/' . $pattern . '/i', $file->getPathname())) {
×
265
                        Util\File::getFS()->remove($file->getPathname());
×
266
                        $fileCount++;
×
267
                        $this->builder->getLogger()->debug(\sprintf('Cache removed: "%s"', trim(Util\File::getFS()->makePathRelative($file->getPathname(), $this->builder->getConfig()->getCachePath()), '/')));
×
268
                    }
269
                }
270
            }
271
        } catch (\Exception $e) {
×
272
            $this->builder->getLogger()->error($e->getMessage());
×
273

UNCOV
274
            return 0;
×
275
        }
276

277
        return $fileCount;
×
278
    }
279

280
    /**
281
     * Returns cache content file from path.
282
     */
283
    public function getContentFile(string $path): string
284
    {
285
        $path = str_replace(['https://', 'http://'], '', $path); // remove protocol (if URL)
1✔
286

287
        return Util::joinFile($this->cacheDir, '_files', $path);
1✔
288
    }
289

290
    /**
291
     * Returns cache file from key.
292
     */
293
    private function getFile(string $key): string
294
    {
295
        if (\count(explode('-', $key)) > 2) {
1✔
296
            return Util::joinFile($this->cacheDir, explode('-', $key, 2)[0], explode('-', $key, 2)[1]) . '.ser';
1✔
297
        }
298

299
        return Util::joinFile($this->cacheDir, "$key.ser");
1✔
300
    }
301

302
    /**
303
     * Prepares and validate $key.
304
     *
305
     * @throws \InvalidArgumentException if the $key contains reserved characters.
306
     */
307
    private static function sanitizeKey(string $key): string
308
    {
309
        $key = str_replace(['https://', 'http://'], '', $key); // remove protocol (if URL)
1✔
310
        $key = Page::slugify($key);                            // slugify
1✔
311
        $key = trim($key, '/');                                // remove leading/trailing slashes
1✔
312
        $key = str_replace(['\\', '/'], ['-', '-'], $key);     // replace slashes by hyphens
1✔
313
        $key = substr($key, 0, 200);                           // truncate to 200 characters (NTFS filename length limit is 255 characters)
1✔
314

315
        if (false !== strpbrk($key, self::RESERVED_CHARACTERS)) {
1✔
UNCOV
316
            throw new \InvalidArgumentException(\sprintf('Cache key "%s" contains reserved characters "%s".', $key, self::RESERVED_CHARACTERS));
×
317
        }
318

319
        return $key;
1✔
320
    }
321

322
    /**
323
     * Removes previous cache files.
324
     */
325
    private function prune(string $key): bool
326
    {
327
        try {
328
            $keyAsArray = explode('__', self::sanitizeKey($key));
1✔
329
            // if 2 or more parts (with hash), remove all files with the same first part
330
            // pattern: `name__hash__version`
331
            if (!empty($keyAsArray[0]) && \count($keyAsArray) >= 2) {
1✔
332
                $pattern = Util::joinFile($this->cacheDir, $keyAsArray[0]) . '*';
1✔
333
                foreach (glob($pattern) ?: [] as $filename) {
1✔
334
                    Util\File::getFS()->remove($filename);
×
335
                }
336
            }
UNCOV
337
        } catch (\Exception $e) {
×
UNCOV
338
            $this->builder->getLogger()->error($e->getMessage());
×
339

UNCOV
340
            return false;
×
341
        }
342

343
        return true;
1✔
344
    }
345

346
    /**
347
     * Convert the various expressions of a TTL value into duration in seconds.
348
     */
349
    protected function duration(int|\DateInterval $ttl): int
350
    {
351
        if (\is_int($ttl)) {
1✔
352
            return $ttl;
1✔
353
        }
354
        if ($ttl instanceof \DateInterval) {
×
UNCOV
355
            return (int) $ttl->d * 86400 + $ttl->h * 3600 + $ttl->i * 60 + $ttl->s;
×
356
        }
357

UNCOV
358
        throw new \InvalidArgumentException('TTL values must be int or \DateInterval');
×
359
    }
360

361
    /**
362
     * Removes the cache content file.
363
     */
364
    protected function deleteContentFile(string $path): bool
365
    {
366
        try {
367
            Util\File::getFS()->remove($this->getContentFile($path));
×
UNCOV
368
        } catch (\Exception $e) {
×
UNCOV
369
            $this->builder->getLogger()->error($e->getMessage());
×
370

UNCOV
371
            return false;
×
372
        }
373

UNCOV
374
        return true;
×
375
    }
376
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc