Skip to content
Closed
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
23 changes: 23 additions & 0 deletions scripts/test-native-filter-semantics.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,29 @@ async function testFlate() {
hasPdfError("invalid-object", /empty/)
);

// PDF 32000-1 7.3.8.1 puts an EOL marker after the stream data and excludes
// it from /Length. Producers that count it anyway leave a stray CR, LF, or
// CRLF after an otherwise complete zlib stream. The marker is not data, and
// the payload it follows decodes to exactly the same bytes.
for (const marker of [[0x0a], [0x0d], [0x0d, 0x0a]]) {
assert.deepEqual(
await decodeOne(concat(zlib, Uint8Array.from(marker)), "FlateDecode"),
decoded,
`zlib stream followed by a ${marker.length}-byte EOL marker`
);
}

// Only that marker is specified to sit there. Other trailing bytes, other
// whitespace, and more than one marker stay errors.
for (const junk of [[0x20], [0x09], [0x0a, 0x0a], [0x0a, 0x0d], [0x0d, 0x0a, 0x0a]]) {
await assert.rejects(
decodeOne(concat(zlib, Uint8Array.from(junk)), "FlateDecode"),
hasPdfError("invalid-object", /FlateDecode/),
`zlib stream followed by ${JSON.stringify(junk)}`
);
}


// 0x7820 has a valid FCHECK and advertises FDICT. PDF Flate streams cannot
// supply the external dictionary, so this is a deterministic typed failure.
await assert.rejects(
Expand Down
56 changes: 55 additions & 1 deletion src/pdf/nativeFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,46 @@ async function* decodeFlateChunks(
}
}

/**
* True when `inputs` end with a single EOL marker whose removal yields a
* stream that decodes to exactly `producedLength` bytes. Nothing else is
* trimmed: NUL, spaces, tabs and repeated markers stay decode failures.
*/
async function decodesIdenticallyWithoutEolMarker(
inputs: readonly Uint8Array[],
format: "deflate" | "deflate-raw",
limit: number,
signal: AbortSignal | undefined,
producedLength: number
): Promise<boolean> {
const trimmed = withoutTrailingEolMarker(inputs);
if (!trimmed) return false;
const state: PlatformInflateState = { length: 0 };
try {
for await (const chunk of inflatePlatformChunks(trimmed, format, limit, signal, state, false)) {
void chunk;
}
} catch {
return false;
}
return state.length === producedLength;
}

/** The trailing CR, LF, or CRLF removed, or null when there is no marker. */
function withoutTrailingEolMarker(
inputs: readonly Uint8Array[]
): readonly Uint8Array[] | null {
let lastIndex = inputs.length - 1;
while (lastIndex >= 0 && inputs[lastIndex].length === 0) lastIndex -= 1;
if (lastIndex < 0) return null;
const last = inputs[lastIndex];
let end = last.length;
if (last[end - 1] === 0x0a) end -= 1;
if (end > 0 && last[end - 1] === 0x0d) end -= 1;
if (end === last.length || end === 0) return null;
return [...inputs.slice(0, lastIndex), last.subarray(0, end)];
}

function classifyFlateWrapper(input: Uint8Array): "zlib" | "raw" {
if (input.length === 0) throw new PdfError("invalid-object", "FlateDecode stream is empty.");
const cmf = input[0];
Expand Down Expand Up @@ -303,7 +343,8 @@ async function* inflatePlatformChunks(
format: "deflate" | "deflate-raw",
limit: number,
signal: AbortSignal | undefined,
state: PlatformInflateState
state: PlatformInflateState,
recoverEolMarker = true
): AsyncIterable<Uint8Array> {
if (typeof DecompressionStream !== "function") {
throw new PdfError("unsupported-filter", "FlateDecode requires DecompressionStream support.");
Expand Down Expand Up @@ -348,6 +389,19 @@ async function* inflatePlatformChunks(
await reader.cancel().catch(() => undefined);
if (cause instanceof PdfError) throw cause;
if (signal?.aborted) throwIfAborted(signal);
// PDF 32000-1 7.3.8.1 places an EOL marker after the stream data and keeps
// it out of /Length. Producers that count it leave a stray CR, LF, or CRLF
// that platform decoders reject as trailing junk, discarding a payload that
// was already complete. Accept that marker, but only after re-decoding
// without it proves the same byte count: a truncated stream cannot be
// rescued this way, and any other trailing byte remains an error.
if (
recoverEolMarker &&
await decodesIdenticallyWithoutEolMarker(inputs, format, limit, signal, state.length)
) {
completed = true;
return;
}
throw new PdfError("invalid-object", "Malformed FlateDecode stream.", { cause });
} finally {
if (!completed) {
Expand Down