diff --git a/.changeset/giant-dancers-sin.md b/.changeset/giant-dancers-sin.md new file mode 100644 index 000000000..53427abfb --- /dev/null +++ b/.changeset/giant-dancers-sin.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": patch +--- + +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 diff --git a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts index 100c757f2..0c82a5741 100644 --- a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts +++ b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts @@ -1,15 +1,12 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import util from 'node:util'; import type { Compiler as RspackCompiler } from '@rspack/core'; import jwt from 'jsonwebtoken'; import type { Compiler as WebpackCompiler } from 'webpack'; import { type CodeSigningPluginConfig, validateConfig } from './config.js'; export class CodeSigningPlugin { - private chunkFilenames: Set; - /** * Constructs new `RepackPlugin`. * @@ -18,7 +15,6 @@ export class CodeSigningPlugin { constructor(private config: CodeSigningPluginConfig) { validateConfig(config); this.config.excludeChunks = this.config.excludeChunks ?? []; - this.chunkFilenames = new Set(); } private shouldSignFile( @@ -27,7 +23,7 @@ export class CodeSigningPlugin { excludedChunks: string[] | RegExp[] ): boolean { /** Exclude non-chunks & main chunk as it's always local */ - if (!this.chunkFilenames.has(file) || file === mainOutputFilename) { + if (file === mainOutputFilename) { return false; } @@ -39,6 +35,26 @@ export class CodeSigningPlugin { }); } + private signAsset( + asset: { source: { source(): string | Buffer } }, + privateKey: Buffer, + beginMark: string, + tokenBufferSize: number + ): Buffer { + const source = asset.source.source(); + const content = Buffer.isBuffer(source) ? source : Buffer.from(source); + + const hash = crypto.createHash('sha256').update(content).digest('hex'); + const token = jwt.sign({ hash }, privateKey, { + algorithm: 'RS256', + }); + + return Buffer.concat( + [content, Buffer.from(beginMark), Buffer.from(token)], + content.length + tokenBufferSize + ); + } + apply(compiler: RspackCompiler): void; apply(compiler: WebpackCompiler): void; @@ -76,40 +92,49 @@ export class CodeSigningPlugin { ? this.config.excludeChunks : [this.config.excludeChunks as RegExp]; - compiler.hooks.emit.tap('RepackCodeSigningPlugin', (compilation) => { - compilation.chunks.forEach((chunk) => { - chunk.files.forEach((file) => this.chunkFilenames.add(file)); - }); - }); - - compiler.hooks.assetEmitted.tapPromise( - { name: 'RepackCodeSigningPlugin', stage: 20 }, - async (file, { outputPath, compilation }) => { - const outputFilepath = path.join(outputPath, file); - const readFileAsync = util.promisify( - compiler.outputFileSystem!.readFile - ); - const content = (await readFileAsync(outputFilepath)) as Buffer; + compiler.hooks.thisCompilation.tap( + 'RepackCodeSigningPlugin', + (compilation) => { + const { sources } = compiler.webpack; const mainBundleName = compilation.outputOptions.filename as string; - if (!this.shouldSignFile(file, mainBundleName, excludedChunks)) { - return; - } - logger.debug(`Signing ${file}`); - /** generate bundle hash */ - const hash = crypto.createHash('sha256').update(content).digest('hex'); - /** generate token */ - const token = jwt.sign({ hash }, privateKey, { algorithm: 'RS256' }); - /** combine the bundle and the token */ - const signedBundle = Buffer.concat( - [content, Buffer.from(BEGIN_CS_MARK), Buffer.from(token)], - content.length + TOKEN_BUFFER_SIZE - ); - const writeFileAsync = util.promisify( - compiler.outputFileSystem!.writeFile + compilation.hooks.processAssets.tap( + { + name: 'RepackCodeSigningPlugin', + // Sign at ANALYSE (2000) so later processAssets consumers, + // such as Zephyr at REPORT (5000), receive already-signed assets + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, + }, + () => { + for (const chunk of compilation.chunks) { + for (const file of chunk.files) { + if ( + !this.shouldSignFile(file, mainBundleName, excludedChunks) + ) { + continue; + } + + const asset = compilation.getAsset(file); + if (!asset) continue; + + logger.debug(`Signing ${file}`); + const signedBundle = this.signAsset( + asset, + privateKey, + BEGIN_CS_MARK, + TOKEN_BUFFER_SIZE + ); + + compilation.updateAsset( + file, + new sources.RawSource(signedBundle) + ); + + logger.debug(`Signed ${file}`); + } + } + } ); - await writeFileAsync(outputFilepath, signedBundle); - logger.debug(`Signed ${file}`); } ); } diff --git a/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts b/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts index f9cb007c9..bcb687fdf 100644 --- a/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts +++ b/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { rspack } from '@rspack/core'; +import { type Compiler, rspack } from '@rspack/core'; import jwt from 'jsonwebtoken'; import memfs from 'memfs'; import RspackVirtualModulePlugin from 'rspack-plugin-virtual-module'; @@ -15,7 +15,8 @@ const BUNDLE_WITH_JWT_REGEX = async function compileBundle( outputFilename: string, virtualModules: Record, - codeSigningConfig: CodeSigningPluginConfig + codeSigningConfig: CodeSigningPluginConfig, + additionalPlugins: Array<{ apply(compiler: Compiler): void }> = [] ) { const fileSystem = memfs.createFsFromVolume(new memfs.Volume()); @@ -36,6 +37,7 @@ async function compileBundle( 'package.json': '{ "type": "module" }', ...virtualModules, }), + ...additionalPlugins, ], }); @@ -81,6 +83,95 @@ describe('CodeSigningPlugin', () => { expect(chunkBundle.length).toBeGreaterThan(1280); }); + it('exposes signed chunk assets to processAssets REPORT (after ANALYSE signing)', async () => { + const seenBeforeSigning: Record = {}; + const seenAtReportStage: Record = {}; + + const captureAtReportStage = { + apply(compiler: Compiler) { + compiler.hooks.thisCompilation.tap( + 'TestReportStageCapture', + (compilation) => { + const { + PROCESS_ASSETS_STAGE_ANALYSE, + PROCESS_ASSETS_STAGE_REPORT, + } = compiler.webpack.Compilation; + + /** Immediately before CodeSigningPlugin (ANALYSE / 2000) so content is still unsigned. */ + const beforeSigningStage = PROCESS_ASSETS_STAGE_ANALYSE - 1; + + compilation.hooks.processAssets.tap( + { + name: 'TestPreAnalyseCapture', + stage: beforeSigningStage, + }, + () => { + for (const chunk of compilation.chunks) { + for (const file of chunk.files) { + const asset = compilation.getAsset(file); + if (!asset) continue; + const raw = asset.source.source(); + const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + seenBeforeSigning[file] = buf.toString(); + } + } + } + ); + + compilation.hooks.processAssets.tap( + { + name: 'TestReportStageCapture', + stage: PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + for (const chunk of compilation.chunks) { + for (const file of chunk.files) { + const asset = compilation.getAsset(file); + if (!asset) continue; + const raw = asset.source.source(); + const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + seenAtReportStage[file] = buf.toString(); + } + } + } + ); + } + ); + }, + }; + + await compileBundle( + 'index.bundle', + { + 'index.js': ` + const chunk = import(/* webpackChunkName: "myChunk" */'./myChunk.js'); + chunk.then(console.log); + `, + 'myChunk.js': ` + export default 'myChunk'; + `, + }, + { enabled: true, privateKeyPath: '__fixtures__/testRS256.pem' }, + [captureAtReportStage] + ); + + const chunkFile = 'myChunk.chunk.bundle'; + const before = seenBeforeSigning[chunkFile]; + const atReport = seenAtReportStage[chunkFile]; + + expect(before).toBeDefined(); + expect(atReport).toBeDefined(); + /** Regression guard: signing at ANALYSE must mutate assets before REPORT (not only on emit). */ + expect(before.includes('/* RCSSB */')).toBe(false); + expect(atReport.includes('/* RCSSB */')).toBe(true); + expect(atReport.length).toBeGreaterThan(before.length); + + expect(atReport.match(BUNDLE_WITH_JWT_REGEX)).toBeTruthy(); + expect( + seenAtReportStage['index.bundle']?.match(BUNDLE_WITH_JWT_REGEX) + ).toBeNull(); + }); + it('produces code-signed bundles with valid JWTs', async () => { const publicKey = fs.readFileSync( path.join(__dirname, '__fixtures__/testRS256.pem.pub')