Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
87 changes: 86 additions & 1 deletion src/Assetic/Filter/CssImportFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kris.wallsmith@gmail.com>
*/
class CssImportFilter extends BaseCssFilter implements DependencyExtractorInterface
class CssImportFilter extends BaseCssFilter implements DependencyExtractorInterface, HashableInterface
{
private $importFilter;

Expand Down Expand Up @@ -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;
Expand Down
86 changes: 86 additions & 0 deletions tests/Assetic/Test/Filter/CssImportFilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');
Expand Down