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

Cecilapp / Cecil / 26851091189

02 Jun 2026 10:12PM UTC coverage: 82.14%. First build
26851091189

Pull #2396

github

web-flow
Merge 91c4315d8 into c401fd851
Pull Request #2396: Improve cache cleanup and add doctor command

10 of 17 new or added lines in 1 file covered. (58.82%)

3509 of 4272 relevant lines covered (82.14%)

0.83 hits per line

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

61.94
/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
    private const SHARD_DELIMITER = '-';
32

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

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

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

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

67
            return false;
×
68
        }
69

70
        return true;
1✔
71
    }
72

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

83
        return true;
1✔
84
    }
85

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

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

116
            return $default;
×
117
        }
118

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

122
    /**
123
     * {@inheritdoc}
124
     */
125
    public function delete($key): bool
126
    {
127
        try {
128
            $key = self::sanitizeKey($key);
×
NEW
129
            $this->removeCacheEntry($this->getFile($key));
×
130
            $this->prune($key);
×
131
        } catch (\Exception $e) {
×
132
            $this->builder->getLogger()->error($e->getMessage());
×
133

134
            return false;
×
135
        }
136

137
        return true;
×
138
    }
139

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

150
            return false;
×
151
        }
152

153
        return true;
×
154
    }
155

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

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

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

180
    /**
181
     * Creates key: "$name_$tags__HASH__VERSION".
182
     *
183
     * The $name is generated from the $value (string, Asset, or file) and can be customized with the $name parameter.
184
     * 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.
185
     * 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.
186
     * The $version is the Cecil version, used to invalidate cache when Cecil is updated.
187
     * 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.
188
     *
189
     * @throws \InvalidArgumentException if the $value type is not supported or if the generated key contains reserved characters.
190
     */
191
    public function createKey(mixed $value, ?string $name = null, ?array $tags = null): string
192
    {
193
        // string
194
        if (\is_string($value)) {
1✔
195
            $name .= '-' . hash('adler32', $value);
1✔
196
            $hash = hash('xxh128', $value);
1✔
197
        }
198

199
        // asset
200
        if ($value instanceof Asset) {
1✔
201
            $name = "{$value['_path']}_{$value['ext']}";
1✔
202
            $hash = $value['hash'];
1✔
203
        }
204

205
        // file
206
        if ($value instanceof \Symfony\Component\Finder\SplFileInfo) {
1✔
207
            $name = $value->getRelativePathname();
1✔
208
            $hash = hash_file('xxh128', $value->getRealPath());
1✔
209
        }
210

211
        if (empty($name) or empty($hash)) {
1✔
212
            throw new \InvalidArgumentException(\sprintf('Unable to create cache key: invalid value type "%s".', get_debug_type($value)));
×
213
        }
214

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

239
        $name = self::sanitizeKey($name);
1✔
240

241
        return \sprintf('%s__%s__%s', $name, $hash, $this->builder->getVersion());
1✔
242
    }
243

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

270
            return 0;
×
271
        }
272

273
        return $fileCount;
×
274
    }
275

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

283
        return Util::joinFile($this->cacheDir, '_files', $path);
1✔
284
    }
285

286
    /**
287
     * Returns cache file from key.
288
     */
289
    private function getFile(string $key): string
290
    {
291
        [$targetDir, $fileName] = $this->resolveShard($key);
1✔
292

293
        return Util::joinFile($targetDir, "$fileName.ser");
1✔
294
    }
295

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

309
        if (false !== strpbrk($key, self::RESERVED_CHARACTERS)) {
1✔
310
            throw new \InvalidArgumentException(\sprintf('Cache key "%s" contains reserved characters "%s".', $key, self::RESERVED_CHARACTERS));
×
311
        }
312

313
        return $key;
1✔
314
    }
315

316
    /**
317
     * Removes previous cache files.
318
     */
319
    private function prune(string $key): bool
320
    {
321
        try {
322
            $keyAsArray = explode('__', self::sanitizeKey($key));
1✔
323
            // if 2 or more parts (with hash), remove all files with the same first part
324
            // pattern: `name__hash__version`
325
            if (!empty($keyAsArray[0]) && \count($keyAsArray) >= 2) {
1✔
326
                $prefix = $keyAsArray[0];
1✔
327
                [$targetDir, $filenamePrefix] = $this->resolveShard(\sprintf('%s__', $prefix));
1✔
328

329
                if (!Util\File::getFS()->exists($targetDir)) {
1✔
330
                    return true;
1✔
331
                }
332

333
                $iterator = new \DirectoryIterator($targetDir);
1✔
334
                foreach ($iterator as $file) {
1✔
335
                    if (!$file->isFile()) {
1✔
336
                        continue;
1✔
337
                    }
338
                    $fileNameWithoutExtension = pathinfo($file->getFilename(), PATHINFO_FILENAME);
1✔
339
                    if (str_starts_with($fileNameWithoutExtension, $filenamePrefix)) {
1✔
340
                        $this->removeCacheEntry($file->getPathname());
1✔
341
                    }
342
                }
343
            }
344
        } catch (\Exception $e) {
×
345
            $this->builder->getLogger()->error($e->getMessage());
×
346

347
            return false;
×
348
        }
349

350
        return true;
1✔
351
    }
352

353
    /**
354
     * Returns target cache directory and filename/key suffix according to sharding rules.
355
     */
356
    private function resolveShard(string $key): array
357
    {
358
        // Keep shard detection aligned with getFile historical behavior:
359
        // detect a shard from the part before "__", then split the full key at the first delimiter.
360
        $prefixBeforeHashSegments = explode(self::SHARD_DELIMITER, explode('__', $key)[0], 2);
1✔
361
        $fullKeyShardSegments = explode(self::SHARD_DELIMITER, $key, 2);
1✔
362
        $hasShardPrefix = \count($prefixBeforeHashSegments) === 2;
1✔
363
        $hasFullKeyShard = \count($fullKeyShardSegments) === 2 && !empty($fullKeyShardSegments[1]);
1✔
364
        if ($hasShardPrefix && $hasFullKeyShard) {
1✔
365
            return [Util::joinFile($this->cacheDir, $fullKeyShardSegments[0]), $fullKeyShardSegments[1]];
1✔
366
        }
367

368
        return [$this->cacheDir, $key];
1✔
369
    }
370

371
    /**
372
     * Convert the various expressions of a TTL value into duration in seconds.
373
     */
374
    protected function duration(int|\DateInterval $ttl): int
375
    {
376
        if (\is_int($ttl)) {
1✔
377
            return $ttl;
1✔
378
        }
379
        if ($ttl instanceof \DateInterval) {
×
380
            return (int) $ttl->d * 86400 + $ttl->h * 3600 + $ttl->i * 60 + $ttl->s;
×
381
        }
382

383
        throw new \InvalidArgumentException('TTL values must be int or \DateInterval');
×
384
    }
385

386
    /**
387
     * Removes the cache content file.
388
     */
389
    protected function deleteContentFile(string $path): bool
390
    {
391
        try {
392
            Util\File::getFS()->remove($this->getContentFile($path));
×
393
        } catch (\Exception $e) {
×
394
            $this->builder->getLogger()->error($e->getMessage());
×
395

396
            return false;
×
397
        }
398

399
        return true;
×
400
    }
401

402
    /**
403
     * Removes a cache metadata file and its dedicated content file if any.
404
     */
405
    private function removeCacheEntry(string $path): void
406
    {
407
        $value = $this->getStoredCacheValue($path);
1✔
408
        Util\File::getFS()->remove($path);
1✔
409
        if (\is_array($value) && !empty($value['path'])) {
1✔
NEW
410
            $this->deleteContentFile((string) $value['path']);
×
411
        }
412
    }
413

414
    /**
415
     * Extracts the stored cache value from a metadata file.
416
     */
417
    private function getStoredCacheValue(string $path): mixed
418
    {
419
        if (\pathinfo($path, \PATHINFO_EXTENSION) !== 'ser') {
1✔
NEW
420
            return null;
×
421
        }
422

423
        $content = Util\File::fileGetContents($path);
1✔
424
        if ($content === false) {
1✔
NEW
425
            return null;
×
426
        }
427

428
        $data = unserialize($content);
1✔
429
        if (!\is_array($data)) {
1✔
NEW
430
            return null;
×
431
        }
432

433
        return $data['value'] ?? null;
1✔
434
    }
435
}
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