Skip to content

Commit 8ab3105

Browse files
JhohellsDLdannyhw
andauthored
fix(CodeSigningPlugin): sign assets at processAssets ANALYSE stage before REPORT (#1379)
Co-authored-by: Daniel Williams <me@dannyhw.com>
1 parent d806b04 commit 8ab3105

3 files changed

Lines changed: 159 additions & 37 deletions

File tree

.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: 61 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
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>;
1210
/**
1311
* Constructs new `RepackPlugin`.
1412
*
@@ -17,7 +15,6 @@ export class CodeSigningPlugin {
1715
constructor(private config: CodeSigningPluginConfig) {
1816
validateConfig(config);
1917
this.config.excludeChunks = this.config.excludeChunks ?? [];
20-
this.chunkFilenames = new Set();
2118
}
2219

2320
private shouldSignFile(
@@ -26,7 +23,7 @@ export class CodeSigningPlugin {
2623
excludedChunks: string[] | RegExp[]
2724
): boolean {
2825
/** Exclude non-chunks & main chunk as it's always local */
29-
if (!this.chunkFilenames.has(file) || file === mainOutputFilename) {
26+
if (file === mainOutputFilename) {
3027
return false;
3128
}
3229

@@ -38,6 +35,26 @@ export class CodeSigningPlugin {
3835
});
3936
}
4037

38+
private signAsset(
39+
asset: { source: { source(): string | Buffer } },
40+
privateKey: Buffer,
41+
beginMark: string,
42+
tokenBufferSize: number
43+
): Buffer {
44+
const source = asset.source.source();
45+
const content = Buffer.isBuffer(source) ? source : Buffer.from(source);
46+
47+
const hash = crypto.createHash('sha256').update(content).digest('hex');
48+
const token = jwt.sign({ hash }, privateKey, {
49+
algorithm: 'RS256',
50+
});
51+
52+
return Buffer.concat(
53+
[content, Buffer.from(beginMark), Buffer.from(token)],
54+
content.length + tokenBufferSize
55+
);
56+
}
57+
4158
apply(compiler: RspackCompiler): void;
4259
apply(compiler: WebpackCompiler): void;
4360

@@ -75,40 +92,49 @@ export class CodeSigningPlugin {
7592
? this.config.excludeChunks
7693
: [this.config.excludeChunks as RegExp];
7794

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

107-
const writeFileAsync = util.promisify(
108-
compiler.outputFileSystem!.writeFile
101+
compilation.hooks.processAssets.tap(
102+
{
103+
name: 'RepackCodeSigningPlugin',
104+
// Sign at ANALYSE (2000) so later processAssets consumers,
105+
// such as Zephyr at REPORT (5000), receive already-signed assets
106+
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE,
107+
},
108+
() => {
109+
for (const chunk of compilation.chunks) {
110+
for (const file of chunk.files) {
111+
if (
112+
!this.shouldSignFile(file, mainBundleName, excludedChunks)
113+
) {
114+
continue;
115+
}
116+
117+
const asset = compilation.getAsset(file);
118+
if (!asset) continue;
119+
120+
logger.debug(`Signing ${file}`);
121+
const signedBundle = this.signAsset(
122+
asset,
123+
privateKey,
124+
BEGIN_CS_MARK,
125+
TOKEN_BUFFER_SIZE
126+
);
127+
128+
compilation.updateAsset(
129+
file,
130+
new sources.RawSource(signedBundle)
131+
);
132+
133+
logger.debug(`Signed ${file}`);
134+
}
135+
}
136+
}
109137
);
110-
await writeFileAsync(outputFilepath, signedBundle);
111-
logger.debug(`Signed ${file}`);
112138
}
113139
);
114140
}

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

Lines changed: 93 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,95 @@ 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 seenBeforeSigning: Record<string, string> = {};
88+
const seenAtReportStage: Record<string, string> = {};
89+
90+
const captureAtReportStage = {
91+
apply(compiler: Compiler) {
92+
compiler.hooks.thisCompilation.tap(
93+
'TestReportStageCapture',
94+
(compilation) => {
95+
const {
96+
PROCESS_ASSETS_STAGE_ANALYSE,
97+
PROCESS_ASSETS_STAGE_REPORT,
98+
} = compiler.webpack.Compilation;
99+
100+
/** Immediately before CodeSigningPlugin (ANALYSE / 2000) so content is still unsigned. */
101+
const beforeSigningStage = PROCESS_ASSETS_STAGE_ANALYSE - 1;
102+
103+
compilation.hooks.processAssets.tap(
104+
{
105+
name: 'TestPreAnalyseCapture',
106+
stage: beforeSigningStage,
107+
},
108+
() => {
109+
for (const chunk of compilation.chunks) {
110+
for (const file of chunk.files) {
111+
const asset = compilation.getAsset(file);
112+
if (!asset) continue;
113+
const raw = asset.source.source();
114+
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
115+
seenBeforeSigning[file] = buf.toString();
116+
}
117+
}
118+
}
119+
);
120+
121+
compilation.hooks.processAssets.tap(
122+
{
123+
name: 'TestReportStageCapture',
124+
stage: PROCESS_ASSETS_STAGE_REPORT,
125+
},
126+
() => {
127+
for (const chunk of compilation.chunks) {
128+
for (const file of chunk.files) {
129+
const asset = compilation.getAsset(file);
130+
if (!asset) continue;
131+
const raw = asset.source.source();
132+
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
133+
seenAtReportStage[file] = buf.toString();
134+
}
135+
}
136+
}
137+
);
138+
}
139+
);
140+
},
141+
};
142+
143+
await compileBundle(
144+
'index.bundle',
145+
{
146+
'index.js': `
147+
const chunk = import(/* webpackChunkName: "myChunk" */'./myChunk.js');
148+
chunk.then(console.log);
149+
`,
150+
'myChunk.js': `
151+
export default 'myChunk';
152+
`,
153+
},
154+
{ enabled: true, privateKeyPath: '__fixtures__/testRS256.pem' },
155+
[captureAtReportStage]
156+
);
157+
158+
const chunkFile = 'myChunk.chunk.bundle';
159+
const before = seenBeforeSigning[chunkFile];
160+
const atReport = seenAtReportStage[chunkFile];
161+
162+
expect(before).toBeDefined();
163+
expect(atReport).toBeDefined();
164+
/** Regression guard: signing at ANALYSE must mutate assets before REPORT (not only on emit). */
165+
expect(before.includes('/* RCSSB */')).toBe(false);
166+
expect(atReport.includes('/* RCSSB */')).toBe(true);
167+
expect(atReport.length).toBeGreaterThan(before.length);
168+
169+
expect(atReport.match(BUNDLE_WITH_JWT_REGEX)).toBeTruthy();
170+
expect(
171+
seenAtReportStage['index.bundle']?.match(BUNDLE_WITH_JWT_REGEX)
172+
).toBeNull();
173+
});
174+
84175
it('produces code-signed bundles with valid JWTs', async () => {
85176
const publicKey = fs.readFileSync(
86177
path.join(__dirname, '__fixtures__/testRS256.pem.pub')

0 commit comments

Comments
 (0)