8000 [AssetMapper] Add integrity metadata to importmaps · symfony/symfony@477cdc5 · GitHub
[go: up one dir, main page]

Skip to content

Commit 477cdc5

Browse files
committed
[AssetMapper] Add integrity metadata to importmaps
1 parent 94f4d7a commit 477cdc5

File tree

14 files changed

+163
-27
lines changed

14 files changed

+163
-27
lines changed

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ CHANGELOG
5656
* Make `ValidatorCacheWarmer` use `kernel.build_dir` instead of `cache_dir`
5757
* Make `SerializeCacheWarmer` use `kernel.build_dir` instead of `cache_dir`
5858
* Support executing custom workflow validators during container compilation
59+
* Add new `framework.asset_mapper.importmap_integrity_algorithms` option to add integrity metadata to importmaps
5960

6061
7.2
6162
---

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,7 @@ private function addAssetMapperSection(ArrayNodeDefinition $rootNode, callable $
863863
->fixXmlConfig('excluded_pattern')
864864
->fixXmlConfig('extension')
865865
->fixXmlConfig('importmap_script_attribute')
866+
->fixXmlConfig('importmap_integrity_algorithm')
866867
->children()
867868
// add array node called "paths" that will be an array of strings
868869
->arrayNode('paths')
@@ -972,6 +973,12 @@ private function addAssetMapperSection(ArrayNodeDefinition $rootNode, callable $
972973
->end()
973974
->end()
974975
->end()
976+
->arrayNode('importmap_integrity_algorithms')
977+
->info('Array of algorithms used to compute importmap resources integrity.')
978+
->beforeNormalization()->castToArray()->end()
979+
->prototype('scalar')->end()
980+
->defaultValue([])
981+
->end()
975982
->end()
976983
->end()
977984
->end()

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,6 +1482,7 @@ private function registerAssetMapperConfiguration(array $config, ContainerBuilde
14821482
$container
14831483
->getDefinition('asset_mapper.mapped_asset_factory')
14841484
->replaceArgument(2, $config['vendor_dir'])
1485+
->setArgument(3, $config['importmap_integrity_algorithms'])
14851486
;
14861487

14871488
$container

src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
service('asset_mapper.public_assets_path_resolver'),
6868
service('asset_mapper_compiler'),
6969
abstract_arg('vendor directory'),
70+
abstract_arg('integrity hash algorithms'),
7071
])
7172

7273
->set('asset_mapper.cached_mapped_asset_factory', CachedMappedAssetFactory::class)

src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@
208208
<xsd:element name="extension" type="asset_mapper_extension" minOccurs="0" maxOccurs="unbounded" />
209209
<xsd:element name="importmap-script-attribute" type="asset_mapper_attribute" minOccurs="0" maxOccurs="unbounded" />
210210
<xsd:element name="precompress" type="asset_mapper_precompress" minOccurs="0" maxOccurs="1" />
211+
<xsd:element name="importmap-integrity-algorithm" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
211212
</xsd:sequence>
212213
<xsd:attribute name="enabled" type="xsd:boolean" />
213214
<xsd:attribute name="exclude-dotfiles" type="xsd:boolean" />

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ public function testAssetMapperCanBeEnabled()
148148
'formats' => [],
149149
'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [],
150150
],
151+
'importmap_integrity_algorithms' => ['sha384'],
151152
];
152153

153154
$this->assertEquals($defaultConfig, $config['asset_mapper']);
@@ -877,6 +878,7 @@ protected static function getBundleDefaultConfig()
877878
'formats' => [],
878879
'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [],
879880
],
881+
'importmap_integrity_algorithms' => ['sha384'],
880882
],
881883
'cache' => [
882884
'pools' => [],

src/Symfony/Component/AssetMapper/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CHANGELOG
77
* Add support for pre-compressing assets with Brotli, Zstandard, Zopfli, and gzip
88
* Add option `--dry-run` to `importmap:require` command
99
* `ImportMapRequireCommand` now takes `projectDir` as a required third constructor argument
10+
* Add integrity metadata to importmaps
1011

1112
7.2
1213
---

src/Symfony/Component/AssetMapper/Factory/MappedAssetFactory.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Component\AssetMapper\AssetMapperCompiler;
1515
use Symfony\Component\AssetMapper\Exception\CircularAssetsException;
16+
use Symfony\Component\AssetMapper\Exception\LogicException;
1617
use Symfony\Component\AssetMapper\Exception\RuntimeException;
1718
use Symfony\Component\AssetMapper\MappedAsset;
1819
use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface;
@@ -25,6 +26,7 @@ class MappedAssetFactory implements MappedAssetFactoryInterface
2526
{
2627
private const PREDIGESTED_REGEX = '/-([0-9a-zA-Z]{7,128}\.digested)/';
2728
private const PUBLIC_DIGEST_LENGTH = 7;
29+
private const INTEGRITY_HASH_ALGORITHMS = ['sha256', 'sha384', 'sha512'];
2830

2931
private array $assetsCache = [];
3032
private array $assetsBeingCreated = [];
@@ -33,7 +35,11 @@ public function __construct(
3335
private readonly PublicAssetsPathResolverInterface $assetsPathResolver,
3436
private readonly AssetMapperCompiler $compiler,
3537
private readonly string $vendorDir,
38+
private readonly array $integrityHashAlgorithms = [],
3639
) {
40+
if ($unsupportedAlgorithms = array_diff($this->integrityHashAlgorithms, self::INTEGRITY_HASH_ALGORITHMS)) {
41+
throw new LogicException(sprintf('Unsupported "%s" algorithm(s). Supported ones are "%s".', implode('", "', $unsupportedAlgorithms), implode('", "', self::INTEGRITY_HASH_ALGORITHMS)));
42+
}
3743
}
3844

3945
public function createMappedAsset(string $logicalPath, string $sourcePath): ?MappedAsset
@@ -63,6 +69,7 @@ public function createMappedAsset(string $logicalPath, string $sourcePath): ?Map
6369
$asset->getDependencies(),
6470
$asset->getFileDependencies(),
6571
$asset->getJavaScriptImports(),
72+
$this->getIntegrity($asset, $content),
6673
);
6774

6875
$this->assetsCache[$logicalPath] = $asset;
@@ -131,4 +138,20 @@ private function isVendor(string $sourcePath): bool
131138

132139
return $sourcePath && $vendorDir && str_starts_with($sourcePath, $vendorDir);
133140
}
141+
142+
private function getIntegrity(MappedAsset $asset, ?string $content): ?string
143+
{
144+
$integrity = null;
145+
146+
foreach ($this->integrityHashAlgorithms as $algorithm) {
147+
$hash = null !== $content
148+
? hash($algorithm, $content, true)
149+
: hash_file($algorithm, $asset->sourcePath, true)
150+
;
151+
152+
$integrity .= \sprintf('%s%s-%s', $integrity ? ' ' : '', $algorithm, base64_encode($hash));
153+
}
154+
155+
return $integrity;
156+
}
134157
}

src/Symfony/Component/AssetMapper/ImportMap/ImportMapGenerator.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public function getEntrypointNames(): array
5050
/**
5151
* @param string[] $entrypointNames
5252
*
53-
* @return array<string, array{path: string, type: string, preload?: bool}>
53+
* @return array<string, array{path: string, type: string, integrity?: string, preload?: bool}>
5454
*
5555
* @internal
5656
*/
@@ -83,7 +83,7 @@ public function getImportMapData(array $entrypointNames): array
8383
/**
8484
* @internal
8585
*
86-
* @return array<string, array{path: string, type: string}>
86+
* @return array<string, array{path: string, type: string, integrity?: string}>
8787
*/
8888
public function getRawImportMapData(): array
8989
{
@@ -104,9 +104,10 @@ public function getRawImportMapData(): array
104104
throw $this->createMissingImportMapAssetException($entry);
105105
}
106106

107-
$path = $asset->publicPath;
108-
$data = ['path' => $path, 'type' => $entry->type->value];
109-
$rawImportMapData[$entry->importName] = $data;
107+
$rawImportMapData[$entry->importName] = ['path' => $asset->publicPath, 'type' => $entry->type->value];
108+
if ($asset->integrity) {
109+
$rawImportMapData[$entry->importName]['integrity'] = $asset->integrity;
110+
}
110111
}
111112

112113
return $rawImportMapData;

src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public function render(string|array $entryPoint, array $attributes = []): string
4949
$importMap = [];
5050
$modulePreloads = [];
5151
$cssLinks = [];
52+
$integrity = [];
5253
$polyfillPath = null;
5354
foreach ($importMapData as $importName => $data) {
5455
$path = $data['path'];
@@ -70,8 +71,12 @@ public function render(string|array $entryPoint, array $attributes = []): string
7071
}
7172

7273
$preload = $data['preload'] ?? false;
74+
$assetIntegrity = $data['integrity'] ?? false;
7375
if ('css' !== $data['type']) {
7476
$importMap[$importName] = $path;
77+
if ($assetIntegrity) {
78+
$integrity[$path] = $assetIntegrity;
79+
}
7580
if ($preload) {
7681
$modulePreloads[] = $path;
7782
}
@@ -96,7 +101,7 @@ public function render(string|array $entryPoint, array $attributes = []): string
96101
}
97102

98103
$scriptAttributes = $attributes || $this->scriptAttributes ? ' '.$this->createAttributesString($attributes) : '';
99-
$importMapJson = json_encode(['imports' => $importMap], \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_HEX_TAG);
104+
$importMapJson = json_encode(['imports' => $importMap, ...$integrity ? ['integrity' => $integrity] : []], \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_HEX_TAG);
100105
$output .= <<<HTML
101106
102107
<script type="importmap"$scriptAttributes>

src/Symfony/Component/AssetMapper/MappedAsset.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public function __construct(
5252
private array $dependencies = [],
5353
private array $fileDependencies = [],
5454
private array $javaScriptImports = [],
55+
public ?string $integrity = null,
5556
) {
5657
if (null !== $sourcePath) {
5758
$this->sourcePath = $sourcePath;

src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Symfony\Component\AssetMapper\Compiler\CssAssetUrlCompiler;
2020
use Symfony\Component\AssetMapper\Compiler\JavaScriptImportPathCompiler;
2121
use Symfony\Component\AssetMapper\Exception\CircularAssetsException;
22+
use Symfony\Component\AssetMapper\Exception\LogicException;
2223
use Symfony\Component\AssetMapper\Factory\MappedAssetFactory;
2324
use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader;
2425
use Symfony\Component\AssetMapper\MappedAsset;
@@ -148,7 +149,35 @@ public function testCreateMappedAssetInMissingVendor()
148149
$this->assertFalse($asset->isVendor);
149150
}
150151

151-
private function createFactory(?AssetCompilerInterface $extraCompiler = null, ?string $vendorDir = self::VENDOR_FIXTURES_DIR): MappedAssetFactory
152+
public function testCreateMappedAssetWithoutIntegrity()
153+
{
154+
$factory = $this->createFactory();
155+
$asset = $factory->createMappedAsset('file2.js', self::FIXTURES_DIR.'/dir1/file2.js');
156+
$this->assertNull($asset->integrity);
157+
}
158+
159+
public function testCreateMappedAssetWithOneIntegrityAlgorithm()
160+
{
161+
$factory = $this->createFactory(integrityHashAlgorithms: ['sha256']);
162+
$asset = $factory->createMappedAsset('file2.js', self::FIXTURES_DIR.'/dir1/file2.js');
163+
$this->assertSame('sha256-b8bze+0OP5qLVVEG0aUh25UkvNjZXLeugH9Jg7MvSz8=', $asset->integrity);
164+
}
165+
166+
public function testCreateMappedAssetWithManyIntegrityAlgorithms()
167+
{
168+
$factory = $this->createFactory(integrityHashAlgorithms: ['sha256', 'sha384']);
169+
$asset = $factory->createMappedAsset('file2.js', self::FIXTURES_DIR.'/dir1/file2.js');
170+
$this->assertSame('sha256-b8bze+0OP5qLVVEG0aUh25UkvNjZXLeugH9Jg7MvSz8= sha384-2cpbxkWC8I4PKAhlQ+LaFmVek6qd8w35xUZ+QRGMzcSvX9SP2EgjLvKSawSmS9J7', $asset->integrity);
171+
}
172+
173+
public function testCreateMappedAssetWithInvalidIntegrityAlgorithm()
174+
{
175+
$this->expectException(LogicException::class);
176+
$this->expectExceptionMessage('Unsupported "sha1" algorithm(s). Supported ones are "sha256", "sha384", "sha512".');
177+
$this->createFactory(integrityHashAlgorithms: ['sha1']);
178+
}
179+
180+
private function createFactory(?AssetCompilerInterface $extraCompiler = null, ?string $vendorDir = self::VENDOR_FIXTURES_DIR, array $integrityHashAlgorithms = []): MappedAssetFactory
152181
{
153182
$compilers = [
154183
new JavaScriptImportPathCompiler($this->createMock(ImportMapConfigReader::class)),
@@ -174,6 +203,7 @@ private function createFactory(?AssetCompilerInterface $extraCompiler = null, ?s
174203
$pathResolver,
175204
$compiler,
176205
$vendorDir,
206+
$integrityHashAlgorithms,
177207
);
178208

179209
// mock the AssetMapper to behave like normal: by calling back to the factory

src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapGeneratorTest.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ public function testGetImportMapData()
9292
path: 'styles/never_imported_css.css',
9393
type: ImportMapType::CSS,
9494
),
95+
self::createLocalEntry(
96+
'js_file_with_integrity',
97+
path: 'js_file_with_integrity.js',
98+
),
9599
]);
96100

97101
$importedFile1 = new MappedAsset(
@@ -142,6 +146,13 @@ public function testGetImportMapData()
142146
publicPathWithoutDigest: '/assets/styles/never_imported_css.css',
143147
publicPath: '/assets/styles/never_imported_css-d1g35t.css',
144148
);
149+
$jsFileWithIntegrity = new MappedAsset(
150+
'js_file_with_integrity.js',
151+
'/path/to/js_file_with_integrity.js',
152+
publicPathWithoutDigest: '/assets/js_file_with_integrity.js',
153+
publicPath: '/assets/js_file_with_integrity-d1g35t.js',
154+
integrity: 'sha384-base64-hash'
155+
);
145156
$this->mockAssetMapper([
146157
new MappedAsset(
147158
'entry1.js',
@@ -179,6 +190,7 @@ public function testGetImportMapData()
179190
$importedCss2,
180191
$importedCssInImportmap,
181192
$neverImportedCss,
193+
$jsFileWithIntegrity,
182194
]);
183195

184196
$actualImportMapData = $manager->getImportMapData(['entry2', 'entry1']);
@@ -232,6 +244,11 @@ public function testGetImportMapData()
232244
'path' => '/assets/styles/never_imported_css-d1g35t.css',
233245
'type' => 'css',
234246
],
247+
'js_file_with_integrity' => [
248+
'path' => '/assets/js_file_with_integrity-d1g35t.js',
249+
'type' => 'js',
250+
'integrity' => 'sha384-base64-hash',
251+
],
235252
], $actualImportMapData);
236253

237254
// now check the order
@@ -251,6 +268,7 @@ public function testGetImportMapData()
251268
// importmap entries never imported
252269
'entry3',
253270
'never_imported_css',
271+
'js_file_with_integrity',
254272
], array_keys($actualImportMapData));
255273
}
256274

@@ -570,6 +588,31 @@ public static function getRawImportMapDataTests(): iterable
570588
],
571589
],
572590
];
591+
592+
yield 'it adds integrity when it exists' => [
593+
[
594+
self::createLocalEntry(
595+
'app',
596+
path: './assets/app.js',
597+
),
598+
],
599+
[
600+
new MappedAsset(
601+
'app.js',
602+
// /fake/root is the mocked root directory
603+
'/fake/root/assets/app.js',
604+
publicPath: '/assets/app-d1g3st.js',
605+
integrity: 'sha384-base64-hash',
606+
),
607+
],
608+
[
609+
'app' => [
610+
'path' => '/assets/app-d1g3st.js',
611+
'type' => 'js',
612+
'integrity' => 'sha384-base64-hash',
613+
],
614+
],
615+
];
573616
}
574617

575618
public function testGetRawImportDataUsesCacheFile()

0 commit comments

Comments
 (0)
0