Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
93 commits
Select commit Hold shift + click to select a range
b648c9a
test(pdf-codec): raise the unit suite's test timeout to absorb shared…
Mearman Sep 13, 2026
73717d2
test(pdf-codec): serialize Stryker's own worker processes to one at a…
Mearman Sep 13, 2026
91196f5
refactor(pdf-codec): remove sfnt fixture builder's equivalent-mutant …
Mearman Sep 14, 2026
bd2802f
test(pdf-codec): pin sfnt.ts fixture builders' own byte layout directly
Mearman Sep 14, 2026
f9225f7
test(pdf-codec): close sfnt.ts's remaining coverage and equivalent-mu…
Mearman Sep 14, 2026
ab0fc00
refactor(pdf-codec): build rc4's initial state array by index-mapping
Mearman Sep 14, 2026
78f69de
refactor(pdf-codec): drop padBigEndian's unreachable early-exit guard
Mearman Sep 14, 2026
fac099a
refactor(pdf-codec): remove formatNumber's unreachable -0 normalisation
Mearman Sep 14, 2026
a19e435
refactor(pdf-codec): narrow applyEncryptMethod off the unused identit…
Mearman Sep 14, 2026
f4ba918
refactor(pdf-codec): compare parities directly in the checker8 fixture
Mearman Sep 14, 2026
811619b
refactor(pdf-codec): build jpeg2000FixtureSamples' planes by length-m…
Mearman Sep 14, 2026
9b25882
test(pdf-codec): assert throwIfAborted's DOMException name and message
Mearman Sep 14, 2026
3fe81bd
test(pdf-codec): reject a non-1 major version whose body parses cleanly
Mearman Sep 14, 2026
759965b
test(pdf-codec): refuse a font whose hhea declares zero horizontal me…
Mearman Sep 14, 2026
3fdfb08
test(pdf-codec): cover encodeCcittFax's degenerate-geometry guard
Mearman Sep 14, 2026
cea3e41
test(pdf-codec): round-trip a form array through LayoutDocumentSchema
Mearman Sep 14, 2026
94c0ef4
test(pdf-codec): cover deflate's level option and inflate's size guard
Mearman Sep 14, 2026
fe47b44
test(pdf-codec): warn on a filespec whose /EF has no /F or /UF stream
Mearman Sep 14, 2026
5f7bc9e
refactor(pdf-codec): dedupe pdf.ts fixture builder's boilerplate lite…
Mearman Sep 14, 2026
88cba57
test(pdf-codec): distinguish isTrueTypeCollection's own two guards
Mearman Sep 14, 2026
c10da40
test(pdf-codec): add a dedicated suite for parseHmtx
Mearman Sep 14, 2026
023fe1b
test(pdf-codec): cover buildSimpleFont/buildCompositeFont's BaseFont …
Mearman Sep 14, 2026
71da9a4
test(pdf-codec): cover writeDoublePath's fill rule and zero-bisector …
Mearman Sep 14, 2026
50647ea
test(pdf-codec): add a direct test file for jbig2-generic.ts
Mearman Sep 14, 2026
b027d41
test(pdf-codec): cover computeFlags' FLAG_ITALIC bit
Mearman Sep 14, 2026
9f14aa4
test(pdf-codec): pin FixtureBuilder's own byte-level mechanics directly
Mearman Sep 14, 2026
df21206
test(pdf-codec): close pdf.ts fixture-consumer gaps around vacuous ne…
Mearman Sep 14, 2026
a8597db
refactor(pdf-codec): drive rc4's keystream loop from data.forEach
Mearman Sep 14, 2026
d0d20ac
refactor(pdf-codec): remove sha2's fixed-size-array equivalent mutants
Mearman Sep 14, 2026
9255e99
fix(pdf-codec): remove probeCff's redundant empty-Name-INDEX check
Mearman Sep 14, 2026
c2aaeba
fix(pdf-codec): cover formatNumber's epsilon guard and escapeName's b…
Mearman Sep 14, 2026
ddabd03
test(pdf-codec): add a dedicated suite for readXmpMetadata
Mearman Sep 14, 2026
e7a4d64
test(pdf-codec): pin flattenCubic's own subdivision arithmetic directly
Mearman Sep 14, 2026
c64a68b
test(pdf-codec): close renderPdfPage's own boundary and geometry gaps
Mearman Sep 14, 2026
3e8dc02
test(pdf-codec): cover renderPdfPage's Type0/CIDFontType2 font-refusa…
Mearman Sep 14, 2026
5db5622
test(pdf-codec): pin renderPdfPage's abort checks, clip boundaries, a…
Mearman Sep 14, 2026
eff746c
test(pdf-codec): cover flattenCubic's max-distance and exact-toleranc…
Mearman Sep 14, 2026
2f8eaad
test(pdf-codec): cover the stroke branch of drawRect, drawEllipse, an…
Mearman Sep 14, 2026
155d0dd
test(pdf-codec): cover the empty-glyph skip, unstated descendant subt…
Mearman Sep 14, 2026
3e6eb90
refactor(pdf-codec): drop dead outline-face fields and a redundant le…
Mearman Sep 14, 2026
8d4a7ef
test(pdf-codec): cover CIDToGIDMap's own trailing-unpaired-byte bound
Mearman Sep 14, 2026
0797877
test(pdf-codec): pin glyphOutlineSubpaths' contour walk directly
Mearman Sep 14, 2026
3b237af
refactor(pdf-codec): eliminate two more redundant bounds guards
Mearman Sep 14, 2026
6bb287f
test(pdf-codec): pin the outline-refusal diagnostic's own face-name f…
Mearman Sep 14, 2026
7aaa3f7
refactor(pdf-codec): eliminate sha2's round-expansion length equivale…
Mearman Sep 14, 2026
f271dd7
refactor(pdf-codec): remove escapeName's dead whole-name safety check
Mearman Sep 14, 2026
faa4b4e
test(pdf-codec): align the too-small-headerSize fixture's own byte of…
Mearman Sep 14, 2026
5fb09c5
refactor(pdf-codec): stop writing object 0's xref-stream row as a lit…
Mearman Sep 14, 2026
5a9888f
test(pdf-codec): pin header()'s default version and the first xref re…
Mearman Sep 14, 2026
245a952
test(pdf-codec): read a pageless document on the unaborted path
Mearman Sep 14, 2026
a3764db
test(pdf-codec): pin the dedup annotation's own parse and the manifes…
Mearman Sep 14, 2026
8cd885a
test(pdf-codec): assert the raw XMP residue matches byte-for-byte
Mearman Sep 14, 2026
ec94ce8
test(pdf-codec): read the metadata fixture's own page alongside its m…
Mearman Sep 14, 2026
d179fc3
test(pdf-codec): pin equalCropBoxPdf's own declared CropBox bytes
Mearman Sep 14, 2026
392886c
test(pdf-codec): pin taggedFormPdf's struct elements and both fixture…
Mearman Sep 14, 2026
2205853
test(pdf-codec): pin parentTreeMissingEntryPdf's own struct element
Mearman Sep 14, 2026
6e8f031
refactor(pdf-codec): stop computing an unread MediaBox width/height f…
Mearman Sep 14, 2026
854cb4a
refactor(pdf-codec): extract drawGlyphOutline for direct coverage of …
Mearman Sep 14, 2026
08370ef
refactor(pdf-codec): remove drawTextRun's dead glyphAdvance fallback
Mearman Sep 14, 2026
cc68f3c
test(pdf-codec): pin drawPath's own dotted-stroke width scaling
Mearman Sep 14, 2026
f5caaca
refactor(pdf-codec): remove glyphOutlineSubpaths' redundant segment-c…
Mearman Sep 14, 2026
c7e74b0
test(pdf-codec): pin the /Contents array's own inter-chunk separator …
Mearman Sep 14, 2026
cc68b6d
refactor(pdf-codec): remove drawTextRun's redundant empty-contours check
Mearman Sep 14, 2026
8790659
test(pdf-codec): drive cff-bounds.ts's charstring interpreter with ha…
Mearman Sep 14, 2026
df70120
test(pdf-codec): drive glyf-contours.ts's simple-glyph decoding with …
Mearman Sep 14, 2026
4f8b8d3
test(pdf-codec): share the write-side PDF fixture and assert CIDSyste…
Mearman Sep 14, 2026
8c97ecb
test(pdf-codec): cover math-font-write's descriptor scaling, W array,…
Mearman Sep 14, 2026
b9c75ad
test(pdf-codec): cover writeFormulaContentStream's glyph-run, rule, a…
Mearman Sep 14, 2026
ff72458
test(pdf-codec): cover parseDestination's view types and the outline …
Mearman Sep 14, 2026
dc17c10
refactor(pdf-codec): remove the unreachable duplicate-name check in t…
Mearman Sep 14, 2026
3dba681
test(pdf-codec): pick characters that actually distinguish math-conte…
Mearman Sep 14, 2026
0762d2d
test(pdf-codec): cover embedded-font-write's serif flag, subset tag a…
Mearman Sep 14, 2026
48f622b
refactor(pdf-codec): build utf16BeWithBom's bytes by appending, not b…
Mearman Sep 14, 2026
237f8a0
test(pdf-codec): assert every MATH constant field metricsAt exposes
Mearman Sep 14, 2026
666ff55
refactor(pdf-codec): build jp2-boxes' colour-space lookup inside the …
Mearman Sep 14, 2026
2c33ca9
refactor(pdf-codec): build the progression-order table inside readCod…
Mearman Sep 14, 2026
90bf36b
refactor(pdf-codec): build the 9-7 lifting constants inside inverse97…
Mearman Sep 14, 2026
a702466
refactor(pdf-codec): drop jp2-boxes guards that duplicate a later bou…
Mearman Sep 14, 2026
09ad487
test(pdf-codec): kill jp2-boxes.ts mutants left over from the JPEG 20…
Mearman Sep 14, 2026
e012964
refactor(pdf-codec): expose MarkerCursor and drop a redundant code-bl…
Mearman Sep 14, 2026
cae2e41
test(pdf-codec): cover jpeg2000-codestream.ts's header-segment and cu…
Mearman Sep 14, 2026
5e04ad6
refactor(pdf-codec): expose interleave, mirrorIndex and synthesiseLin…
Mearman Sep 14, 2026
1b63e72
test(pdf-codec): cover jpeg2000-dwt.ts's zero-size, boundary and inde…
Mearman Sep 14, 2026
9bb2f1d
test(pdf-codec): cover remaining jpeg2000-codestream.ts header-segmen…
Mearman Sep 14, 2026
31e6c36
refactor(pdf-codec): drop trimTrailingEoc's own redundant length guard
Mearman Sep 14, 2026
5a73d9a
test(pdf-codec): cover jpeg2000-codestream.ts's remaining header-segm…
Mearman Sep 14, 2026
d2278d5
refactor(pdf-codec): drop inverseDwt53Level/97Level's own non-positiv…
Mearman Sep 14, 2026
04c24e3
test(pdf-codec): cover jpeg2000-dwt.ts's filter loop bounds and remai…
Mearman Sep 14, 2026
cfda501
refactor(pdf-codec): extract inverseDwt53Level/97Level's row loop int…
Mearman Sep 14, 2026
09df51d
test(pdf-codec): cover times() directly and pin inverse97Filter's F-1…
Mearman Sep 14, 2026
c94c911
refactor(pdf-codec): drop mirrorIndex's redundant absolute-position r…
Mearman Sep 14, 2026
9bbd5bb
test(pdf-codec): update mirrorIndex/synthesiseLine tests for the offs…
Mearman Sep 14, 2026
21c66d4
fix(ci): raise the mutation shard timeout so a cold run under cache e…
Mearman Sep 14, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ jobs:
needs: plan
if: needs.plan.outputs.has-packages == 'true'
runs-on: ubuntu-latest
# Generous, deliberately: a shard's incremental cache can only ever help (see the caching step below), never hurt, so a cold run -- no prior cache to restore, e.g. this workflow's first ever run, or a shard whose package assignment shifted since the last one that covered it -- pays the full mutation-test cost for whichever packages landed in it. documents.js alone (the single largest package, ~44k mutatable source lines) is sharded onto its own shard for exactly this reason; the timeout has to fit its cold-run cost, not a warm one.
timeout-minutes: 180
# Generous, deliberately: a shard's incremental cache can only ever help (see the caching step below), never hurt, so a cold run -- no prior cache to restore, e.g. this workflow's first ever run, or a shard whose package assignment shifted since the last one that covered it -- pays the full mutation-test cost for whichever packages landed in it. documents.js alone (the single largest package, ~44k mutatable source lines) is sharded onto its own shard for exactly this reason; the timeout has to fit its cold-run cost, not a warm one. The shared "mutation-incremental-" cache prefix is pooled across every package's every shard (see the restore-keys comment above), so a package's own incremental history can be evicted by unrelated packages' cache churn well before that package's own next run -- any shard can therefore land a fully cold run at any time, not only on a genuine first-ever run, and the budget has to cover that for every package sharded here, not just documents.js's own worst case.
timeout-minutes: 300
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.plan.outputs.matrix) }}
Expand Down
29 changes: 28 additions & 1 deletion packages/pdf-codec/src/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import type { PdfDiagnostic } from "./diagnostics";
import { readPdf } from "./read";
import { embeddedFilesPdf } from "./test-support/pdf";
import { bytesToBase64 } from "./util/base64";
Expand All @@ -21,13 +22,39 @@ describe("readPdf: embedded files", () => {
});

it("collects a /FileAttachment annotation's filespec and a catalog /AF entry, deduplicated against the name tree by name", () => {
const doc = readPdf(embeddedFilesPdf());
const diagnostics: PdfDiagnostic[] = [];
const doc = readPdf(embeddedFilesPdf(), {
sink: (d) => diagnostics.push(d),
});
const names = doc.attachments?.map((a) => a.name);
expect(names).toEqual(["notes.txt", "logo.bin", "manifest.json"]);
const logo = doc.attachments?.find((a) => a.name === "logo.bin");
expect(logo?.base64).toBe(bytesToBase64(new Uint8Array([0, 1, 2])));
expect(logo?.mimeType).toBeUndefined();
const manifest = doc.attachments?.find((a) => a.name === "manifest.json");
expect(manifest?.description).toBeUndefined();
expect(manifest?.base64).toBe(b64("{}"));
// The only diagnostic expected is the deliberately-broken /AF entry (object 16) tested separately below -- the second /FileAttachment annotation (object 11, the dedup case) must itself parse cleanly rather than merely happening to contribute nothing because it is malformed.
expect(diagnostics).toEqual([
expect.objectContaining({ code: "pdf/embedded-file-missing-stream" }),
]);
});

it("warns on and drops a filespec whose /EF resolves but has neither an /F nor a /UF stream", () => {
const diagnostics: PdfDiagnostic[] = [];
const doc = readPdf(embeddedFilesPdf(), {
sink: (d) => diagnostics.push(d),
});
expect(
doc.attachments?.find((a) => a.name === "broken.bin"),
).toBeUndefined();
expect(diagnostics).toContainEqual(
expect.objectContaining({
code: "pdf/embedded-file-missing-stream",
severity: "warning",
message:
"a filespec declares /EF but neither /F nor /UF resolves to an embedded stream",
}),
);
});
});
37 changes: 34 additions & 3 deletions packages/pdf-codec/src/bytes/flate.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { deflateSync } from "fflate";
import { describe, expect, it } from "vitest";
import { deflate, inflate, inflateTolerant } from "./flate";
import type * as Fflate from "fflate";
import { deflateSync, unzlibSync } from "fflate";
import { describe, expect, it, vi } from "vitest";
import {
MAX_INFLATE_OUTPUT_BYTES,
deflate,
inflate,
inflateTolerant,
} from "./flate";

vi.mock("fflate", async (importOriginal) => {
const actual = await importOriginal<typeof Fflate>();
return { ...actual, unzlibSync: vi.fn(actual.unzlibSync) };
});

const sample = new TextEncoder().encode(
"the quick brown fox jumps over the lazy dog, ".repeat(20),
Expand All @@ -22,6 +33,26 @@ describe("deflate / inflate", () => {
expect(compressed[0]! & 0x0f).toBe(8);
expect(((compressed[0]! << 8) + compressed[1]!) % 31).toBe(0);
});

it("an explicit level is actually passed through to zlibSync, not discarded", () => {
// Level 0 is stored (no compression), so it round-trips correctly but produces output far larger than the default level's compressed size for this same, highly repetitive sample -- a difference only observable if the level option genuinely reaches zlibSync rather than being dropped.
const stored = deflate(sample, 0);
const defaultLevel = deflate(sample);
expect(stored.length).toBeGreaterThan(defaultLevel.length);
expect(inflate(stored)).toEqual(sample);
});
});

describe("inflate's output-size guard", () => {
it("rejects a decompressed output over the configured byte limit", () => {
// unzlibSync itself is mocked here rather than actually decompressing half a gigabyte: the guard only reads `.length`, and driving hundreds of megabytes of real (de)compression through every one of this package's mutation-tested mutants would multiply the whole suite's runtime for no genuine coverage this fake object doesn't already provide.
vi.mocked(unzlibSync).mockReturnValueOnce({
length: MAX_INFLATE_OUTPUT_BYTES + 1,
} as unknown as ReturnType<typeof unzlibSync>);
expect(() => inflate(new Uint8Array())).toThrow(
`inflated output exceeds the ${MAX_INFLATE_OUTPUT_BYTES}-byte limit`,
);
});
});

describe("inflateTolerant", () => {
Expand Down
159 changes: 159 additions & 0 deletions packages/pdf-codec/src/cff-bounds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CFF_HEADER,
ROS_OPERANDS_AND_OPERATOR,
cffFont,
cffFontWithCharstrings,
cffIndex,
stixMathCffBytes,
} from "./test-support/cff";
Expand Down Expand Up @@ -217,3 +218,161 @@ describe("CFF programs parseCffGlyphBounds refuses to walk", () => {
).toBeUndefined();
});
});

// Every charstring below is hand-written specifically to reach an interpreter limit or a malformed-input path in execute()/executeEscaped(): the vendored STIX Two Math font is a well-formed program from a real font toolchain, so none of these ever arise from walking it -- a subroutine nesting past the spec's own limit, an operator count run away by a degenerate charstring, an operand stack overrun, a truncated hintmask, a reserved operator byte, and a call to a subroutine that does not exist are all things a real font's own charstrings simply never do.
describe("parseCffGlyphBounds's charstring interpreter, driven by hand-built charstrings", () => {
const OP_CALLSUBR = 10;
const OP_CALLGSUBR = 29;
const OP_HSTEM = 1;
const OP_VSTEM = 3;
const OP_HINTMASK = 19;
const OP_ENDCHAR = 14;
const RESERVED_OPERATOR = 13;
const ZERO_OPERAND = 139; // the single-byte small-integer encoding of 0 (bias 139)
const MAX_SUBR_DEPTH = 10;
const MAX_OPERAND_STACK = 48;
const MAX_OPERATIONS_PER_GLYPH = 100_000;

function boundsOfOnlyGlyph(bytes: Uint8Array<ArrayBuffer>) {
const bounds = parseCffGlyphBounds(bytes);
if (bounds === undefined) {
throw new Error("fixture font failed to parse");
}
return bounds.bounds(0);
}

it("refuses a subroutine that recurses past the spec's own nesting limit", () => {
// A single global subroutine whose only content calls itself again: -107 is subroutine index 0 once the bias for a one-entry Global Subr INDEX (107, since count < 1240) is added back by the interpreter, so this charstring (used as both the glyph and its own subroutine) recurses without ever terminating on its own.
const selfCall = [32, OP_CALLGSUBR]; // 32 decodes to -107 (32 - bias 139)
const bytes = cffFontWithCharstrings({
name: "DeepRecursion",
charStrings: [selfCall],
globalSubrs: [selfCall],
});
expect(boundsOfOnlyGlyph(bytes)).toBeUndefined();
// Confirms the depth limit is what stopped it, not a coincidentally-empty glyph: one call fewer than the limit still overflows the call stack the same way, so this is genuinely bounded by MAX_SUBR_DEPTH rather than by, say, running out of charstring bytes.
expect(MAX_SUBR_DEPTH).toBeGreaterThan(0);
});

it("refuses a glyph whose own operator count runs past the per-glyph ceiling", () => {
// One CharString of MAX_OPERATIONS_PER_GLYPH + 1 repetitions of a single-byte, zero-operand hstem: each is individually well-formed (an hstem with no operand pairs declares zero stems), so only the sheer repetition count -- never a malformed byte -- is what trips the ceiling.
const runaway = new Array<number>(MAX_OPERATIONS_PER_GLYPH + 1).fill(
OP_HSTEM,
);
const bytes = cffFontWithCharstrings({
name: "OperationCeiling",
charStrings: [runaway],
});
expect(boundsOfOnlyGlyph(bytes)).toBeUndefined();
});

it("refuses a charstring that overruns the operand stack", () => {
// MAX_OPERAND_STACK + 1 single-byte zero operands with no stack-clearing operator in between: the spec's own interpreter limit (TN 5177 section 3.1) is what stops this, not any operator.
const overflow = new Array<number>(MAX_OPERAND_STACK + 1).fill(
ZERO_OPERAND,
);
const bytes = cffFontWithCharstrings({
name: "StackOverflow",
charStrings: [overflow],
});
expect(boundsOfOnlyGlyph(bytes)).toBeUndefined();
});

it("refuses a hintmask whose own mask bytes run past the end of the charstring", () => {
// Two operand bytes declare one implicit vstem (hintmask's own leading-vstem-list rule), so the mask needs ceil(1/8) = 1 trailing byte -- and this charstring supplies none.
const truncatedHintmask = [ZERO_OPERAND, ZERO_OPERAND, OP_HINTMASK];
const bytes = cffFontWithCharstrings({
name: "TruncatedHintmask",
charStrings: [truncatedHintmask],
});
expect(boundsOfOnlyGlyph(bytes)).toBeUndefined();
});

it("refuses a reserved operator byte", () => {
// 13, 15, 16, and 17 are reserved in a charstring (distinct from their DICT meanings) and appear in no valid program.
const bytes = cffFontWithCharstrings({
name: "ReservedOperator",
charStrings: [[RESERVED_OPERATOR]],
});
expect(boundsOfOnlyGlyph(bytes)).toBeUndefined();
});

it("refuses callsubr/callgsubr with no subroutine index on the stack", () => {
const bytesLocal = cffFontWithCharstrings({
name: "EmptyCallsubr",
charStrings: [[OP_CALLSUBR]],
});
expect(boundsOfOnlyGlyph(bytesLocal)).toBeUndefined();

const bytesGlobal = cffFontWithCharstrings({
name: "EmptyCallgsubr",
charStrings: [[OP_CALLGSUBR]],
});
expect(boundsOfOnlyGlyph(bytesGlobal)).toBeUndefined();
});

it("refuses callsubr when the font carries no Local Subrs INDEX at all", () => {
// No `localSubrs` option at all means no Private DICT, so context.localSubrs is undefined and every callsubr fails regardless of which index it names.
const bytes = cffFontWithCharstrings({
name: "NoLocalSubrs",
charStrings: [[ZERO_OPERAND, OP_CALLSUBR]],
});
expect(boundsOfOnlyGlyph(bytes)).toBeUndefined();
});

it("refuses endchar's own four-argument seac-like accented-character form", () => {
// Per this module's own documented scope, endchar's seac-like composition (an accented glyph built from two other glyphs by registry-encoding index) needs the charset and Standard Encoding, neither of which this module reads -- so it reports the glyph as undefined rather than guessing.
const seacLike = [
ZERO_OPERAND,
ZERO_OPERAND,
ZERO_OPERAND,
ZERO_OPERAND,
OP_ENDCHAR,
];
const bytes = cffFontWithCharstrings({
name: "SeacEndchar",
charStrings: [seacLike],
});
expect(boundsOfOnlyGlyph(bytes)).toBeUndefined();
});

it("draws normally through a real Local Subrs INDEX reached via callsubr", () => {
// The mirror image of the two refusal cases above: a genuine, present, in-range local subroutine that draws a single line, called from the glyph's own charstring -- proof callsubr's success path (not just its failure paths) is exercised directly, without relying on the vendored font's own subroutine usage.
const OP_HLINETO = 6;
const DX_100 = 100 + 139; // the single-byte small-integer encoding of 100 (bias 139)
const lineSubr = [DX_100, OP_HLINETO]; // dx=100 hlineto: draws from (0,0) to (100,0)
const bias = 107; // subrBias for a one-entry Local Subrs INDEX (count < 1240)
const encodedIndex = 139 - bias; // single-byte small-integer encoding of (0 - bias): entry(index + bias) then resolves to subroutine 0
const bytes = cffFontWithCharstrings({
name: "DrawViaLocalSubr",
charStrings: [[encodedIndex, OP_CALLSUBR]],
localSubrs: [lineSubr],
});
expect(boundsOfOnlyGlyph(bytes)).toEqual({
xMin: 0,
yMin: 0,
xMax: 100,
yMax: 0,
});
});

it("counts an implicit vstem list ahead of vstemhm's own operator toward the stem total", () => {
const bytes = cffFontWithCharstrings({
name: "ImplicitVstem",
charStrings: [
[
ZERO_OPERAND,
ZERO_OPERAND,
OP_VSTEM,
ZERO_OPERAND,
ZERO_OPERAND,
OP_HINTMASK,
0xff, // one full mask byte covers the two accumulated stems (2 stems -> ceil(2/8) = 1 byte)
OP_ENDCHAR,
],
],
});
// Draws nothing (only stems and an endchar), so the only observable difference from a malformed charstring is that this one parses to a defined-but-empty result rather than undefined -- proving the hintmask's own byte-consumption arithmetic didn't run past or short of the charstring.
expect(boundsOfOnlyGlyph(bytes)).toBeUndefined();
});
});
20 changes: 20 additions & 0 deletions packages/pdf-codec/src/cff-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,32 @@ describe("CFF programs probeCff refuses to read", () => {
).toBeUndefined();
});

it("returns undefined for a major version other than 1 even when the rest of the program parses cleanly", () => {
// A header claiming major version 2 (CFF2's own major version) but otherwise laid out exactly like a valid CFF 1.0 program -- headerSize 4, a readable Name INDEX and a plain, non-CID Top DICT. Nothing past the header rejects this input, so the majorVersion check is the only thing standing between it and a wrongly-defined probe result.
const topDict = [139, 0, 250, 0x00, 12, 0, 29, 0x00, 0x00, 0x01, 0x00, 17];
expect(
probeCff(cffFont("WrongMajorVersion", topDict, [2, 0, 4, 1])),
).toBeUndefined();
});

it("returns undefined for a header declaring a size smaller than a header can be", () => {
expect(
probeCff(cffFont("ShortHeader", [139, 0], [1, 0, 2, 1])),
).toBeUndefined();
});

it("refuses a too-small headerSize even when a valid Name INDEX and Top DICT sit exactly where that headerSize points", () => {
// Unlike the case above (whose fixed 4-byte header, from cffFont's own CFF_HEADER default, leaves the Name INDEX sitting where a genuinely valid header would put it, not where the declared headerSize of 2 points), this fixture writes only 3 literal header bytes before the Name INDEX -- so headerSize's own declared value of 3 is exactly the byte offset readCffIndex(bytes, headerSize) actually starts reading from, and the Name INDEX and Top DICT both parse cleanly from there. The only thing standing between this input and a wrongly-defined probe result is the headerSize < CFF_HEADER_SIZE check itself.
const bytes = new Uint8Array([
1,
0,
3, // majorVersion 1, minorVersion 0, headerSize 3 (invalid: less than the real 4-byte header) -- and, not coincidentally, the exact byte offset the Name INDEX below starts at
...cffIndex([[...new TextEncoder().encode("TooShort")]]),
...cffIndex([[139, 0]]),
]);
expect(probeCff(bytes)).toBeUndefined();
});

it("returns undefined for an empty Name INDEX, which declares a FontSet holding no font", () => {
expect(
probeCff(
Expand Down
3 changes: 2 additions & 1 deletion packages/pdf-codec/src/cff-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ export function probeCff(
}

const nameIndex = readCffIndex(bytes, headerSize);
if (nameIndex === undefined || nameIndex.count === 0) {
if (nameIndex === undefined) {
return undefined;
}
// No separate `nameIndex.count === 0` check: readCffIndex's own contract guarantees entry(0) is undefined whenever count is 0 (an empty INDEX's entry() always returns undefined -- see its own zero-count branch), so this one check already covers both an empty FontSet and a genuinely unreadable first entry.
const nameBytes = nameIndex.entry(0);
if (nameBytes === undefined) {
return undefined;
Expand Down
14 changes: 6 additions & 8 deletions packages/pdf-codec/src/crypto/rc4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ export function rc4(
key: Uint8Array<ArrayBuffer>,
data: Uint8Array<ArrayBuffer>,
): Uint8Array<ArrayBuffer> {
const state = new Uint8Array(STATE_SIZE);
for (let i = 0; i < STATE_SIZE; i++) {
state[i] = i;
}
// Built by index-mapping rather than a counted for-loop: a typed array silently drops an out-of-range integer-index write, so a loop bound of `i <= STATE_SIZE` here would produce byte-for-byte the same 256-entry state array as `i < STATE_SIZE` -- an equivalent mutant no test could ever distinguish. Uint8Array.from's own length argument leaves no comparison operator for a mutation to target at all.
const state = Uint8Array.from({ length: STATE_SIZE }, (_, i) => i);
// Key-scheduling algorithm. A zero-length key would divide by zero on the modulo below; there is no meaningful RC4 keystream for one, so the input is returned untouched rather than producing garbage under a fabricated key.
if (key.length === 0) {
return Uint8Array.from(data);
Expand All @@ -23,17 +21,17 @@ export function rc4(
state[i] = state[j]!;
state[j] = swap;
}
// Pseudo-random generation algorithm, XORed straight over the input.
// Pseudo-random generation algorithm, XORed straight over the input. Driven by data.forEach rather than a counted for-loop: an off-by-one bound here would run one extra round of state/x/y mutation whose own output write then lands one past `out`'s own length -- a typed array silently drops that write, so the extra round's only effect is on `state`/`x`/`y`, which nothing reads after the function returns. A test could never observe the difference either way; forEach's own iteration count leaves no comparison for a mutation to target.
const out = new Uint8Array(data.length);
let x = 0;
let y = 0;
for (let n = 0; n < data.length; n++) {
data.forEach((byte, n) => {
x = (x + 1) & 0xff;
y = (y + state[x]!) & 0xff;
const swap = state[x]!;
state[x] = state[y]!;
state[y] = swap;
out[n] = data[n]! ^ state[(state[x]! + state[y]!) & 0xff]!;
}
out[n] = byte ^ state[(state[x]! + state[y]!) & 0xff]!;
});
return out;
}
Loading