Skip to content

Commit 2a03a2f

Browse files
committed
[Integrity][Tests][Docs] Emit Subresource Integrity hashes in entrypoints.json
Add an opt-in `integrity: { enabled, algorithms? }` option (default `['sha384']`) that writes an `integrity` map (asset URL -> SRI hash) into entrypoints.json, for Reprise's Symfony bundle (src/RepriseBundle.php, still a stub) to render as integrity="..." on the script/link tags. This change is JS-only. Hashes are computed from the files on disk after each bundler finishes emitting: the Rspack `done` hook and a Vite `writeBundle` hook both read the emitted files back and hash them. In-memory hashing does not work for Vite because it finalizes chunks (e.g. replacing `__VITE_PRELOAD__`) only when writing to disk, so the shipped bytes differ from the bundle. Every referenced file per entry (js/css/preload/dynamic) is covered. Build only -- the dev server serves changing in-memory assets, so no hashes there.
1 parent 32b824b commit 2a03a2f

15 files changed

Lines changed: 385 additions & 8 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Symfony Reprise covers only the Symfony-side glue the bundlers leave out:
3131
- 🔥 **Dev server & HMR**: points Twig at the running Vite/Rsbuild server
3232
- 🧩 **Symfony UX / Stimulus**: registers `controllers.json` and local controllers, eager or lazy
3333
- 🌐 **CDN support**: serve built assets from an absolute `publicPath`
34-
- 🛡️ **Subresource Integrity**: SRI hashes in `entrypoints.json` _(planned)_
34+
- 🛡️ **Subresource Integrity**: SRI hashes in `entrypoints.json`
3535
- 📦 **Shared runtime chunk**: one runtime shared across entries _(planned)_
3636

3737
Vite and Rsbuild already handle **Sass/Less/PostCSS**, **TypeScript**, **JSX/Vue/Svelte**, **code splitting**, **content hashing**, **source maps**, **minification** and **HMR** on their own, so Symfony Reprise does not reimplement any of that.

assets/src/core/format.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,20 @@ export function buildEntrypoints(graph: NormalizedGraph, ctx: BuildContext): Ent
1414
dynamic: files.dynamic.map((f) => joinUrl(ctx.urlPrefix, f)),
1515
};
1616
}
17-
return { isProd: ctx.isProd, devServer: ctx.devServer, publicPath: ctx.publicPath, entryPoints };
17+
const out: EntrypointsJson = {
18+
isProd: ctx.isProd,
19+
devServer: ctx.devServer,
20+
publicPath: ctx.publicPath,
21+
entryPoints,
22+
};
23+
if (graph.integrity) {
24+
// Re-key the per-file-name hashes by the same URLs that appear in the entry lists,
25+
// so the Symfony side can look each one up by asset URL.
26+
out.integrity = Object.fromEntries(
27+
Object.entries(graph.integrity).map(([fileName, sri]) => [joinUrl(ctx.urlPrefix, fileName), sri])
28+
);
29+
}
30+
return out;
1831
}
1932

2033
export function buildManifest(graph: NormalizedGraph, ctx: BuildContext): ManifestJson {

assets/src/core/integrity.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { EntryFiles } from '../types';
2+
import { createHash } from 'node:crypto';
3+
import { readFileSync } from 'node:fs';
4+
import { join } from 'node:path';
5+
6+
/**
7+
* Build a Subresource Integrity string for `content`: one `<algorithm>-<base64 digest>`
8+
* token per algorithm, joined by spaces (the format browsers expect in an `integrity`
9+
* attribute, and the one Webpack Encore writes into `entrypoints.json`).
10+
*/
11+
export function computeIntegrity(content: string | Uint8Array, algorithms: string[]): string {
12+
return algorithms.map((algo) => `${algo}-${createHash(algo).update(content).digest('base64')}`).join(' ');
13+
}
14+
15+
/**
16+
* The distinct file names referenced by every entry, across all four buckets
17+
* (js/css/preload/dynamic), in first-seen order. This is the set of emitted files
18+
* that get an integrity hash.
19+
*/
20+
export function referencedFileNames(entryPoints: Record<string, EntryFiles>): string[] {
21+
const seen = new Set<string>();
22+
for (const files of Object.values(entryPoints)) {
23+
for (const fileName of [...files.js, ...files.css, ...files.preload, ...files.dynamic]) {
24+
seen.add(fileName);
25+
}
26+
}
27+
return [...seen];
28+
}
29+
30+
/**
31+
* Compute the integrity of each file read from `outputPath` on disk, keyed by file name.
32+
* Used by the Rspack path, whose `done` hook fires after the assets are emitted (like
33+
* Encore, which reads the emitted files back). Bytes are hashed raw, so binary assets work.
34+
*/
35+
export function integrityFromDisk(
36+
fileNames: string[],
37+
outputPath: string,
38+
algorithms: string[]
39+
): Record<string, string> {
40+
const integrity: Record<string, string> = {};
41+
for (const fileName of fileNames) {
42+
integrity[fileName] = computeIntegrity(readFileSync(join(outputPath, fileName)), algorithms);
43+
}
44+
return integrity;
45+
}

assets/src/core/options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { Options, ResolvedOptions, ResolvedStimulusOptions } from '../types';
22
import * as path from 'node:path';
33

4+
function normalizeIntegrity(integrity: Options['integrity']): ResolvedOptions['integrity'] {
5+
if (!integrity?.enabled) return undefined;
6+
return { algorithms: integrity.algorithms?.length ? [...integrity.algorithms] : ['sha384'] };
7+
}
8+
49
function normalizeStimulus(stimulus: Options['stimulus'], cwd: string): ResolvedStimulusOptions | undefined {
510
if (!stimulus) return undefined;
611
const raw = typeof stimulus === 'string' ? { controllersJson: stimulus } : stimulus;
@@ -38,6 +43,7 @@ export function normalizeOptions(options: Options | undefined, cwd: string): Res
3843
manifestKeyPrefix,
3944
devServerOrigin: options?.devServerOrigin,
4045
stimulus: normalizeStimulus(options?.stimulus, cwd),
46+
integrity: normalizeIntegrity(options?.integrity),
4147
};
4248
}
4349

assets/src/index.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import type { UnpluginFactory } from 'unplugin';
2-
import type { BuildContext, Options } from './types';
2+
import type { BuildContext, NormalizedGraph, Options } from './types';
3+
import { writeFileSync } from 'node:fs';
4+
import { join } from 'node:path';
35
import * as process from 'node:process';
46
import { createUnplugin } from 'unplugin';
57
import { bundleToGraph, configToDevGraph } from './collectors/vite';
68
import { resolveDevOrigin } from './core/dev-server';
79
import { writeSymfonyFiles } from './core/emit';
810
import { buildEntrypoints, buildManifest } from './core/format';
11+
import { integrityFromDisk, referencedFileNames } from './core/integrity';
912
import { normalizeOptions, resolvePublicPath } from './core/options';
1013
import { generateControllersModule, STIMULUS_NOT_ENABLED_MESSAGE, VIRTUAL_CONTROLLERS_ID } from './core/stimulus';
1114

@@ -15,6 +18,8 @@ const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_ID}`;
1518
export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _meta) => {
1619
const resolved = normalizeOptions(options, process.cwd());
1720
let isDev = false;
21+
// When SRI is on, entrypoints.json is finished in `writeBundle` (see below); stash what it needs.
22+
let pendingIntegrity: { graph: NormalizedGraph; ctx: BuildContext } | null = null;
1823

1924
return {
2025
name: '@symfony/reprise',
@@ -49,6 +54,25 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _
4954
fileName: 'manifest.json',
5055
source: `${JSON.stringify(buildManifest(graph, ctx), null, 2)}\n`,
5156
});
57+
// SRI must hash the bytes that ship: Vite only finalizes chunks (replacing markers like
58+
// `__VITE_PRELOAD__`) when writing to disk, so the in-memory bundle differs from the file.
59+
// Defer to writeBundle (files on disk) and rewrite entrypoints.json with the integrity map.
60+
if (resolved.integrity) pendingIntegrity = { graph, ctx };
61+
},
62+
63+
writeBundle() {
64+
if (!pendingIntegrity || !resolved.integrity) return;
65+
const { graph, ctx } = pendingIntegrity;
66+
pendingIntegrity = null;
67+
graph.integrity = integrityFromDisk(
68+
referencedFileNames(graph.entryPoints),
69+
resolved.outputPath,
70+
resolved.integrity.algorithms
71+
);
72+
writeFileSync(
73+
join(resolved.outputPath, 'entrypoints.json'),
74+
`${JSON.stringify(buildEntrypoints(graph, ctx), null, 2)}\n`
75+
);
5276
},
5377

5478
configResolved(config) {

assets/src/rsbuild.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as process from 'node:process';
66
import { rspack } from '@rsbuild/core';
77
import { statsToGraph } from './collectors/rspack';
88
import { writeSymfonyFiles } from './core/emit';
9+
import { integrityFromDisk, referencedFileNames } from './core/integrity';
910
import { buildEntrypoints, buildManifest } from './core/format';
1011
import { normalizeOptions, resolvePublicPath } from './core/options';
1112
import { generateControllersModule, STIMULUS_NOT_ENABLED_MESSAGE, VIRTUAL_CONTROLLERS_ID } from './core/stimulus';
@@ -131,6 +132,15 @@ export default function symfony(options?: Options): RsbuildPlugin {
131132
manifestKeyPrefix: resolved.manifestKeyPrefix,
132133
};
133134
const graph = statsToGraph(stats.toJson({ assets: true, entrypoints: true }) as RspackStats);
135+
// SRI (build only): `done` fires after emit, so hash the files back off disk
136+
// (the same approach Encore takes). Dev serves changing in-memory assets, no hashes.
137+
if (!isDev && resolved.integrity) {
138+
graph.integrity = integrityFromDisk(
139+
referencedFileNames(graph.entryPoints),
140+
resolved.outputPath,
141+
resolved.integrity.algorithms
142+
);
143+
}
134144
// In dev the manifest is empty: assets are served from the dev server, never looked
135145
// up on disk by hash, so cache-busting is moot. entrypoints.json alone drives loading.
136146
// Matches the Vite dev path (see `configureServer` in index.ts), which also writes `{}`.

assets/src/types.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,34 @@ export interface Options {
8484
*/
8585
stimulus?: string | StimulusOptions;
8686

87+
/**
88+
* Emit Subresource Integrity (SRI) hashes for the built assets.
89+
*
90+
* When enabled, `entrypoints.json` gains an `integrity` map (asset URL -> hash),
91+
* which Reprise's Symfony bundle renders as `integrity="..."` on the script/link tags.
92+
* Only applies to build mode; the dev server serves changing in-memory assets, so
93+
* no hashes are emitted there.
94+
*
95+
* ```js
96+
* // enable only for the production build (Vite exposes `command`)
97+
* Symfony({ integrity: { enabled: command === 'build', algorithms: ['sha384'] } })
98+
* ```
99+
*/
100+
integrity?: IntegrityOptions;
101+
87102
// singleRuntimeChunk?: boolean
88103
}
89104

105+
/** Hash algorithm used for Subresource Integrity. */
106+
export type IntegrityAlgorithm = 'sha256' | 'sha384' | 'sha512';
107+
108+
export interface IntegrityOptions {
109+
/** Turn SRI on or off. Off by default. */
110+
enabled: boolean;
111+
/** Algorithms to hash each asset with. Default: `['sha384']`. */
112+
algorithms?: IntegrityAlgorithm[];
113+
}
114+
90115
export interface StimulusOptions {
91116
/** Path to `controllers.json`, e.g. `assets/controllers.json`. */
92117
controllersJson: string;
@@ -112,6 +137,8 @@ export interface ResolvedOptions {
112137
manifestKeyPrefix: string;
113138
devServerOrigin?: string;
114139
stimulus?: ResolvedStimulusOptions;
140+
/** Present (with a non-empty algorithm list) only when SRI is enabled. */
141+
integrity?: { algorithms: string[] };
115142
}
116143

117144
export interface EntryFiles {
@@ -134,6 +161,8 @@ export interface AssetEntry {
134161
export interface NormalizedGraph {
135162
entryPoints: Record<string, EntryFiles>;
136163
assets: AssetEntry[];
164+
/** SRI hash per emitted file name; set by the collectors only when SRI is enabled. */
165+
integrity?: Record<string, string>;
137166
}
138167

139168
export interface BuildContext {
@@ -152,6 +181,8 @@ export interface EntrypointsJson {
152181
devServer: DevServer | null;
153182
publicPath: string;
154183
entryPoints: Record<string, EntryFiles>;
184+
/** SRI hash per asset URL; present only in build mode with SRI enabled. */
185+
integrity?: Record<string, string>;
155186
}
156187

157188
export type ManifestJson = Record<string, string>;

assets/test/core/format.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ describe('buildEntrypoints', () => {
4646
expect(out.entryPoints.app.js).toEqual(['/build/app-a1b2.js']);
4747
});
4848

49+
it('emits a top-level integrity map keyed by URL when the graph carries hashes', () => {
50+
const out = buildEntrypoints(
51+
{ ...graph, integrity: { 'app-a1b2.js': 'sha384-JS', 'app-c3d4.css': 'sha384-CSS' } },
52+
ctx
53+
);
54+
expect(out.integrity).toEqual({
55+
'/build/app-a1b2.js': 'sha384-JS',
56+
'/build/app-c3d4.css': 'sha384-CSS',
57+
});
58+
});
59+
60+
it('omits integrity when the graph carries no hashes', () => {
61+
expect(buildEntrypoints(graph, ctx).integrity).toBeUndefined();
62+
});
63+
4964
it('builds URLs from urlPrefix but emits the original publicPath field', () => {
5065
const devCtx: BuildContext = {
5166
isProd: false,

assets/test/core/integrity.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { EntryFiles } from '../../src/types';
2+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { dirname, join } from 'node:path';
5+
import { describe, expect, it } from 'vitest';
6+
import { computeIntegrity, integrityFromDisk, referencedFileNames } from '../../src/core/integrity';
7+
8+
describe('computeIntegrity', () => {
9+
it('formats a single algorithm as `<algo>-<base64>`', () => {
10+
expect(computeIntegrity('reprise', ['sha384'])).toBe(
11+
'sha384-faAamSmmStfxJQ9skVshC6f5CFqO35HWc37M47/RU370OJxAaGp/EDpOhEtMqHU6'
12+
);
13+
expect(computeIntegrity('reprise', ['sha256'])).toBe('sha256-9HI/4mqcitIofBrwxywRV2OTHrfkKZSVAdGtr3AidrQ=');
14+
});
15+
16+
it('joins multiple algorithms with a space, in the given order', () => {
17+
expect(computeIntegrity('reprise', ['sha256', 'sha512'])).toBe(
18+
'sha256-9HI/4mqcitIofBrwxywRV2OTHrfkKZSVAdGtr3AidrQ= sha512-lMCBX5XJ7xq9zWMR7zg7rQXzt0U1v/qX3vtUkCDkrBvOULn+UWCMFvf0hTGqKr+GMj+gTY5zzqQhISiHJlMDXg=='
19+
);
20+
});
21+
22+
it('hashes raw bytes (Uint8Array) the same as the equivalent string', () => {
23+
expect(computeIntegrity(new TextEncoder().encode('reprise'), ['sha256'])).toBe(
24+
computeIntegrity('reprise', ['sha256'])
25+
);
26+
});
27+
});
28+
29+
describe('referencedFileNames', () => {
30+
it('collects js/css/preload/dynamic across entries, deduped in first-seen order', () => {
31+
const entryPoints: Record<string, EntryFiles> = {
32+
app: { js: ['app.js'], css: ['app.css'], preload: ['vendor.js'], dynamic: ['lazy.js'] },
33+
admin: { js: ['admin.js'], css: [], preload: ['vendor.js'], dynamic: [] },
34+
};
35+
expect(referencedFileNames(entryPoints)).toEqual(['app.js', 'app.css', 'vendor.js', 'lazy.js', 'admin.js']);
36+
});
37+
});
38+
39+
describe('integrityFromDisk', () => {
40+
it('hashes each named file (including nested paths) read from the output directory', () => {
41+
const dir = mkdtempSync(join(tmpdir(), 'reprise-sri-'));
42+
writeFileSync(join(dir, 'app.js'), 'reprise');
43+
mkdirSync(dirname(join(dir, 'static/js/vendor.js')), { recursive: true });
44+
writeFileSync(join(dir, 'static/js/vendor.js'), 'reprise');
45+
46+
expect(integrityFromDisk(['app.js', 'static/js/vendor.js'], dir, ['sha384'])).toEqual({
47+
'app.js': 'sha384-faAamSmmStfxJQ9skVshC6f5CFqO35HWc37M47/RU370OJxAaGp/EDpOhEtMqHU6',
48+
'static/js/vendor.js': 'sha384-faAamSmmStfxJQ9skVshC6f5CFqO35HWc37M47/RU370OJxAaGp/EDpOhEtMqHU6',
49+
});
50+
});
51+
});

assets/test/core/options.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,32 @@ describe('normalizeOptions', () => {
7070
controllersDir: join('/app', 'assets/stimulus'),
7171
});
7272
});
73+
74+
it('leaves integrity undefined when not configured', () => {
75+
expect(normalizeOptions(undefined, '/app').integrity).toBeUndefined();
76+
});
77+
78+
it('leaves integrity undefined when explicitly disabled', () => {
79+
expect(normalizeOptions({ integrity: { enabled: false } }, '/app').integrity).toBeUndefined();
80+
});
81+
82+
it('defaults enabled integrity to the sha384 algorithm', () => {
83+
expect(normalizeOptions({ integrity: { enabled: true } }, '/app').integrity).toEqual({
84+
algorithms: ['sha384'],
85+
});
86+
});
87+
88+
it('honors explicit algorithms', () => {
89+
expect(
90+
normalizeOptions({ integrity: { enabled: true, algorithms: ['sha256', 'sha512'] } }, '/app').integrity
91+
).toEqual({ algorithms: ['sha256', 'sha512'] });
92+
});
93+
94+
it('falls back to sha384 when enabled with an empty algorithm list', () => {
95+
expect(normalizeOptions({ integrity: { enabled: true, algorithms: [] } }, '/app').integrity).toEqual({
96+
algorithms: ['sha384'],
97+
});
98+
});
7399
});
74100

75101
describe('resolvePublicPath', () => {

0 commit comments

Comments
 (0)