Skip to content

Commit dc3d3fe

Browse files
committed
fix(CodeSigningPlugin): sign assets at processAssets ANALYSE stage before REPORT
1 parent 13e7ed5 commit dc3d3fe

File tree

5 files changed

+127
-43
lines changed

5 files changed

+127
-43
lines changed

.changeset/giant-dancers-sin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@callstack/repack": patch
3+
---
4+
5+
Fix CodeSigningPlugin signing assets at processAssets ANALYSE stage (2000) instead of assetEmitted, ensuring bundles are signed before plugins running at REPORT stage (5000) such as withZephyr() can capture and upload them

packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts

Lines changed: 57 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,15 @@
11
import crypto from 'node:crypto';
22
import fs from 'node:fs';
33
import path from 'node:path';
4-
import util from 'node:util';
54
import type { Compiler as RspackCompiler } from '@rspack/core';
65
import jwt from 'jsonwebtoken';
76
import type { Compiler as WebpackCompiler } from 'webpack';
87
import { type CodeSigningPluginConfig, validateConfig } from './config.js';
98

109
export class CodeSigningPlugin {
11-
private chunkFilenames: Set<string>;
12-
13-
/**
14-
* Constructs new `RepackPlugin`.
15-
*
16-
* @param config Plugin configuration options.
17-
*/
1810
constructor(private config: CodeSigningPluginConfig) {
1911
validateConfig(config);
2012
this.config.excludeChunks = this.config.excludeChunks ?? [];
21-
this.chunkFilenames = new Set();
2213
}
2314

2415
private shouldSignFile(
@@ -27,7 +18,7 @@ export class CodeSigningPlugin {
2718
excludedChunks: string[] | RegExp[]
2819
): boolean {
2920
/** Exclude non-chunks & main chunk as it's always local */
30-
if (!this.chunkFilenames.has(file) || file === mainOutputFilename) {
21+
if (file === mainOutputFilename) {
3122
return false;
3223
}
3324

@@ -76,40 +67,65 @@ export class CodeSigningPlugin {
7667
? this.config.excludeChunks
7768
: [this.config.excludeChunks as RegExp];
7869

79-
compiler.hooks.emit.tap('RepackCodeSigningPlugin', (compilation) => {
80-
compilation.chunks.forEach((chunk) => {
81-
chunk.files.forEach((file) => this.chunkFilenames.add(file));
82-
});
83-
});
84-
85-
compiler.hooks.assetEmitted.tapPromise(
86-
{ name: 'RepackCodeSigningPlugin', stage: 20 },
87-
async (file, { outputPath, compilation }) => {
88-
const outputFilepath = path.join(outputPath, file);
89-
const readFileAsync = util.promisify(
90-
compiler.outputFileSystem!.readFile
91-
);
92-
const content = (await readFileAsync(outputFilepath)) as Buffer;
70+
compiler.hooks.thisCompilation.tap(
71+
'RepackCodeSigningPlugin',
72+
(compilation) => {
73+
// @ts-ignore — sources is available on both rspack and webpack compilers
74+
const { sources } = compiler.webpack;
9375
const mainBundleName = compilation.outputOptions.filename as string;
94-
if (!this.shouldSignFile(file, mainBundleName, excludedChunks)) {
95-
return;
96-
}
97-
logger.debug(`Signing ${file}`);
98-
/** generate bundle hash */
99-
const hash = crypto.createHash('sha256').update(content).digest('hex');
100-
/** generate token */
101-
const token = jwt.sign({ hash }, privateKey, { algorithm: 'RS256' });
102-
/** combine the bundle and the token */
103-
const signedBundle = Buffer.concat(
104-
[content, Buffer.from(BEGIN_CS_MARK), Buffer.from(token)],
105-
content.length + TOKEN_BUFFER_SIZE
106-
);
10776

108-
const writeFileAsync = util.promisify(
109-
compiler.outputFileSystem!.writeFile
77+
compilation.hooks.processAssets.tap(
78+
{
79+
name: 'RepackCodeSigningPlugin',
80+
// Sign at ANALYSE (2000) so assets are signed before any plugin
81+
// running at REPORT (5000) — e.g. withZephyr() — captures them.
82+
// The original assetEmitted hook fires after processAssets completes,
83+
// which is too late when Zephyr uploads assets at REPORT stage.
84+
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE,
85+
},
86+
() => {
87+
for (const chunk of compilation.chunks) {
88+
for (const file of chunk.files) {
89+
if (
90+
!this.shouldSignFile(file, mainBundleName, excludedChunks)
91+
) {
92+
continue;
93+
}
94+
95+
const asset = compilation.getAsset(file);
96+
if (!asset) continue;
97+
98+
const source = asset.source.source();
99+
const content = Buffer.isBuffer(source)
100+
? source
101+
: Buffer.from(source);
102+
103+
logger.debug(`Signing ${file}`);
104+
/** generate bundle hash */
105+
const hash = crypto
106+
.createHash('sha256')
107+
.update(content)
108+
.digest('hex');
109+
/** generate token */
110+
const token = jwt.sign({ hash }, privateKey, {
111+
algorithm: 'RS256',
112+
});
113+
/** combine the bundle and the token */
114+
const signedBundle = Buffer.concat(
115+
[content, Buffer.from(BEGIN_CS_MARK), Buffer.from(token)],
116+
content.length + TOKEN_BUFFER_SIZE
117+
);
118+
119+
compilation.updateAsset(
120+
file,
121+
new sources.RawSource(signedBundle)
122+
);
123+
124+
logger.debug(`Signed ${file}`);
125+
}
126+
}
127+
}
110128
);
111-
await writeFileAsync(outputFilepath, signedBundle);
112-
logger.debug(`Signed ${file}`);
113129
}
114130
);
115131
}

packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
3-
import { rspack } from '@rspack/core';
3+
import { type Compiler, rspack } from '@rspack/core';
44
import jwt from 'jsonwebtoken';
55
import memfs from 'memfs';
66
import RspackVirtualModulePlugin from 'rspack-plugin-virtual-module';
@@ -15,7 +15,8 @@ const BUNDLE_WITH_JWT_REGEX =
1515
async function compileBundle(
1616
outputFilename: string,
1717
virtualModules: Record<string, string>,
18-
codeSigningConfig: CodeSigningPluginConfig
18+
codeSigningConfig: CodeSigningPluginConfig,
19+
additionalPlugins: Array<{ apply(compiler: Compiler): void }> = []
1920
) {
2021
const fileSystem = memfs.createFsFromVolume(new memfs.Volume());
2122

@@ -36,6 +37,7 @@ async function compileBundle(
3637
'package.json': '{ "type": "module" }',
3738
...virtualModules,
3839
}),
40+
...additionalPlugins,
3941
],
4042
});
4143

@@ -81,6 +83,59 @@ describe('CodeSigningPlugin', () => {
8183
expect(chunkBundle.length).toBeGreaterThan(1280);
8284
});
8385

86+
it('exposes signed chunk assets to processAssets REPORT (after ANALYSE signing)', async () => {
87+
const seenAtReportStage: Record<string, string> = {};
88+
89+
const captureAtReportStage = {
90+
apply(compiler: Compiler) {
91+
compiler.hooks.thisCompilation.tap(
92+
'TestReportStageCapture',
93+
(compilation) => {
94+
compilation.hooks.processAssets.tap(
95+
{
96+
name: 'TestReportStageCapture',
97+
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT,
98+
},
99+
() => {
100+
for (const chunk of compilation.chunks) {
101+
for (const file of chunk.files) {
102+
const asset = compilation.getAsset(file);
103+
if (!asset) continue;
104+
const raw = asset.source.source();
105+
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
106+
seenAtReportStage[file] = buf.toString();
107+
}
108+
}
109+
}
110+
);
111+
}
112+
);
113+
},
114+
};
115+
116+
await compileBundle(
117+
'index.bundle',
118+
{
119+
'index.js': `
120+
const chunk = import(/* webpackChunkName: "myChunk" */'./myChunk.js');
121+
chunk.then(console.log);
122+
`,
123+
'myChunk.js': `
124+
export default 'myChunk';
125+
`,
126+
},
127+
{ enabled: true, privateKeyPath: '__fixtures__/testRS256.pem' },
128+
[captureAtReportStage]
129+
);
130+
131+
expect(
132+
seenAtReportStage['myChunk.chunk.bundle']?.match(BUNDLE_WITH_JWT_REGEX)
133+
).toBeTruthy();
134+
expect(
135+
seenAtReportStage['index.bundle']?.match(BUNDLE_WITH_JWT_REGEX)
136+
).toBeNull();
137+
});
138+
84139
it('produces code-signed bundles with valid JWTs', async () => {
85140
const publicKey = fs.readFileSync(
86141
path.join(__dirname, '__fixtures__/testRS256.pem.pub')

website/src/latest/api/plugins/code-signing.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ Whether to enable the plugin. You typically want to enable the plugin only for p
3939

4040
Names of chunks to exclude from code-signing. You might want to use this if some of the chunks in your setup are not being delivered remotely and don't need to be verified.
4141

42+
## Behavior
43+
44+
Chunk signatures are applied during `processAssets` at the `ANALYSE` stage (2000), before later stages of the same hook. This ensures that plugins or tooling that capture or upload chunk outputs at subsequent stages — such as `withZephyr()` which runs at `REPORT` stage (5000) — receive bundles that already include the signature.
45+
4246
## Guide
4347

4448
To add code-signing to your app, you first need to generate a pair of cryptographic keys that will be used for both signing the bundles (private key) and verifying their integrity in runtime.

website/src/v4/docs/plugins/code-signing.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ Whether to enable the plugin. You typically want to enable the plugin only for p
3737

3838
Names of chunks to exclude from code-signing. You might want to use this if some of the chunks in your setup are not being delivered remotely and don't need to be verified.
3939

40+
## Behavior
41+
42+
Chunk signatures are applied during `processAssets` at the `ANALYSE` stage (2000), before later stages of the same hook. This ensures that plugins or tooling that capture or upload chunk outputs at subsequent stages — such as `withZephyr()` which runs at `REPORT` stage (5000) — receive bundles that already include the signature.
43+
4044
## Guide
4145

4246
To add code-signing to your app, you first need to generate a pair of cryptographic keys that will be used for both signing the bundles (private key) and verifying their integrity in runtime.

0 commit comments

Comments
 (0)