Skip to content

Commit d5f124d

Browse files
Fix: Sanitize cache keys to avoid reserved characters validation error (#39)
* Fix: Sanitize cache keys to avoid reserved characters validation error --------- Co-authored-by: Xavier Lacot <[email protected]>
1 parent 39bf6f3 commit d5f124d

File tree

6 files changed

+193
-8
lines changed

6 files changed

+193
-8
lines changed

src/Storage/CacheKeySanitizer.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JoliCode\MediaBundle\Storage;
6+
7+
use Symfony\Contracts\Cache\ItemInterface;
8+
9+
/**
10+
* Sanitizes cache keys to ensure they don't contain PSR-6 reserved characters.
11+
*
12+
* @see ItemInterface::RESERVED_CHARACTERS
13+
*/
14+
final class CacheKeySanitizer
15+
{
16+
/**
17+
* Sanitizes a string by replacing PSR-6 reserved characters with underscores.
18+
*
19+
* PSR-6 reserved characters are: {}()/\@:
20+
*
21+
* @param string $value The value to sanitize
22+
*
23+
* @return string The sanitized value safe to use in cache keys
24+
*/
25+
public static function sanitize(string $value): string
26+
{
27+
return str_replace(str_split(ItemInterface::RESERVED_CHARACTERS), '_', $value);
28+
}
29+
}

src/Storage/MediaPropertyAccessor.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,22 +85,22 @@ function (ItemInterface $item) use ($path): int {
8585

8686
private function getCacheKey(string $path, string $property): string
8787
{
88-
return \sprintf(
88+
return CacheKeySanitizer::sanitize(\sprintf(
8989
'joli_media_property_%s_%s_%s_%s',
9090
$this->libraryName,
9191
Resolver::normalizePath($path),
9292
$this->getLastModified($path),
9393
$property,
94-
);
94+
));
9595
}
9696

9797
private function getLastModifiedCacheKey(string $path): string
9898
{
99-
return \sprintf(
99+
return CacheKeySanitizer::sanitize(\sprintf(
100100
'joli_media_property_%s_%s_lastModified',
101101
$this->libraryName,
102102
Resolver::normalizePath($path),
103-
);
103+
));
104104
}
105105

106106
private function guessFilesize(string $path): int

src/Storage/MediaVariationPropertyAccessor.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,24 +85,24 @@ function (ItemInterface $item) use ($path, $variation): int {
8585

8686
private function getCacheKey(string $path, Variation $variation, string $property): string
8787
{
88-
return \sprintf(
88+
return CacheKeySanitizer::sanitize(\sprintf(
8989
'joli_media_property_%s_%s_%s_%s_%s',
9090
$this->libraryName,
9191
$variation->getName(),
9292
Resolver::normalizePath($path),
9393
$this->getLastModified($path, $variation),
9494
$property,
95-
);
95+
));
9696
}
9797

9898
private function getLastModifiedCacheKey(string $path, Variation $variation): string
9999
{
100-
return \sprintf(
100+
return CacheKeySanitizer::sanitize(\sprintf(
101101
'joli_media_property_%s_%s_%s_lastModified',
102102
$this->libraryName,
103103
$variation->getName(),
104104
Resolver::normalizePath($path),
105-
);
105+
));
106106
}
107107

108108
private function guessFilesize(string $path, Variation $variation): int

tests/infrastructure/php-configuration/mods-available/app-default.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ always_populate_raw_post_data = -1
1313
upload_max_filesize = 20M
1414
post_max_size = 20M
1515
zend.max_allowed_stack_size = -1
16+
[Assertion]
17+
zend.assertions = 1
1618
[Date]
1719
date.timezone = UTC
1820
[Phar]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JoliCode\MediaBundle\Tests\Storage;
6+
7+
use JoliCode\MediaBundle\Storage\CacheKeySanitizer;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use PHPUnit\Framework\TestCase;
10+
11+
class CacheKeySanitizerTest extends TestCase
12+
{
13+
#[DataProvider('provideSanitizationCases')]
14+
public function testSanitize(string $input, string $expected): void
15+
{
16+
$this->assertSame($expected, CacheKeySanitizer::sanitize($input));
17+
}
18+
19+
/**
20+
* @return array<string, array{0: string, 1: string}>
21+
*/
22+
public static function provideSanitizationCases(): array
23+
{
24+
return [
25+
'simple path with slash' => ['folder/file.jpg', 'folder_file.jpg'],
26+
'path with all reserved characters' => ['path/with@special:chars{test}(1).jpg', 'path_with_special_chars_test__1_.jpg'],
27+
'backslash' => ['path\file.jpg', 'path_file.jpg'],
28+
'colon' => ['namespace:key', 'namespace_key'],
29+
'at symbol' => ['user@host', 'user_host'],
30+
'curly braces' => ['prefix{suffix}', 'prefix_suffix_'],
31+
'parentheses' => ['name(variant)', 'name_variant_'],
32+
'mixed slashes' => ['folder/subfolder\file.jpg', 'folder_subfolder_file.jpg'],
33+
'no reserved characters' => ['simple_key', 'simple_key'],
34+
'empty string' => ['', ''],
35+
];
36+
}
37+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JoliCode\MediaBundle\Tests\Storage;
6+
7+
use JoliCode\MediaBundle\Binary\MimeTypeGuesser;
8+
use JoliCode\MediaBundle\Model\Format;
9+
use JoliCode\MediaBundle\Storage\MediaPropertyAccessor;
10+
use JoliCode\MediaBundle\Storage\MediaVariationPropertyAccessor;
11+
use JoliCode\MediaBundle\Storage\Strategy\FolderStorageStrategy;
12+
use JoliCode\MediaBundle\Transformer\TransformerChain;
13+
use JoliCode\MediaBundle\Variation\Variation;
14+
use League\Flysystem\Filesystem;
15+
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
16+
use PHPUnit\Framework\TestCase;
17+
use Symfony\Component\Cache\Adapter\ArrayAdapter;
18+
use Symfony\Component\Mime\FileBinaryMimeTypeGuesser;
19+
use Symfony\Component\Mime\MimeTypes;
20+
21+
/**
22+
* Tests that cache keys are properly sanitized when using paths with reserved PSR-6 characters.
23+
*
24+
* This test ensures that files in subfolders (containing forward slashes) don't cause
25+
* cache key validation errors.
26+
*/
27+
class CacheKeyWithSubfolderTest extends TestCase
28+
{
29+
private ArrayAdapter $cache;
30+
31+
private Filesystem $filesystem;
32+
33+
private MimeTypeGuesser $mimeTypeGuesser;
34+
35+
protected function setUp(): void
36+
{
37+
parent::setUp();
38+
39+
$this->cache = new ArrayAdapter();
40+
$this->filesystem = new Filesystem(new InMemoryFilesystemAdapter());
41+
$this->mimeTypeGuesser = new MimeTypeGuesser(new MimeTypes(), new FileBinaryMimeTypeGuesser());
42+
}
43+
44+
public function testSubfolderPath(): void
45+
{
46+
// Create a file in a subfolder (path contains '/' which is a reserved PSR-6 character)
47+
$path = 'subfolder/test.jpg';
48+
$this->filesystem->write($path, 'dummy image content');
49+
50+
$accessor = new MediaPropertyAccessor(
51+
'default',
52+
$this->filesystem,
53+
$this->mimeTypeGuesser,
54+
$this->cache,
55+
);
56+
57+
$this->expectNotToPerformAssertions();
58+
59+
// This should NOT throw an exception about reserved characters
60+
$accessor->getMimeType($path);
61+
$accessor->getFormat($path);
62+
$accessor->getFileSize($path);
63+
64+
// Test clearCache doesn't throw either
65+
$accessor->clearCache($path);
66+
}
67+
68+
public function testVariationSubfolderPath(): void
69+
{
70+
$strategy = new FolderStorageStrategy();
71+
72+
// Create a file in a nested subfolder
73+
$path = 'folder/subfolder/image.png';
74+
$this->filesystem->write($path, 'dummy image content');
75+
76+
$variation = new Variation('thumbnail', Format::PNG, new TransformerChain([]));
77+
78+
$accessor = new MediaVariationPropertyAccessor(
79+
'my-library',
80+
$strategy,
81+
$this->filesystem,
82+
$this->mimeTypeGuesser,
83+
$this->cache,
84+
);
85+
86+
$this->expectNotToPerformAssertions();
87+
88+
// This should NOT throw an exception about reserved characters
89+
$accessor->getMimeType($path, $variation);
90+
$accessor->getFormat($path, $variation);
91+
$accessor->getFileSize($path, $variation);
92+
93+
// Test clearCache doesn't throw either
94+
$accessor->clearCache($path, $variation);
95+
}
96+
97+
public function testNestedSubfolderPath(): void
98+
{
99+
// Path with forward slash (subfolder)
100+
$path = 'user-uploads/2024/document.pdf';
101+
$this->filesystem->write($path, 'dummy pdf content');
102+
103+
$accessor = new MediaPropertyAccessor(
104+
'default',
105+
$this->filesystem,
106+
$this->mimeTypeGuesser,
107+
$this->cache,
108+
);
109+
110+
// Should work without throwing PSR-6 cache key validation errors
111+
$mimeType = $accessor->getMimeType($path);
112+
113+
// Verify the cache was actually used by calling again
114+
$mimeType2 = $accessor->getMimeType($path);
115+
$this->assertSame($mimeType, $mimeType2);
116+
}
117+
}

0 commit comments

Comments
 (0)