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/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) + } +} 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)) + } +}