diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
index ce62c9cdf836b..bb2ff02f4008f 100644
--- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
+++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
@@ -56,6 +56,7 @@ CHANGELOG
* Make `ValidatorCacheWarmer` use `kernel.build_dir` instead of `cache_dir`
* Make `SerializeCacheWarmer` use `kernel.build_dir` instead of `cache_dir`
* Support executing custom workflow validators during container compilation
+ * Add new `framework.asset_mapper.importmap_integrity_algorithms` option to add integrity metadata to importmaps
7.2
---
diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
index 4c40455526e57..36282a868aab2 100644
--- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
+++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
@@ -863,6 +863,7 @@ private function addAssetMapperSection(ArrayNodeDefinition $rootNode, callable $
->fixXmlConfig('excluded_pattern')
->fixXmlConfig('extension')
->fixXmlConfig('importmap_script_attribute')
+ ->fixXmlConfig('importmap_integrity_algorithm')
->children()
// add array node called "paths" that will be an array of strings
->arrayNode('paths')
@@ -972,6 +973,12 @@ private function addAssetMapperSection(ArrayNodeDefinition $rootNode, callable $
->end()
->end()
->end()
+ ->arrayNode('importmap_integrity_algorithms')
+ ->info('Array of algorithms used to compute importmap resources integrity.')
+ ->beforeNormalization()->castToArray()->end()
+ ->prototype('scalar')->end()
+ ->defaultValue([])
+ ->end()
->end()
->end()
->end()
diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
index 385a4caf38ded..31adf624ffa82 100644
--- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
+++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
@@ -1482,6 +1482,7 @@ private function registerAssetMapperConfiguration(array $config, ContainerBuilde
$container
->getDefinition('asset_mapper.mapped_asset_factory')
->replaceArgument(2, $config['vendor_dir'])
+ ->setArgument(3, $config['importmap_integrity_algorithms'])
;
$container
diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php
index eeb1ceb4f8962..550d00643363c 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php
@@ -67,6 +67,7 @@
service('asset_mapper.public_assets_path_resolver'),
service('asset_mapper_compiler'),
abstract_arg('vendor directory'),
+ abstract_arg('integrity hash algorithms'),
])
->set('asset_mapper.cached_mapped_asset_factory', CachedMappedAssetFactory::class)
diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd
index 3a6242b837dd3..1f1e8dfa4d721 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd
+++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd
@@ -208,6 +208,7 @@
+
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php
index c8142e98ab1a7..d8fac8cafdf28 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php
@@ -148,6 +148,7 @@ public function testAssetMapperCanBeEnabled()
'formats' => [],
'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [],
],
+ 'importmap_integrity_algorithms' => [],
];
$this->assertEquals($defaultConfig, $config['asset_mapper']);
@@ -877,6 +878,7 @@ protected static function getBundleDefaultConfig()
'formats' => [],
'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [],
],
+ 'importmap_integrity_algorithms' => [],
],
'cache' => [
'pools' => [],
diff --git a/src/Symfony/Component/AssetMapper/CHANGELOG.md b/src/Symfony/Component/AssetMapper/CHANGELOG.md
index 93d622101c0c8..5b998a3eaf991 100644
--- a/src/Symfony/Component/AssetMapper/CHANGELOG.md
+++ b/src/Symfony/Component/AssetMapper/CHANGELOG.md
@@ -7,6 +7,7 @@ CHANGELOG
* Add support for pre-compressing assets with Brotli, Zstandard, Zopfli, and gzip
* Add option `--dry-run` to `importmap:require` command
* `ImportMapRequireCommand` now takes `projectDir` as a required third constructor argument
+ * Add integrity metadata to importmaps
7.2
---
diff --git a/src/Symfony/Component/AssetMapper/Factory/MappedAssetFactory.php b/src/Symfony/Component/AssetMapper/Factory/MappedAssetFactory.php
index f1cf2ad5897f7..a92ecb5d713b1 100644
--- a/src/Symfony/Component/AssetMapper/Factory/MappedAssetFactory.php
+++ b/src/Symfony/Component/AssetMapper/Factory/MappedAssetFactory.php
@@ -13,6 +13,7 @@
use Symfony\Component\AssetMapper\AssetMapperCompiler;
use Symfony\Component\AssetMapper\Exception\CircularAssetsException;
+use Symfony\Component\AssetMapper\Exception\LogicException;
use Symfony\Component\AssetMapper\Exception\RuntimeException;
use Symfony\Component\AssetMapper\MappedAsset;
use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface;
@@ -25,6 +26,7 @@ class MappedAssetFactory implements MappedAssetFactoryInterface
{
private const PREDIGESTED_REGEX = '/-([0-9a-zA-Z]{7,128}\.digested)/';
private const PUBLIC_DIGEST_LENGTH = 7;
+ private const INTEGRITY_HASH_ALGORITHMS = ['sha256', 'sha384', 'sha512'];
private array $assetsCache = [];
private array $assetsBeingCreated = [];
@@ -33,7 +35,11 @@ public function __construct(
private readonly PublicAssetsPathResolverInterface $assetsPathResolver,
private readonly AssetMapperCompiler $compiler,
private readonly string $vendorDir,
+ private readonly array $integrityHashAlgorithms = [],
) {
+ if ($unsupportedAlgorithms = array_diff($this->integrityHashAlgorithms, self::INTEGRITY_HASH_ALGORITHMS)) {
+ throw new LogicException(sprintf('Unsupported "%s" algorithm(s). Supported ones are "%s".', implode('", "', $unsupportedAlgorithms), implode('", "', self::INTEGRITY_HASH_ALGORITHMS)));
+ }
}
public function createMappedAsset(string $logicalPath, string $sourcePath): ?MappedAsset
@@ -63,6 +69,7 @@ public function createMappedAsset(string $logicalPath, string $sourcePath): ?Map
$asset->getDependencies(),
$asset->getFileDependencies(),
$asset->getJavaScriptImports(),
+ $this->getIntegrity($asset, $content),
);
$this->assetsCache[$logicalPath] = $asset;
@@ -131,4 +138,20 @@ private function isVendor(string $sourcePath): bool
return $sourcePath && $vendorDir && str_starts_with($sourcePath, $vendorDir);
}
+
+ private function getIntegrity(MappedAsset $asset, ?string $content): ?string
+ {
+ $integrity = null;
+
+ foreach ($this->integrityHashAlgorithms as $algorithm) {
+ $hash = null !== $content
+ ? hash($algorithm, $content, true)
+ : hash_file($algorithm, $asset->sourcePath, true)
+ ;
+
+ $integrity .= \sprintf('%s%s-%s', $integrity ? ' ' : '', $algorithm, base64_encode($hash));
+ }
+
+ return $integrity;
+ }
}
diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapGenerator.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapGenerator.php
index 89579fb313ed2..bd813c3e6d44d 100644
--- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapGenerator.php
+++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapGenerator.php
@@ -50,7 +50,7 @@ public function getEntrypointNames(): array
/**
* @param string[] $entrypointNames
*
- * @return array
+ * @return array
*
* @internal
*/
@@ -83,7 +83,7 @@ public function getImportMapData(array $entrypointNames): array
/**
* @internal
*
- * @return array
+ * @return array
*/
public function getRawImportMapData(): array
{
@@ -104,9 +104,10 @@ public function getRawImportMapData(): array
throw $this->createMissingImportMapAssetException($entry);
}
- $path = $asset->publicPath;
- $data = ['path' => $path, 'type' => $entry->type->value];
- $rawImportMapData[$entry->importName] = $data;
+ $rawImportMapData[$entry->importName] = ['path' => $asset->publicPath, 'type' => $entry->type->value];
+ if ($asset->integrity) {
+ $rawImportMapData[$entry->importName]['integrity'] = $asset->integrity;
+ }
}
return $rawImportMapData;
diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php
index 87d557f6d422f..855a5bd68238b 100644
--- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php
+++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php
@@ -49,6 +49,7 @@ public function render(string|array $entryPoint, array $attributes = []): string
$importMap = [];
$modulePreloads = [];
$cssLinks = [];
+ $integrity = [];
$polyfillPath = null;
foreach ($importMapData as $importName => $data) {
$path = $data['path'];
@@ -70,8 +71,12 @@ public function render(string|array $entryPoint, array $attributes = []): string
}
$preload = $data['preload'] ?? false;
+ $assetIntegrity = $data['integrity'] ?? false;
if ('css' !== $data['type']) {
$importMap[$importName] = $path;
+ if ($assetIntegrity) {
+ $integrity[$path] = $assetIntegrity;
+ }
if ($preload) {
$modulePreloads[] = $path;
}
@@ -96,7 +101,7 @@ public function render(string|array $entryPoint, array $attributes = []): string
}
$scriptAttributes = $attributes || $this->scriptAttributes ? ' '.$this->createAttributesString($attributes) : '';
- $importMapJson = json_encode(['imports' => $importMap], \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_HEX_TAG);
+ $importMapJson = json_encode(['imports' => $importMap, ...$integrity ? ['integrity' => $integrity] : []], \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_HEX_TAG);
$output .= <<
diff --git a/src/Symfony/Component/AssetMapper/MappedAsset.php b/src/Symfony/Component/AssetMapper/MappedAsset.php
index 6bb828d61cae2..7bc4348d604e3 100644
--- a/src/Symfony/Component/AssetMapper/MappedAsset.php
+++ b/src/Symfony/Component/AssetMapper/MappedAsset.php
@@ -52,6 +52,7 @@ public function __construct(
private array $dependencies = [],
private array $fileDependencies = [],
private array $javaScriptImports = [],
+ public ?string $integrity = null,
) {
if (null !== $sourcePath) {
$this->sourcePath = $sourcePath;
diff --git a/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php b/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php
index f63edd6a7dfd6..ff60f5ee4ee81 100644
--- a/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php
+++ b/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php
@@ -19,6 +19,7 @@
use Symfony\Component\AssetMapper\Compiler\CssAssetUrlCompiler;
use Symfony\Component\AssetMapper\Compiler\JavaScriptImportPathCompiler;
use Symfony\Component\AssetMapper\Exception\CircularAssetsException;
+use Symfony\Component\AssetMapper\Exception\LogicException;
use Symfony\Component\AssetMapper\Factory\MappedAssetFactory;
use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader;
use Symfony\Component\AssetMapper\MappedAsset;
@@ -148,7 +149,35 @@ public function testCreateMappedAssetInMissingVendor()
$this->assertFalse($asset->isVendor);
}
- private function createFactory(?AssetCompilerInterface $extraCompiler = null, ?string $vendorDir = self::VENDOR_FIXTURES_DIR): MappedAssetFactory
+ public function testCreateMappedAssetWithoutIntegrity()
+ {
+ $factory = $this->createFactory();
+ $asset = $factory->createMappedAsset('file2.js', self::FIXTURES_DIR.'/dir1/file2.js');
+ $this->assertNull($asset->integrity);
+ }
+
+ public function testCreateMappedAssetWithOneIntegrityAlgorithm()
+ {
+ $factory = $this->createFactory(integrityHashAlgorithms: ['sha256']);
+ $asset = $factory->createMappedAsset('file2.js', self::FIXTURES_DIR.'/dir1/file2.js');
+ $this->assertSame('sha256-b8bze+0OP5qLVVEG0aUh25UkvNjZXLeugH9Jg7MvSz8=', $asset->integrity);
+ }
+
+ public function testCreateMappedAssetWithManyIntegrityAlgorithms()
+ {
+ $factory = $this->createFactory(integrityHashAlgorithms: ['sha256', 'sha384']);
+ $asset = $factory->createMappedAsset('file2.js', self::FIXTURES_DIR.'/dir1/file2.js');
+ $this->assertSame('sha256-b8bze+0OP5qLVVEG0aUh25UkvNjZXLeugH9Jg7MvSz8= sha384-2cpbxkWC8I4PKAhlQ+LaFmVek6qd8w35xUZ+QRGMzcSvX9SP2EgjLvKSawSmS9J7', $asset->integrity);
+ }
+
+ public function testCreateMappedAssetWithInvalidIntegrityAlgorithm()
+ {
+ $this->expectException(LogicException::class);
+ $this->expectExceptionMessage('Unsupported "sha1" algorithm(s). Supported ones are "sha256", "sha384", "sha512".');
+ $this->createFactory(integrityHashAlgorithms: ['sha1']);
+ }
+
+ private function createFactory(?AssetCompilerInterface $extraCompiler = null, ?string $vendorDir = self::VENDOR_FIXTURES_DIR, array $integrityHashAlgorithms = []): MappedAssetFactory
{
$compilers = [
new JavaScriptImportPathCompiler($this->createMock(ImportMapConfigReader::class)),
@@ -174,6 +203,7 @@ private function createFactory(?AssetCompilerInterface $extraCompiler = null, ?s
$pathResolver,
$compiler,
$vendorDir,
+ $integrityHashAlgorithms,
);
// mock the AssetMapper to behave like normal: by calling back to the factory
diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapGeneratorTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapGeneratorTest.php
index bdc8bc36c1ed7..813aced32c3b1 100644
--- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapGeneratorTest.php
+++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapGeneratorTest.php
@@ -92,6 +92,10 @@ public function testGetImportMapData()
path: 'styles/never_imported_css.css',
type: ImportMapType::CSS,
),
+ self::createLocalEntry(
+ 'js_file_with_integrity',
+ path: 'js_file_with_integrity.js',
+ ),
]);
$importedFile1 = new MappedAsset(
@@ -142,6 +146,13 @@ public function testGetImportMapData()
publicPathWithoutDigest: '/assets/styles/never_imported_css.css',
publicPath: '/assets/styles/never_imported_css-d1g35t.css',
);
+ $jsFileWithIntegrity = new MappedAsset(
+ 'js_file_with_integrity.js',
+ '/path/to/js_file_with_integrity.js',
+ publicPathWithoutDigest: '/assets/js_file_with_integrity.js',
+ publicPath: '/assets/js_file_with_integrity-d1g35t.js',
+ integrity: 'sha384-base64-hash'
+ );
$this->mockAssetMapper([
new MappedAsset(
'entry1.js',
@@ -179,6 +190,7 @@ public function testGetImportMapData()
$importedCss2,
$importedCssInImportmap,
$neverImportedCss,
+ $jsFileWithIntegrity,
]);
$actualImportMapData = $manager->getImportMapData(['entry2', 'entry1']);
@@ -232,6 +244,11 @@ public function testGetImportMapData()
'path' => '/assets/styles/never_imported_css-d1g35t.css',
'type' => 'css',
],
+ 'js_file_with_integrity' => [
+ 'path' => '/assets/js_file_with_integrity-d1g35t.js',
+ 'type' => 'js',
+ 'integrity' => 'sha384-base64-hash',
+ ],
], $actualImportMapData);
// now check the order
@@ -251,6 +268,7 @@ public function testGetImportMapData()
// importmap entries never imported
'entry3',
'never_imported_css',
+ 'js_file_with_integrity',
], array_keys($actualImportMapData));
}
@@ -570,6 +588,31 @@ public static function getRawImportMapDataTests(): iterable
],
],
];
+
+ yield 'it adds integrity when it exists' => [
+ [
+ self::createLocalEntry(
+ 'app',
+ path: './assets/app.js',
+ ),
+ ],
+ [
+ new MappedAsset(
+ 'app.js',
+ // /fake/root is the mocked root directory
+ '/fake/root/assets/app.js',
+ publicPath: '/assets/app-d1g3st.js',
+ integrity: 'sha384-base64-hash',
+ ),
+ ],
+ [
+ 'app' => [
+ 'path' => '/assets/app-d1g3st.js',
+ 'type' => 'js',
+ 'integrity' => 'sha384-base64-hash',
+ ],
+ ],
+ ];
}
public function testGetRawImportDataUsesCacheFile()
diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php
index ef519ff719b4b..e0e2b6375cf81 100644
--- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php
+++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php
@@ -58,6 +58,15 @@ public function testBasicRender()
'path' => '/assets/implicitly-added-d1g35t.js',
'type' => 'js',
],
+ '/assets/with-integrity' => [
+ 'path' => '/assets/with-integrity-d1g35t.js',
+ 'type' => 'js',
+ 'integrity' => 'sha384-base64-hash',
+ ],
+ '/assets/without-integrity' => [
+ 'path' => '/assets/without-integrity-d1g35t.js',
+ 'type' => 'js',
+ ],
]);
$assetPackages = $this->createMock(Packages::class);
@@ -98,6 +107,10 @@ public function testBasicRender()
$this->assertStringContainsString('"remote_js": "https://cdn.example.com/assets/remote-d1g35t.js"', $html);
// both the key and value are prefixed with the subdirectory
$this->assertStringContainsString('"/subdirectory/assets/implicitly-added": "/subdirectory/assets/implicitly-added-d1g35t.js"', $html);
+ // integrity
+ $this->assertStringContainsString('"integrity":', $html);
+ $this->assertStringContainsString('"/subdirectory/assets/with-integrity-d1g35t.js": "sha384-base64-hash"', $html);
+ $this->assertStringNotContainsString('"/subdirectory/assets/without-integrity-d1g35t.js":', $html);
}
public function testNoPolyfill()
@@ -106,6 +119,12 @@ public function testNoPolyfill()
$this->assertStringNotContainsString('https://ga.jspm.io/npm:es-module-shims', $renderer->render([]));
}
+ public function testNoIntegrity()
+ {
+ $renderer = new ImportMapRenderer($this->createBasicImportMapGenerator(), null, 'UTF-8', false);
+ $this->assertStringNotContainsString('"integrity":', $renderer->render([]));
+ }
+
public function testDefaultPolyfillUsedIfNotInImportmap()
{
$importMapGenerator = $this->createMock(ImportMapGenerator::class);
@@ -154,26 +173,6 @@ public function testWithEntrypoint()
$this->assertStringContainsString("import 'bar';", $html);
}
- private function createBasicImportMapGenerator(): ImportMapGenerator
- {
- $importMapGenerator = $this->createMock(ImportMapGenerator::class);
- $importMapGenerator->expects($this->once())
- ->method('getImportMapData')
- ->willReturn([
- 'app' => [
- 'path' => 'app.js',
- 'type' => 'js',
- ],
- 'es-module-shims' => [
- 'path' => 'https://polyfillUrl.example',
- 'type' => 'js',
- ],
- ])
- ;
-
- return $importMapGenerator;
- }
-
public function testItAddsPreloadLinks()
{
$importMapGenerator = $this->createMock(ImportMapGenerator::class);
@@ -210,4 +209,24 @@ public function testItAddsPreloadLinks()
$this->assertSame(['as' => 'style'], $linkProvider->getLinks()[0]->getAttributes());
$this->assertSame('/assets/styles/app-preload-d1g35t.css', $linkProvider->getLinks()[0]->getHref());
}
+
+ private function createBasicImportMapGenerator(): ImportMapGenerator
+ {
+ $importMapGenerator = $this->createMock(ImportMapGenerator::class);
+ $importMapGenerator->expects($this->once())
+ ->method('getImportMapData')
+ ->willReturn([
+ 'app' => [
+ 'path' => 'app.js',
+ 'type' => 'js',
+ ],
+ 'es-module-shims' => [
+ 'path' => 'https://polyfillUrl.example',
+ 'type' => 'js',
+ ],
+ ])
+ ;
+
+ return $importMapGenerator;
+ }
}