Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/giant-dancers-sin.md
Original file line number Diff line number Diff line change
@@ -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
97 changes: 61 additions & 36 deletions packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts
Original file line number Diff line number Diff line change
@@ -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<string>;

/**
* Constructs new `RepackPlugin`.
*
Expand All @@ -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(
Expand All @@ -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;
}

Expand All @@ -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;

Expand Down Expand Up @@ -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}`);
}
);
}
Expand Down
95 changes: 93 additions & 2 deletions packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,7 +15,8 @@ const BUNDLE_WITH_JWT_REGEX =
async function compileBundle(
outputFilename: string,
virtualModules: Record<string, string>,
codeSigningConfig: CodeSigningPluginConfig
codeSigningConfig: CodeSigningPluginConfig,
additionalPlugins: Array<{ apply(compiler: Compiler): void }> = []
) {
const fileSystem = memfs.createFsFromVolume(new memfs.Volume());

Expand All @@ -36,6 +37,7 @@ async function compileBundle(
'package.json': '{ "type": "module" }',
...virtualModules,
}),
...additionalPlugins,
],
});

Expand Down Expand Up @@ -81,6 +83,95 @@ describe('CodeSigningPlugin', () => {
expect(chunkBundle.length).toBeGreaterThan(1280);
});

it('exposes signed chunk assets to processAssets REPORT (after ANALYSE signing)', async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test proves assets are signed by REPORT, which is good 👍 but what about one more test for asserting the actual delta from old behavior? it might be done by checking that content at REPORT is larger than unsigned chunk or by detecting /* RCSSB */ substring in content
wdyt?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. I extended the test so it doesn’t only assert the JWT-shaped tail at REPORT.

There is now a processAssets tap at one stage before ANALYSE (same chunk iteration as REPORT) to snapshot the asset before CodeSigningPlugin runs. Then at REPORT we assert:

the pre-sign snapshot has no /* RCSSB / marker
the REPORT snapshot includes /
RCSSB */
REPORT content is strictly longer than the pre-sign snapshot
The existing regex checks on the chunk vs main bundle are unchanged. That should document the regression we care about: anything observing assets at REPORT must see the already signed bytes, not only the final emitted file.

const seenBeforeSigning: Record<string, string> = {};
const seenAtReportStage: Record<string, string> = {};

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')
Expand Down
Loading