From 2385befbac43632af33834b8d428deff69a158bc Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 26 Aug 2026 16:58:20 +0200 Subject: [PATCH 1/2] tounicode: bound how many codes one map may name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bfrange names a run of codes in about twenty bytes. The width of a single run was capped and the number of runs was not, so the size of the answer had nothing to do with the size of the question: 585 bytes produced 655 360 entries in 2.3 seconds, and 10 655 bytes produced 13 107 200 entries, a gigabyte of memory and ten seconds. Every font on every page is read this way, so that was a gigabyte per font. The bound is not a guess. Across 5 338 /ToUnicode maps taken out of real documents the median names 13 codes, the 99th percentile names 538, and the largest names exactly 65 536 — one whole two-byte code space, which is as many codes as a font of that shape can have. This allows four times that, so a document has to be malformed or hostile to reach it. All 5 338 come back byte for byte identical. 585 bytes 2.3 s, 63 MB -> 47 ms, 31 MB 2 705 bytes 14.6 s, 264 MB -> 52 ms, 31 MB 10 655 bytes 10.3 s, 1054 MB -> 52 ms, 31 MB 106 055 bytes -> 48 ms, 34 MB The regression test reports 655 360 entries from 585 bytes against the parent commit, taking 229 seconds to do it, and then times the suite out at five minutes. It runs in 0.66 s here. This commit also adds the fuzz targets. There were none in this repository. Co-Authored-By: Claude Opus 5 --- fuzz_test.go | 84 +++++++++++++++++++++ tounicode.go | 28 ++++++- tounicodebound_test.go | 165 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 fuzz_test.go create mode 100644 tounicodebound_test.go diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..6487dcf --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,84 @@ +package pdffont_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-pdfkit/pdffont" +) + +// seedDir can be pointed at a directory of /ToUnicode maps taken out of real +// documents — CMAP_SEEDS — which is a far better starting population than +// anything a generator would invent. Without it the built-in seeds and the +// crashers under testdata still run. +var seedDir = os.Getenv("CMAP_SEEDS") + +func addCMapSeeds(f *testing.F, max, cap int) { + f.Add([]byte("begincmap\n1 beginbfchar\n<0041><0061>\nendbfchar\nendcmap\n")) + f.Add([]byte("begincmap\n1 beginbfrange\n<0000><00ff><0041>\nendbfrange\nendcmap\n")) + f.Add([]byte("begincmap\n1 beginbfrange\n<0000><0002>[<0041><0042><0043>]\nendbfrange\nendcmap\n")) + if seedDir == "" { + return + } + ents, err := os.ReadDir(seedDir) + if err != nil { + return + } + n := 0 + for _, e := range ents { + info, err := e.Info() + if err != nil || info.Size() > int64(cap) { + continue + } + b, err := os.ReadFile(filepath.Join(seedDir, e.Name())) + if err != nil { + continue + } + f.Add(b) + if n++; n >= max { + return + } + } +} + +// FuzzReadToUnicode drives the CMap parser, which reads a little program the +// document supplies whose ranges are numbers the document chose. +// +// The budget is the point of the target as much as the panic is: a bfrange +// names a run of codes in about twenty bytes, so the answer's size need not +// have anything to do with the question's, and a cost that grows without the +// input growing raises nothing on its own. +func FuzzReadToUnicode(f *testing.F) { + addCMapSeeds(f, 800, 32*1024) + f.Fuzz(func(t *testing.T, b []byte) { + start := time.Now() + m := pdffont.ReadToUnicode(b) + if d := time.Since(start); d > 2*time.Second { + t.Fatalf("%d bytes of CMap took %s and named %d codes", len(b), d, len(m)) + } + if len(m) > 1<<18 { + t.Fatalf("%d bytes of CMap named %d codes", len(b), len(m)) + } + }) +} + +// FuzzRuneOfGlyphName drives the other place a document's own text becomes a +// lookup: the glyph names a font gives its characters. +func FuzzRuneOfGlyphName(f *testing.F) { + for _, s := range []string{ + "A", "space", "uni0041", "u1F600", "g123", "cid42", "afii10017", + "uni", "uniZZZZ", "u", "", "a.sc", "f_f_i", "uni00410042", + } { + f.Add(s) + } + f.Fuzz(func(t *testing.T, s string) { + start := time.Now() + pdffont.RuneOfGlyphName(s) + pdffont.TrimText(s) + if d := time.Since(start); d > time.Second { + t.Fatalf("a %d-byte glyph name took %s", len(s), d) + } + }) +} diff --git a/tounicode.go b/tounicode.go index eb1f3fe..7691038 100644 --- a/tounicode.go +++ b/tounicode.go @@ -43,6 +43,24 @@ func ReadToUnicode(data []byte) map[int]string { return out } +// maxToUnicodeEntries is how many codes one map may name. +// +// A bfrange names a run of codes in about twenty bytes, and only the width of +// a single run was bounded — not how many runs a map could hold. So the size +// of the answer had nothing to do with the size of the question: 585 bytes +// produced 655 360 entries in 2.3 seconds, and 10 655 bytes produced +// 13 107 200 entries and a gigabyte. Every font on every page is read this +// way, so that was a gigabyte per font. +// +// The bound is not a guess. Across 5 338 /ToUnicode maps taken out of real +// documents — arXiv figures, government forms, and mozilla's pdf.js corpus — +// the median names 13 codes, the 99th percentile names 538, and the largest +// names exactly 65 536: one whole two-byte code space, which is as many codes +// as a font of that shape can have. This allows four times that, so a +// document has to be malformed or hostile to reach it, and a map that does is +// cut off there rather than being allowed to ask for everything. +const maxToUnicodeEntries = 1 << 18 + // A cmapToken is one piece of such a program: a hexadecimal string, an array // bracket, or a bare word. type cmapToken struct { @@ -92,6 +110,9 @@ func readBFChar(toks []cmapToken, i int, out map[int]string) int { if toks[i].word == "endbfchar" { return i } + if len(out) >= maxToUnicodeEntries { + return i + } if !toks[i].isHex || !toks[i+1].isHex { return i } @@ -115,17 +136,20 @@ func readBFRange(toks []cmapToken, i int, out map[int]string) int { if hi < lo || hi-lo > 1<<16 { return i } + if len(out) >= maxToUnicodeEntries { + return i + } switch { case toks[i+2].isHex: base := utf16Runes(toks[i+2].hex) - for c := lo; c <= hi; c++ { + for c := lo; c <= hi && len(out) < maxToUnicodeEntries; c++ { out[c] = countOn(base, c-lo) } i += 3 case toks[i+2].word == "[": j := i + 3 for c := lo; c <= hi && j < len(toks) && toks[j].word != "]"; c++ { - if toks[j].isHex { + if toks[j].isHex && len(out) < maxToUnicodeEntries { out[c] = textOf(toks[j].hex) } j++ diff --git a/tounicodebound_test.go b/tounicodebound_test.go new file mode 100644 index 0000000..bfba455 --- /dev/null +++ b/tounicodebound_test.go @@ -0,0 +1,165 @@ +package pdffont_test + +import ( + "bytes" + "fmt" + "runtime" + "testing" + "time" + + "github.com/go-pdfkit/pdffont" +) + +// manyRanges builds a CMap holding n bfrange blocks, each naming a distinct +// block of 65 536 codes. That is about twenty bytes of input for sixty-five +// thousand entries of output, which is the whole of the defect: the width of +// one run was bounded and the number of runs was not, so the size of the +// answer had nothing to do with the size of the question. +func manyRanges(n int) []byte { + var b bytes.Buffer + b.WriteString("/CIDInit /ProcSet findresource begin\nbegincmap\n") + for i := 0; i < n; i++ { + b.WriteString("1 beginbfrange\n") + fmt.Fprintf(&b, "<%04x0000><%04xffff><0041>\n", i, i) + b.WriteString("endbfrange\n") + } + b.WriteString("endcmap\n") + return b.Bytes() +} + +// TestToUnicodeIsBoundedByItsOwnSize is the regression. Before the bound, +// 10 655 bytes of CMap produced 13 107 200 entries, a gigabyte of memory and +// ten seconds; every font on every page is read this way, so that was a +// gigabyte per font. +// +// What is asserted is that the cost stops growing: sixteen times the input +// must not buy sixteen times the entries. +func TestToUnicodeIsBoundedByItsOwnSize(t *testing.T) { + const limit = 1 << 18 + small := pdffont.ReadToUnicode(manyRanges(10)) + large := pdffont.ReadToUnicode(manyRanges(2000)) + if len(small) > limit { + t.Fatalf("585 bytes named %d codes, over the %d bound", len(small), limit) + } + if len(large) > limit { + t.Fatalf("106 055 bytes named %d codes, over the %d bound", len(large), limit) + } + if len(large) != len(small) { + t.Fatalf("two hundred times the input gave %d codes against %d: the bound is not holding", + len(large), len(small)) + } +} + +// TestToUnicodeCostIsBounded checks the other half: time and memory, measured +// rather than assumed. The generous limits are there so the test says +// something on a loaded machine and still fails loudly on an unbounded one, +// which used to spend ten seconds and a gigabyte here. +func TestToUnicodeCostIsBounded(t *testing.T) { + data := manyRanges(2000) + var m0, m1 runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&m0) + start := time.Now() + got := pdffont.ReadToUnicode(data) + elapsed := time.Since(start) + runtime.ReadMemStats(&m1) + allocated := float64(m1.TotalAlloc-m0.TotalAlloc) / (1 << 20) + + if elapsed > 5*time.Second { + t.Errorf("%d bytes of CMap took %s", len(data), elapsed) + } + if allocated > 250 { + t.Errorf("%d bytes of CMap allocated %.0f MB", len(data), allocated) + } + if len(got) == 0 { + t.Fatal("the map came back empty; the bound should cut it off, not throw it away") + } +} + +// TestToUnicodeStillReadsWhatItShould checks the bound did not break the maps +// anybody actually has. The largest of 5 338 /ToUnicode maps taken out of real +// documents names 65 536 codes — one whole two-byte code space — so a map of +// that size has to come back whole. +func TestToUnicodeStillReadsWhatItShould(t *testing.T) { + full := []byte("/CIDInit /ProcSet findresource begin\nbegincmap\n" + + "1 beginbfrange\n<0000><0041>\nendbfrange\nendcmap\n") + m := pdffont.ReadToUnicode(full) + if len(m) != 65536 { + t.Fatalf("a full two-byte code space came back with %d codes, want 65536", len(m)) + } + if m[0] != "A" { + t.Errorf("code 0 stands for %q, want %q", m[0], "A") + } + if m[1] != "B" { + t.Errorf("code 1 stands for %q, want %q — a range counts on from its first character", m[1], "B") + } + + // And the ordinary small map, which is what nearly every document has: + // the median of those 5 338 names thirteen codes. + small := pdffont.ReadToUnicode([]byte("begincmap\n2 beginbfchar\n<0041><0061>\n<0042><0062>\nendbfchar\nendcmap\n")) + if len(small) != 2 { + t.Fatalf("a two-entry bfchar block gave %d codes", len(small)) + } + if small[0x41] != "a" || small[0x42] != "b" { + t.Errorf("bfchar gave %q and %q", small[0x41], small[0x42]) + } +} + +// TestToUnicodeBoundHoldsWithinOneBlock covers the other shape the same +// document can take: not many blocks of one range each, but one block naming +// many. The bound has to hold inside a block as well as between them, and a +// block that follows a map already full must stop rather than start. +func TestToUnicodeBoundHoldsWithinOneBlock(t *testing.T) { + var b bytes.Buffer + b.WriteString("/CIDInit /ProcSet findresource begin\nbegincmap\n") + b.WriteString("400 beginbfrange\n") + for i := 0; i < 400; i++ { + fmt.Fprintf(&b, "<%04x0000><%04xffff><0041>\n", i, i) + } + b.WriteString("endbfrange\n") + // A second block, entered with the map already full: it must add nothing + // rather than run to the end of its own list. + b.WriteString("2 beginbfchar\n<0041><0061>\n<0042><0062>\nendbfchar\n") + b.WriteString("1 beginbfrange\n<0041>\nendbfrange\n") + b.WriteString("endcmap\n") + + start := time.Now() + m := pdffont.ReadToUnicode(b.Bytes()) + elapsed := time.Since(start) + if len(m) > 1<<18 { + t.Fatalf("one block of 400 ranges named %d codes, over the bound", len(m)) + } + if elapsed > 5*time.Second { + t.Fatalf("one block of 400 ranges took %s", elapsed) + } + // The blocks after the map filled must have been refused outright, so the + // codes they name are not there. + if _, ok := m[0xffff0000]; ok { + t.Error("a range block entered with a full map added to it anyway") + } +} + +// TestToUnicodeBFCharBlockAfterFull covers the char block's own guard with +// nothing else in the way: a bfchar block whose pairs begin after the bound +// has already been reached. +func TestToUnicodeBFCharBlockAfterFull(t *testing.T) { + var b bytes.Buffer + b.WriteString("begincmap\n400 beginbfrange\n") + for i := 0; i < 400; i++ { + fmt.Fprintf(&b, "<%04x0000><%04xffff><0041>\n", i, i) + } + // Codes far outside every range above, so that finding one in the answer + // can only mean the char block was read. + b.WriteString("endbfrange\n4 beginbfchar\n") + for i := 0; i < 4; i++ { + fmt.Fprintf(&b, "<7fff00%02x><0061>\n", i) + } + b.WriteString("endbfchar\nendcmap\n") + m := pdffont.ReadToUnicode(b.Bytes()) + if _, ok := m[0x7fff0000]; ok { + t.Error("a char block entered with a full map added to it anyway") + } + if len(m) > 1<<18 { + t.Fatalf("named %d codes, over the bound", len(m)) + } +} From cacef9125910008c5dfca175f832a4b83a853646 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 26 Aug 2026 18:49:02 +0200 Subject: [PATCH 2/2] deps: take the reader that does not walk to two thousand million reader v0.4.1 stopped answering "which objects call themselves a catalogue?" by counting from zero to the largest object number a file names. This package was still asking for v0.4.0, so it still handed its callers the defect. bug1980958.pdf in mozilla's pdf.js corpus is 219 bytes. It has no trailer and no startxref, so it can only be read by repairing it, and the last object it declares is numbered 2 147 483 647. Two thousand million map lookups for four objects: reader.Open on 219 bytes 21.2 s, 0 MB allocated -> under 1 ms Not one byte allocated, which is why no memory limit anywhere caught it. The test builds the file rather than committing somebody else's, and guards the dependency rather than this package's own code. It fails in 15.1 s against the parent commit. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 +-- stalereader_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 stalereader_test.go diff --git a/go.mod b/go.mod index 958b950..acb012d 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/go-pdfkit/pdffont go 1.26.4 -require github.com/go-pdfkit/reader v0.4.0 +require github.com/go-pdfkit/reader v0.4.1 diff --git a/go.sum b/go.sum index 3ff081a..e65851b 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/go-pdfkit/reader v0.4.0 h1:qPbNZSO+Xl+4NBvQoV1PYt7HlqHaEAQ1tUc6/IL8JAU= -github.com/go-pdfkit/reader v0.4.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= +github.com/go-pdfkit/reader v0.4.1 h1:pRxFqRjsn7H/VsGfWb9nYWyFuDgTU2Pjmoq/f5mgVq4= +github.com/go-pdfkit/reader v0.4.1/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= diff --git a/stalereader_test.go b/stalereader_test.go new file mode 100644 index 0000000..51349f7 --- /dev/null +++ b/stalereader_test.go @@ -0,0 +1,63 @@ +package pdffont_test + +import ( + "testing" + "time" + + "github.com/go-pdfkit/pdffont" + "github.com/go-pdfkit/reader" +) + +// hugeObjectNumber is a whole PDF in 219 bytes. It has no trailer and no +// startxref, so it can only be read by repairing it, and the last object it +// declares is numbered 2 147 483 647. +// +// reader v0.4.0 answered "which objects call themselves a catalogue?" by +// counting from zero to the largest object number the file mentioned, one map +// lookup each. For this file that is two thousand million lookups for four +// objects: twenty-one seconds, and not one byte allocated, which is why no +// memory limit anywhere caught it. +const hugeObjectNumber = "%PDF-1.7\n" + + "1 0 obj <>\nendobj\n" + + "2 0 obj <>\nendobj\n" + + "3 0 obj <>\nendobj\n\n" + + "2147483647 0 obj <>\nendobj\n" + +// TestATinyFileWithAHugeObjectNumber guards the version of the reader this +// package is built against, not this package's own code. +// +// The defect was fixed in reader v0.4.1, and merging is not shipping: a +// consumer still asking for v0.4.0 still hands its callers a file that takes +// twenty-one seconds to open. The budget is two seconds because the answer is +// either a fraction of a millisecond or twenty-one seconds, and nothing in +// between; there is no threshold here to tune. +func TestATinyFileWithAHugeObjectNumber(t *testing.T) { + b := []byte(hugeObjectNumber) + start := time.Now() + d, err := reader.Open(b) + opened := time.Since(start) + if err != nil { + t.Fatalf("opening %d bytes: %v", len(b), err) + } + if opened > 2*time.Second { + t.Fatalf("opening %d bytes took %s: the reader this is built against walks every object "+ + "number up to the largest one named, so it is older than v0.4.1", len(b), opened) + } + // And through this package's own entry point, since that is what callers + // reach it by: a font dictionary is read out of a document. + page, err := d.Page(1) + if err != nil { + t.Fatalf("page 1: %v", err) + } + resources, _ := d.GetDict(page, "Resources") + fonts, _ := d.GetDict(resources, "Font") + start = time.Now() + for k := range fonts { + if fd, ok := d.GetDict(fonts, k); ok { + pdffont.Read(d, fd) + } + } + if read := time.Since(start); read > 2*time.Second { + t.Fatalf("reading the fonts of a %d-byte file took %s", len(b), read) + } +}