Skip to content

Commit 8db77e8

Browse files
committed
fix(attachments): make PDF screening actually work (pdf-parse v2 API + bundling) (ABCA-745)
PDF attachment screening was silently broken on EVERY channel — live-caught when the Linear P4 path fetched a PDF and got 'PDF processing is unavailable'. Two independent bugs, both platform-wide (Jira aws-samples#619 equally affected): 1. API mismatch: code called pdf-parse as the v1 callable `pdfParseFn(buf)`, but the pinned pdf-parse@^2.4.5 exposes a `PDFParse` CLASS (`new PDFParse({data}).getText()`). A stale `@types/pdf-parse@^1` + a hand- written v1 module shim (src/types/pdf-parse.d.ts) made the wrong shape compile. Rewrote extractPdfText to the v2 class API (first:N page cap, destroy() teardown); removed both v1 type sources so v2's bundled types apply. 2. Bundling: the Linear + Jira webhook processors bundled pdf-parse with esbuild (only `externalModules:['@aws-sdk/*']`), mangling its pdfjs/@napi-rs/canvas deps → 'DOMMatrix is not defined' at import. Added `nodeModules:['pdf-parse']` so it resolves natively at runtime — matching the TaskApi/orchestrator attachment-screening bundling that already had the carve-out. Verified: PDFParse.getText extracts text headless in raw node24 (= the Lambda runtime). Tests mock the v2 class (ts-jest can't drive pdfjs's ESM worker); the real extraction is validated on the live deploy. text/csv/etc. unaffected.
1 parent 53057b1 commit 8db77e8

7 files changed

Lines changed: 97 additions & 36 deletions

File tree

cdk/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@
4848
"@types/jest": "^30.0.0",
4949
"@types/js-yaml": "^4.0.9",
5050
"@types/node": "^26",
51-
"@types/pdf-parse": "^1.1.5",
5251
"@types/ws": "^8.18.1",
5352
"@typescript-eslint/eslint-plugin": "^8",
5453
"@typescript-eslint/parser": "^8",

cdk/src/constructs/jira-integration.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,15 @@ export class JiraIntegration extends Construct {
207207
const commonBundling: lambda.BundlingOptions = {
208208
externalModules: ['@aws-sdk/*'],
209209
};
210+
// pdf-parse (v2, pdfjs-based) can't be esbuild-bundled — its pdfjs/native
211+
// (@napi-rs/canvas) deps break at import (`DOMMatrix is not defined`,
212+
// ABCA-745). Ship it unbundled via `nodeModules` so it resolves natively at
213+
// runtime. Mirrors TaskApi's attachment-screening bundling. Jira's #619
214+
// attachment path screens PDFs, so its webhook processor needs the carve-out.
215+
const attachmentScreeningBundling: lambda.BundlingOptions = {
216+
...commonBundling,
217+
nodeModules: ['pdf-parse'],
218+
};
210219

211220
// --- Task creation environment (matches LinearIntegration / SlackIntegration pattern) ---
212221
const createTaskEnv: Record<string, string> = {
@@ -258,7 +267,8 @@ export class JiraIntegration extends Construct {
258267
JIRA_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName,
259268
JIRA_WORKSPACE_REGISTRY_TABLE_NAME: this.workspaceRegistryTable.tableName,
260269
},
261-
bundling: commonBundling,
270+
// Uses the PDF attachment-screening path (#619) — pdf-parse must stay unbundled.
271+
bundling: attachmentScreeningBundling,
262272
});
263273
this.projectMappingTable.grantReadData(webhookProcessorFn);
264274
this.userMappingTable.grantReadData(webhookProcessorFn);

cdk/src/constructs/linear-integration.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,15 @@ export class LinearIntegration extends Construct {
195195
const commonBundling: lambda.BundlingOptions = {
196196
externalModules: ['@aws-sdk/*'],
197197
};
198+
// pdf-parse (v2, pdfjs-based) can't be esbuild-bundled — its pdfjs/native
199+
// (@napi-rs/canvas) deps break at import (`DOMMatrix is not defined`,
200+
// ABCA-745). Ship it unbundled via `nodeModules` so it resolves natively at
201+
// runtime. Mirrors TaskApi's attachment-screening bundling (task-api.ts) and
202+
// the task-orchestrator. Used by the webhook processor's PDF attachment path.
203+
const attachmentScreeningBundling: lambda.BundlingOptions = {
204+
...commonBundling,
205+
nodeModules: ['pdf-parse'],
206+
};
198207

199208
// --- Task creation environment (matches TaskApi / SlackIntegration pattern) ---
200209
const createTaskEnv: Record<string, string> = {
@@ -262,7 +271,8 @@ export class LinearIntegration extends Construct {
262271
MAX_CONCURRENT_TASKS_PER_USER: String(props.maxConcurrentTasksPerUser ?? 10),
263272
}),
264273
},
265-
bundling: commonBundling,
274+
// Uses the PDF attachment-screening path — pdf-parse must stay unbundled.
275+
bundling: attachmentScreeningBundling,
266276
});
267277
this.projectMappingTable.grantReadData(webhookProcessorFn);
268278
this.userMappingTable.grantReadData(webhookProcessorFn);

cdk/src/handlers/shared/attachment-screening.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -249,13 +249,19 @@ const PDF_MAX_TEXT_BYTES = 1024 * 1024; // 1 MB extracted text cap
249249
const PDF_EXTRACT_TIMEOUT_MS = 15_000;
250250

251251
async function extractPdfText(content: Buffer, filename: string): Promise<string> {
252-
// Dynamic import — pdf-parse is only used for PDF attachments.
253-
254-
let pdfParseFn: (data: Buffer, options?: { max?: number }) => Promise<{ text: string }>;
252+
// pdf-parse v2 (^2.4.5) exposes a `PDFParse` CLASS — `new PDFParse({ data }).getText()` —
253+
// NOT the v1 callable default export. It runs headless in the Node Lambda runtime
254+
// (the `DOMMatrix`/`Path2D` warnings from pdfjs's optional canvas layer are benign
255+
// for text extraction). Two things made this fail before (ABCA-745): the code called
256+
// the v1 `pdfParseFn(buf)` shape (undefined for v2), AND the webhook-processor Lambdas
257+
// bundled pdf-parse with esbuild instead of shipping it via `nodeModules`, mangling its
258+
// pdfjs/native deps at import — see linear-integration/jira-integration bundling.
259+
let PDFParse;
255260
try {
256-
// pdf-parse uses a default export; handle both CJS and ESM module shapes.
257-
const mod = await import(/* webpackIgnore: true */ 'pdf-parse');
258-
pdfParseFn = (mod as any).default ?? mod;
261+
// Destructure the class from the dynamic import and let TS infer its type from
262+
// the value — a cross-mode `typeof import('pdf-parse').PDFParse` annotation trips
263+
// the ESM-vs-CJS dual-`.d.ts` hazard under moduleResolution:nodenext.
264+
({ PDFParse } = await import(/* webpackIgnore: true */ 'pdf-parse'));
259265
} catch (importErr) {
260266
logger.error('pdf-parse module could not be imported — PDF screening unavailable', {
261267
error: importErr instanceof Error ? importErr.message : String(importErr),
@@ -268,13 +274,20 @@ async function extractPdfText(content: Buffer, filename: string): Promise<string
268274
}
269275

270276
let timeoutId: ReturnType<typeof setTimeout>;
277+
// A TypedArray is preferred (pdf-parse transfers ownership to its worker, lowering
278+
// main-thread memory). Slice to the exact PDF bytes so a pooled Buffer's backing
279+
// ArrayBuffer isn't handed over wholesale.
280+
const parser = new PDFParse({ data: new Uint8Array(content.buffer, content.byteOffset, content.byteLength) });
271281
try {
272282
const timeoutPromise = new Promise<never>((_, reject) => {
273283
timeoutId = setTimeout(() => reject(new Error('PDF extraction timed out')), PDF_EXTRACT_TIMEOUT_MS);
274284
});
275285

286+
// `first: N` parses only pages 1..N (the v2 page-cap knob; the v1 `max` option
287+
// is gone). Screening the first PDF_MAX_PAGES pages is enough to catch injected
288+
// instructions without unbounded work on a huge PDF.
276289
const result = await Promise.race([
277-
pdfParseFn(content, { max: PDF_MAX_PAGES }),
290+
parser.getText({ first: PDF_MAX_PAGES }),
278291
timeoutPromise,
279292
]);
280293

@@ -291,6 +304,8 @@ async function extractPdfText(content: Buffer, filename: string): Promise<string
291304
);
292305
} finally {
293306
clearTimeout(timeoutId!);
307+
// Release the pdfjs worker/document (v2 holds native + worker handles).
308+
await parser.destroy().catch(() => { /* best-effort teardown */ });
294309
}
295310
}
296311

cdk/src/types/pdf-parse.d.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

cdk/test/handlers/shared/attachment-screening.test.ts

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@
2020
import * as fs from 'fs';
2121
import * as path from 'path';
2222
import type { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
23+
24+
// pdf-parse v2 exposes a `PDFParse` CLASS (`new PDFParse({data}).getText()`), not
25+
// the v1 callable default. Mock it at that shape so the PDF-screening tests drive
26+
// the real v2 contract (see the ABCA-745 regression tests below). pdfjs runs
27+
// headless in the nodejs24.x Lambda, but ts-jest's CJS transform can't spin its
28+
// ESM worker under jest — so unit-mock the class and prove the wiring here; the
29+
// real extraction is validated on the live deploy.
30+
const pdfGetTextMock = jest.fn();
31+
const pdfDestroyMock = jest.fn().mockResolvedValue(undefined);
32+
jest.mock('pdf-parse', () => ({
33+
__esModule: true,
34+
PDFParse: jest.fn().mockImplementation(() => ({
35+
getText: (...args: unknown[]) => pdfGetTextMock(...args),
36+
destroy: (...args: unknown[]) => pdfDestroyMock(...args),
37+
})),
38+
}));
39+
2340
import {
2441
assertImageUploadBytes,
2542
AttachmentScreeningError,
@@ -318,6 +335,19 @@ describe('screenImage', () => {
318335
});
319336

320337
describe('screenTextFile', () => {
338+
// jest config sets clearMocks:true, which wipes the PDFParse constructor's
339+
// implementation + pdfDestroyMock's resolved value before each test. Re-establish
340+
// them so `new PDFParse({data})` keeps returning the getText/destroy stubs.
341+
beforeEach(() => {
342+
(pdfDestroyMock as jest.Mock).mockResolvedValue(undefined);
343+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- re-arm the mocked ctor after clearMocks
344+
const { PDFParse } = require('pdf-parse') as { PDFParse: jest.Mock };
345+
PDFParse.mockImplementation(() => ({
346+
getText: (...args: unknown[]) => pdfGetTextMock(...args),
347+
destroy: (...args: unknown[]) => pdfDestroyMock(...args),
348+
}));
349+
});
350+
321351
test('screens plain text content', async () => {
322352
const config = {
323353
bedrockClient: mockBedrockPass(),
@@ -360,24 +390,37 @@ describe('screenTextFile', () => {
360390
}
361391
});
362392

363-
test('throws for PDF with no extractable text', async () => {
364-
// Mock pdf-parse to return empty text
365-
jest.mock('pdf-parse', () => ({
366-
__esModule: true,
367-
default: jest.fn().mockResolvedValue({ text: '' }),
368-
}), { virtual: true });
393+
// pdf-parse v2 is mocked at the PDFParse-class level (see the jest.mock at the
394+
// top of this file). The prior code called the v1 callable shape
395+
// (`pdfParseFn(buf)`), undefined on v2, so every PDF fail-closed as
396+
// "processing unavailable" (ABCA-745). These assert the v2 contract:
397+
// `new PDFParse({data}).getText()` → `{ text }`. Real end-to-end PDF extraction
398+
// is validated on the live deploy (pdfjs runs headless in nodejs24.x; ts-jest's
399+
// CJS transform can't drive its ESM worker, so a class mock is the right unit).
369400

401+
test('extracts and screens a PDF via the v2 PDFParse class (ABCA-745 regression)', async () => {
402+
pdfGetTextMock.mockResolvedValueOnce({ text: 'Design spec: add CONTRIBUTORS', total: 1, pages: [] });
370403
const config = {
371404
bedrockClient: mockBedrockPass(),
372405
guardrailId: 'test-guardrail',
373406
guardrailVersion: '1',
374407
};
408+
const result = await screenTextFile(Buffer.from('%PDF-1.4 real'), 'application/pdf', 'design.pdf', config);
409+
expect(result.screening.status).toBe('passed');
410+
// The v2 API was actually driven: constructed with { data } + getText called.
411+
expect(pdfGetTextMock).toHaveBeenCalledTimes(1);
412+
expect(pdfDestroyMock).toHaveBeenCalledTimes(1); // worker released
413+
});
375414

376-
// A minimal PDF-like buffer (pdf-parse is mocked so content doesn't matter)
377-
const content = Buffer.from('%PDF-1.4 empty');
378-
415+
test('throws for PDF with no extractable text', async () => {
416+
pdfGetTextMock.mockResolvedValueOnce({ text: '', total: 1, pages: [] });
417+
const config = {
418+
bedrockClient: mockBedrockPass(),
419+
guardrailId: 'test-guardrail',
420+
guardrailVersion: '1',
421+
};
379422
await expect(
380-
screenTextFile(content, 'application/pdf', 'empty.pdf', config),
423+
screenTextFile(Buffer.from('%PDF-1.4 empty'), 'application/pdf', 'empty.pdf', config),
381424
).rejects.toThrow(/no extractable text/);
382425
});
383426

yarn.lock

Lines changed: 0 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)