From 458bb03075fa555fdf12a68c0e0cb743f0610ed8 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Fri, 14 Aug 2026 13:33:10 -0600 Subject: [PATCH 1/2] Make CssImportFilter hashable so the asset cache can key it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AssetCache builds its cache key from an asset's filters, hashing those that implement HashableInterface and falling back to serialize() for the rest. Since v3.2.1 CssImportFilter can hold an import validator, which is normally a closure, and serializing a closure throws — so any asset filtered by a configured CssImportFilter fataled with "Serialization of 'Closure' is not allowed" as soon as it passed through the asset cache. Implement HashableInterface so the filter is never serialized. The hash covers the inner import filter and the validator, preferring serialization of each, which captures configuration. A closure cannot be serialized, so it is identified by its declaration site plus the variables bound into it — which is what carries a validator's configuration, so two validators confining imports to different roots cannot be served each other's cached output. Object identity is deliberately never used: it is not stable between requests and would give the cache a fresh key every time. Co-Authored-By: Claude Opus 5 (1M context) --- src/Assetic/Filter/CssImportFilter.php | 87 ++++++++++++++++++- .../Test/Filter/CssImportFilterTest.php | 86 ++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/src/Assetic/Filter/CssImportFilter.php b/src/Assetic/Filter/CssImportFilter.php index 8f8d0a1..333b68d 100644 --- a/src/Assetic/Filter/CssImportFilter.php +++ b/src/Assetic/Filter/CssImportFilter.php @@ -8,13 +8,17 @@ use Assetic\Factory\AssetFactory; use Assetic\Contracts\Filter\DependencyExtractorInterface; use Assetic\Contracts\Filter\FilterInterface; +use Assetic\Contracts\Filter\HashableInterface; +use Closure; +use ReflectionFunction; +use Throwable; /** * Inlines imported stylesheets. * * @author Kris Wallsmith */ -class CssImportFilter extends BaseCssFilter implements DependencyExtractorInterface +class CssImportFilter extends BaseCssFilter implements DependencyExtractorInterface, HashableInterface { private $importFilter; @@ -50,6 +54,87 @@ public function setImportValidator(?callable $importValidator): self return $this; } + /** + * Generates a hash for the object. + * + * {@see \Assetic\Asset\AssetCache} builds its cache key from the filters applied + * to an asset, falling back to `serialize()` for any filter that is not hashable. + * An import validator is typically a closure, and serializing a closure throws, + * so this filter must hash itself rather than be serialized. + * + * @return string Object hash + */ + public function hash() + { + return md5(implode('|', [ + static::class, + $this->hashComponent($this->importFilter), + $this->hashComponent($this->importValidator), + ])); + } + + /** + * Reduces a filter or callable held by this filter to a stable string. + * + * Serialization is preferred, since it captures the component's configuration, + * but it fails for closures and for objects holding one. Object identity is + * never used as a fallback: `spl_object_hash()` and friends are not stable + * between requests and would give the asset cache a new key every time, + * defeating it. + * + * @param mixed $component + * @return string + */ + private function hashComponent($component) + { + if (null === $component) { + return ''; + } + + if ($component instanceof HashableInterface) { + return $component->hash(); + } + + if ($component instanceof Closure) { + return $this->hashClosure($component); + } + + if (is_array($component) && isset($component[0]) && is_object($component[0])) { + // Callable array of [object, method] + return get_class($component[0]) . '::' . (string) ($component[1] ?? ''); + } + + try { + return serialize($component); + } catch (Throwable $e) { + return is_object($component) ? get_class($component) : gettype($component); + } + } + + /** + * Reduces a closure to a stable string. + * + * A closure cannot be serialized, so it is identified by where it was declared + * plus the variables bound into it. The bound variables carry the closure's + * configuration — for an import validator, the set of paths it authorises — so + * two validators sharing a declaration but confining imports differently still + * hash differently, and neither can be served the other's cached output. Both + * halves are stable between requests, which keeps the cache usable. + * + * @return string + */ + private function hashClosure(Closure $closure) + { + $reflection = new ReflectionFunction($closure); + + return implode(':', [ + Closure::class, + (string) $reflection->getFileName(), + (string) $reflection->getStartLine(), + $this->hashComponent($reflection->getStaticVariables()), + ]); + } + public function filterLoad(AssetInterface $asset) { $importFilter = $this->importFilter; diff --git a/tests/Assetic/Test/Filter/CssImportFilterTest.php b/tests/Assetic/Test/Filter/CssImportFilterTest.php index 930167a..c647f90 100644 --- a/tests/Assetic/Test/Filter/CssImportFilterTest.php +++ b/tests/Assetic/Test/Filter/CssImportFilterTest.php @@ -3,7 +3,10 @@ namespace Assetic\Test\Filter; use PHPUnit\Framework\TestCase; +use Assetic\Asset\AssetCache; use Assetic\Asset\FileAsset; +use Assetic\Cache\ArrayCache; +use Assetic\Contracts\Filter\HashableInterface; use Assetic\Filter\CssImportFilter; use Assetic\Filter\CssRewriteFilter; @@ -85,6 +88,89 @@ public function testImportValidatorReceivesResolvedPathAndCanAllow() $this->assertStringContainsString('import.css', implode('|', $seen)); } + public function testIsHashableSoTheAssetCacheNeverSerializesIt() + { + $filter = new CssImportFilter(); + $filter->setImportValidator(function ($path) { + return true; + }); + + $this->assertInstanceOf(HashableInterface::class, $filter); + $this->assertNotEmpty($filter->hash()); + } + + public function testAssetCacheDumpsAnAssetFilteredWithAnImportValidator() + { + $filter = new CssImportFilter(); + $filter->setImportValidator(function ($path) { + return true; + }); + + $asset = new FileAsset(__DIR__ . '/fixtures/cssimport/main.css', [$filter], __DIR__ . '/fixtures/cssimport', 'main.css'); + $cached = new AssetCache($asset, new ArrayCache()); + + // The cache key is built from the asset's filters, serializing any that are + // not hashable. Serializing a closure throws, so a filter holding an import + // validator has to hash itself or this dump fails outright. + $this->assertStringContainsString('body { color: red; }', $cached->dump()); + } + + public function testHashIsStableBetweenEquivalentInstances() + { + $bare = new CssImportFilter(); + + $validated = new CssImportFilter(); + $validated->setImportValidator(function ($path) { + return true; + }); + + // A stable hash is what keeps the asset cache usable across requests. + $this->assertSame($bare->hash(), (new CssImportFilter())->hash()); + $this->assertSame($validated->hash(), $validated->hash()); + $this->assertNotSame($bare->hash(), $validated->hash()); + } + + public function testHashDistinguishesValidatorsByBoundConfiguration() + { + $allowsA = new CssImportFilter(); + $allowsA->setImportValidator($this->createRootValidator('/allowed/a')); + + $allowsB = new CssImportFilter(); + $allowsB->setImportValidator($this->createRootValidator('/allowed/b')); + + // Validators declared in the same place but confining imports to different + // roots must not share a cache key, or one filter is served output the other + // produced under looser rules. + $this->assertNotSame($allowsA->hash(), $allowsB->hash()); + + // An equivalent configuration still hashes alike, so the cache stays warm. + $this->assertSame($allowsA->hash(), (new CssImportFilter()) + ->setImportValidator($this->createRootValidator('/allowed/a')) + ->hash()); + } + + public function testHashDistinguishesValidatorsByDeclarationSite() + { + $permissive = new CssImportFilter(); + $permissive->setImportValidator(function ($path) { + return true; + }); + + $restrictive = new CssImportFilter(); + $restrictive->setImportValidator(function ($path) { + return false; + }); + + $this->assertNotSame($permissive->hash(), $restrictive->hash()); + } + + private function createRootValidator($root) + { + return function ($path) use ($root) { + return strpos($path, $root) === 0; + }; + } + public function testNonCssImport() { $asset = new FileAsset(__DIR__ . '/fixtures/cssimport/noncssimport.css', [], __DIR__ . '/fixtures/cssimport', 'noncssimport.css'); From 9f58d89f056909628415d11356255239f45bc4a7 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Fri, 14 Aug 2026 13:39:21 -0600 Subject: [PATCH 2/2] Backfill changelog for 3.1.2 through 3.2.2 The changelog stopped at 3.1.1; 3.1.2, 3.1.3, 3.1.4, 3.1.5, 3.1.6, 3.2.0 and 3.2.1 shipped without entries. Reconstructed from the commits between each tag, dated from the published release rather than the local tag. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d6320..7d26b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,69 @@ # Changelog +## 3.2.2 (2026-08-14) + +### Bug fixes + +- `CssImportFilter` now implements `HashableInterface`, so it is hashed instead of serialized when `AssetCache` builds its cache key. Serializing the filter threw `Serialization of 'Closure' is not allowed` whenever an import validator had been set. + - The hash covers the inner import filter and the import validator, so filters confining imports to different paths no longer share a cache entry. + - Cache keys for assets filtered by `CssImportFilter` change with this release, so those assets are rebuilt once on upgrade. + +## 3.2.1 (2026-07-26) + +### New features + +- Added an optional import validator to `CssImportFilter`, letting consumers confine `@import` inlining to trusted paths, by @LukeTowers in https://github.com/assetic-php/assetic/pull/51 + +## 3.2.0 (2026-06-20) + +### New features + +- Added support for Twig 3 alongside Twig 2 by @austinderrick in https://github.com/assetic-php/assetic/pull/49 +- Added support for the Tailwind CSS CLI v4 by @bennothommo in https://github.com/assetic-php/assetic/pull/50 + +### Community updates + +- Added PHP 8.4 and 8.5 to the test matrix and updated the CI workflow to the Node 20 actions by @austinderrick in https://github.com/assetic-php/assetic/pull/48 + +## 3.1.6 (2026-06-04) + +### Bug fixes + +- Resolved PHP 8.4 / 8.5 deprecation notices in `HttpAsset` and `BasePhpFormulaLoader` by @austinderrick in https://github.com/assetic-php/assetic/pull/47 + +## 3.1.5 (2026-04-23) + +### New features + +- Added `AssetReference::getName()` and `AssetReference::getAsset()`, exposing the reference name and the asset it resolves to, by @dlundgren in https://github.com/assetic-php/assetic/pull/18 + +## 3.1.4 (2026-04-22) + +### Bug fixes + +- `HttpAsset` now reads response headers via `http_get_last_response_headers()` instead of the `$http_response_header` local, which PHP 8.4 deprecates, by @matteotrubini in https://github.com/assetic-php/assetic/pull/46 + +### Changes + +- Added audit configuration to `composer.json` +- Fixed the test suite against current dependencies + +## 3.1.3 (2025-02-01) + +### Bug fixes + +- Resolved PHP 8.4 deprecation notices for implicitly nullable parameters throughout the codebase by @TomA-R in https://github.com/assetic-php/assetic/pull/45 + +## 3.1.2 (2024-07-14) + +### New features + +- Added the Tailwind CSS Standalone CLI utility filter by @bennothommo in https://github.com/assetic-php/assetic/pull/42 + +### Changes + +- Widened the `symfony/deprecation-contracts` constraint to `^2.2.0|^3.0` by @10n in https://github.com/assetic-php/assetic/pull/43 + ## 3.1.1 (2024-07-05) ### Changes