Files
pdf-odin-llm/golden_test.odin
2026-09-25 23:32:29 +02:00

468 lines
19 KiB
Odin

package pdf
import "core:os"
import "core:strings"
import "core:testing"
import "core:time"
// ---------------------------------------------------------------------------
// Golden files
//
// The rest of the suite builds its PDFs in memory with the pb_* helpers, which
// is the right way to test a structure: the builder records the offsets, so a
// test never asserts a hand-counted number. But a builder is part of the same
// codebase as the reader, and three things it therefore cannot show:
//
// 1. that `open_file` works on an actual file, not just on a byte slice;
// 2. that byte offsets some *other* tool wrote are the ones we parse;
// 3. that a refactor did not quietly change what the library accepts by
// changing the builder at the same time.
//
// So testdata/ holds fourteen committed files, one per structural case, and
// this table pins down exactly what each of them must produce -- including the
// exact set of repairs, so that a file which starts opening "more cleanly"
// than it should fails here rather than passing silently.
//
// testdata/README.md says how each file was made.
// ---------------------------------------------------------------------------
@(private = "file")
GOLDEN_DIR :: #directory + "testdata/"
@(private = "file")
Golden :: struct {
name: string,
// Flattened page count.
pages: int,
// First page's /MediaBox, after inheritance.
width: f64,
height: f64,
// First page's /Rotate, normalised.
rotate: int,
// What the first page's decoded content stream must begin with. Every
// golden page draws the same filled rectangle and stroked diagonal behind
// a comment naming the file, so this doubles as "the stream decoded".
marker: string,
// The repair kinds this file must record -- exactly, no more and no less,
// once every page's content has been read.
repairs: bit_set[Repair_Kind],
// encryption_method_name, or "" for a file that is not encrypted.
method: string,
// The /Info /Title, which in the encrypted files is a string that only
// decrypts correctly when the file key is right.
title: string,
}
@(private = "file")
GOLDEN := []Golden{
// -- structure --------------------------------------------------------
{"classic-xref.pdf", 1, 200, 200, 0, "% classic-xref",
{}, "", ""},
{"xref-stream.pdf", 1, 200, 200, 0, "% xref-stream",
{}, "", ""},
{"object-stream.pdf", 1, 200, 200, 0, "% object-stream",
{}, "", ""},
// The second generation replaces the page, so 300x300 -- not the 200x200
// the first generation wrote -- is what proves the update was applied.
{"incremental-update.pdf", 1, 300, 300, 0, "% incremental-update generation 2",
{}, "", ""},
{"junk-before-header.pdf", 1, 200, 200, 0, "% junk-before-header",
{}, "", ""},
// -- damage that must be repaired, not rejected ------------------------
{"broken-xref.pdf", 1, 200, 200, 0, "% broken-xref",
{.Bad_Startxref, .Rebuilt_Xref}, "", ""},
{"bad-length.pdf", 1, 200, 200, 0, "% bad-length",
{.Bad_Stream_Length}, "", ""},
{"no-xref.pdf", 1, 200, 200, 0, "% no-xref",
{.Bad_Startxref, .Rebuilt_Xref, .Missing_Root}, "", ""},
{"page-tree-broken.pdf", 1, 200, 200, 0, "% page-tree-broken",
{.Cyclic_Page_Tree}, "", ""},
// -- encryption, every one with an empty user password -----------------
{"encrypted-rc4-40.pdf", 1, 200, 200, 0, "% encrypted-rc4-40",
{}, "RC4 40-bit", "Golden File"},
{"encrypted-rc4-128.pdf", 1, 200, 200, 0, "% encrypted-rc4-128",
{}, "RC4 128-bit", "Golden File"},
{"encrypted-aes128.pdf", 1, 200, 200, 0, "% encrypted-aes128",
{}, "AES-128", "Golden File"},
{"encrypted-aes256.pdf", 1, 200, 200, 0, "% encrypted-aes256",
{}, "AES-256", "Golden File"},
// -- page tree ---------------------------------------------------------
// /MediaBox and /Rotate come from the root, three levels up.
{"page-tree-deep.pdf", 2, 300, 400, 90, "% page-tree-deep page 1",
{}, "", ""},
}
// The password every encrypted golden file restricts itself with. The user
// password is empty, which is what "opens without prompting" means.
@(private = "file")
GOLDEN_OWNER_PW :: "owner"
@(private = "file")
golden_path :: proc(name: string) -> string {
return strings.concatenate({GOLDEN_DIR, name}, context.temp_allocator)
}
// golden_repairs collects the kinds recorded so far, ignoring how many times
// each occurred: a repair that fires twice is the same fact as one that fires
// once, and the count is an implementation detail this table should not pin.
@(private = "file")
golden_repairs :: proc(doc: ^Document) -> (out: bit_set[Repair_Kind]) {
if doc == nil { return }
for r in doc.repairs {
out += {r.kind}
}
return
}
// golden_content decodes one page's content stream.
@(private = "file")
golden_content :: proc(doc: ^Document, page: Page) -> (string, Error) {
s := dict_stream(doc, page.dict, "Contents")
data, err := stream_data(doc, s)
return string(data), err
}
// ---------------------------------------------------------------------------
// The table
// ---------------------------------------------------------------------------
@(test)
test_golden_files :: proc(t: ^testing.T) {
defer free_all(context.temp_allocator)
for g in GOLDEN {
path := golden_path(g.name)
doc, err := open_file(path)
defer close(doc)
if !testing.expectf(t, err == .None, "%s: open_file failed: %s", g.name, error_string(err)) {
continue
}
if !testing.expectf(t, doc != nil, "%s: no document", g.name) {
continue
}
testing.expectf(t, page_count(doc) == g.pages,
"%s: %d pages, wanted %d", g.name, page_count(doc), g.pages)
// Every page's content must decode, not just the first: a file whose
// second page is unreadable is not a file this suite should pass.
for i in 0 ..< page_count(doc) {
page, perr := page_get(doc, i)
if !testing.expectf(t, perr == .None, "%s: page %d: %s", g.name, i, error_string(perr)) {
continue
}
body, cerr := golden_content(doc, page)
testing.expectf(t, cerr == .None,
"%s: page %d content: %s", g.name, i, error_string(cerr))
testing.expectf(t, len(body) > 0, "%s: page %d content is empty", g.name, i)
testing.expectf(t, strings.contains(body, "160 100 re"),
"%s: page %d content does not draw the rectangle", g.name, i)
if i != 0 { continue }
testing.expectf(t, rect_width(page.media_box) == g.width &&
rect_height(page.media_box) == g.height,
"%s: page 0 is %vx%v, wanted %vx%v", g.name,
rect_width(page.media_box), rect_height(page.media_box), g.width, g.height)
testing.expectf(t, page.rotate == g.rotate,
"%s: page 0 /Rotate %d, wanted %d", g.name, page.rotate, g.rotate)
testing.expectf(t, strings.has_prefix(body, g.marker),
"%s: page 0 content starts %q, wanted %q",
g.name, body[:min(len(body), len(g.marker))], g.marker)
}
// Checked after the content streams have been read, because a wrong
// /Length is only discovered when the stream itself is parsed.
got := golden_repairs(doc)
testing.expectf(t, got == g.repairs,
"%s: repairs %v, wanted %v", g.name, got, g.repairs)
testing.expectf(t, doc.is_damaged == (g.repairs != {}),
"%s: is_damaged %v with repairs %v", g.name, doc.is_damaged, got)
method := document_is_encrypted(doc) ? encryption_method_name(doc) : ""
testing.expectf(t, method == g.method,
"%s: encryption %q, wanted %q", g.name, method, g.method)
info := dict_dict(doc, doc.trailer, "Info")
title := string(dict_bytes(doc, info, "Title"))
testing.expectf(t, title == g.title,
"%s: /Info /Title %q, wanted %q", g.name, title, g.title)
}
}
// open_bytes and open_file must agree. They share everything below the read,
// but only this says so.
@(test)
test_golden_open_bytes_agrees :: proc(t: ^testing.T) {
defer free_all(context.temp_allocator)
for g in GOLDEN {
data, read_err := os.read_entire_file(golden_path(g.name), context.allocator)
if !testing.expectf(t, read_err == nil, "%s: cannot read: %v", g.name, read_err) { continue }
defer delete(data)
doc, err := open_bytes(data)
defer close(doc)
if !testing.expectf(t, err == .None, "%s: open_bytes failed: %s", g.name, error_string(err)) {
continue
}
testing.expectf(t, page_count(doc) == g.pages,
"%s: open_bytes gave %d pages, wanted %d", g.name, page_count(doc), g.pages)
page, perr := page_get(doc, 0)
testing.expectf(t, perr == .None, "%s: page 0: %s", g.name, error_string(perr))
testing.expectf(t, rect_width(page.media_box) == g.width,
"%s: open_bytes gave width %v, wanted %v", g.name, rect_width(page.media_box), g.width)
}
}
// ---------------------------------------------------------------------------
// Cases the table cannot express in one line
// ---------------------------------------------------------------------------
// page-tree-deep.pdf has its /MediaBox and /Rotate on the root, three /Pages
// levels above the leaves, and its second page overrides /Rotate alone. That
// the override does not also drop the inherited box is the whole point of the
// file.
@(test)
test_golden_page_tree_inheritance :: proc(t: ^testing.T) {
defer free_all(context.temp_allocator)
doc, err := open_file(golden_path("page-tree-deep.pdf"))
defer close(doc)
if !testing.expect(t, err == .None, "open failed") { return }
p0, e0 := page_get(doc, 0)
testing.expect(t, e0 == .None, "page 0 missing")
testing.expectf(t, p0.rotate == 90, "page 0 /Rotate %d, wanted the inherited 90", p0.rotate)
p1, e1 := page_get(doc, 1)
if !testing.expect(t, e1 == .None, "page 1 missing") { return }
testing.expectf(t, p1.rotate == 180, "page 1 /Rotate %d, wanted its own 180", p1.rotate)
testing.expectf(t, rect_width(p1.media_box) == 300 && rect_height(p1.media_box) == 400,
"page 1 is %vx%v; overriding /Rotate must not drop the inherited /MediaBox",
rect_width(p1.media_box), rect_height(p1.media_box))
// Inherited two levels down from the node that declares it.
testing.expect(t, p0.resources != nil, "page 0 did not inherit /Resources")
testing.expect(t, p1.resources != nil, "page 1 did not inherit /Resources")
}
// Each encrypted file also has an owner password, so the owner branch of the
// key derivation is exercised by a real file and not only by a unit vector.
@(test)
test_golden_owner_password :: proc(t: ^testing.T) {
defer free_all(context.temp_allocator)
for g in GOLDEN {
if g.method == "" { continue }
doc, err := open_file(golden_path(g.name), Open_Options{password = GOLDEN_OWNER_PW})
defer close(doc)
if !testing.expectf(t, err == .None, "%s: owner password rejected: %s",
g.name, error_string(err)) {
continue
}
testing.expectf(t, doc.encrypt != nil && doc.encrypt.authenticated_as == .Owner,
"%s: authenticated as %v, wanted Owner", g.name,
doc.encrypt != nil ? doc.encrypt.authenticated_as : Password_Kind.None)
// The owner is not subject to the restrictions they set.
all := Permissions{.Print, .Modify, .Copy, .Annotate, .Fill_Forms, .Extract, .Assemble, .Print_High_Res}
testing.expectf(t, encryption_permissions(doc) == all,
"%s: owner permissions %v, wanted everything", g.name, encryption_permissions(doc))
page, perr := page_get(doc, 0)
testing.expectf(t, perr == .None, "%s: page 0: %s", g.name, error_string(perr))
body, cerr := golden_content(doc, page)
testing.expectf(t, cerr == .None && strings.has_prefix(body, g.marker),
"%s: content did not decrypt under the owner password", g.name)
}
}
// The files restrict what a user may do, so /P must survive the round trip.
// -44 clears the modify and annotate bits and leaves the rest set; revision 2
// only ever defined four of them, which is why it reports two.
@(test)
test_golden_user_permissions :: proc(t: ^testing.T) {
defer free_all(context.temp_allocator)
Case :: struct {
name: string,
want: Permissions,
}
r3_up := Permissions{.Print, .Copy, .Fill_Forms, .Extract, .Assemble, .Print_High_Res}
cases := []Case{
{"encrypted-rc4-40.pdf", {.Print, .Copy}},
{"encrypted-rc4-128.pdf", r3_up},
{"encrypted-aes128.pdf", r3_up},
{"encrypted-aes256.pdf", r3_up},
}
for c in cases {
doc, err := open_file(golden_path(c.name))
defer close(doc)
if !testing.expectf(t, err == .None, "%s: %s", c.name, error_string(err)) { continue }
testing.expectf(t, encryption_permissions(doc) == c.want,
"%s: permissions %v, wanted %v", c.name, encryption_permissions(doc), c.want)
testing.expectf(t, doc.encrypt != nil && doc.encrypt.authenticated_as == .User,
"%s: the empty password must authenticate as the user", c.name)
}
}
// The table and the directory must not drift apart. Without this, a golden file
// added to testdata/ and forgotten in GOLDEN would be tested by nothing at all,
// and a row whose file was never committed would be a silent skip.
@(test)
test_golden_directory_matches_table :: proc(t: ^testing.T) {
defer free_all(context.temp_allocator)
entries, derr := os.read_directory_by_path(GOLDEN_DIR, -1, context.allocator)
if !testing.expectf(t, derr == nil, "cannot list %s: %v", GOLDEN_DIR, derr) { return }
defer os.file_info_slice_delete(entries, context.allocator)
for e in entries {
if e.type != .Regular || !strings.has_suffix(e.name, ".pdf") { continue }
found := false
for g in GOLDEN {
if g.name == e.name { found = true; break }
}
testing.expectf(t, found, "testdata/%s is not in the GOLDEN table", e.name)
}
for g in GOLDEN {
present := false
for e in entries {
if e.name == g.name { present = true; break }
}
testing.expectf(t, present, "GOLDEN lists %s but testdata/ does not have it", g.name)
}
}
// A password that is neither the user's nor the owner's is refused rather than
// silently producing garbage.
@(test)
test_golden_wrong_password :: proc(t: ^testing.T) {
defer free_all(context.temp_allocator)
for g in GOLDEN {
if g.method == "" { continue }
doc, err := open_file(golden_path(g.name), Open_Options{password = "not the password"})
defer close(doc)
testing.expectf(t, err == .Wrong_Password,
"%s: opened with a wrong password: %s", g.name, error_string(err))
testing.expectf(t, doc == nil, "%s: a failed open must not return a document", g.name)
}
}
// ---------------------------------------------------------------------------
// Fuzz-shaped: truncations and bit flips
//
// Not a fuzzer -- there is no feedback and no corpus growth -- but the same
// shape, and enough to catch the class of bug that matters here: an index
// derived from a file byte that is no longer in range once that byte changes.
// The mutations are driven by a fixed seed so that a failure is reproducible;
// a clock-seeded run that fails once and never again is worse than no test.
// ---------------------------------------------------------------------------
@(private = "file")
GOLDEN_FUZZ_TRUNCATIONS :: 40
@(private = "file")
GOLDEN_FUZZ_FLIPS :: 200
@(private = "file")
GOLDEN_FUZZ_SEED :: u64(0x9E3779B97F4A7C15)
// A single mutated file that takes this long has found a loop bound that is
// too generous. These are all under 1.5 KB; nothing legitimate comes close.
@(private = "file")
GOLDEN_FUZZ_SLOW_MS :: 5000
@(private = "file")
golden_rand :: proc(state: ^u64) -> u64 {
state^ += 0x9E37_79B9_7F4A_7C15
z := state^
z = (z ~ (z >> 30)) * 0xBF58_476D_1CE4_E5B9
z = (z ~ (z >> 27)) * 0x94D0_49BB_1331_11EB
return z ~ (z >> 31)
}
// golden_probe opens one mutated file and drives it as far as a caller would.
// The assertion is only that this returns at all, with either a Document or an
// Error and never both: what a damaged file *means* is not defined, but
// crashing, hanging and handing back a half-built Document are all excluded.
@(private = "file")
golden_probe :: proc(t: ^testing.T, data: []byte, what: string) {
start := time.now()
doc, err := open_bytes(data)
defer close(doc)
if err == .None {
testing.expectf(t, doc != nil, "%s: no error but no document", what)
if doc != nil {
for i in 0 ..< page_count(doc) {
page, perr := page_get(doc, i)
if perr != .None { continue }
// Reading the stream is where a bad /Length or a corrupt Flate
// payload would be dereferenced, so it belongs inside the probe.
s := dict_stream(doc, page.dict, "Contents")
_, _ = stream_data(doc, s)
}
}
} else {
testing.expectf(t, doc == nil, "%s: error %s but a document too", what, error_string(err))
}
ms := time.duration_milliseconds(time.since(start))
testing.expectf(t, ms < GOLDEN_FUZZ_SLOW_MS, "%s: took %v ms", what, ms)
}
@(test)
test_golden_fuzz_truncated :: proc(t: ^testing.T) {
defer free_all(context.temp_allocator)
for g in GOLDEN {
data, read_err := os.read_entire_file(golden_path(g.name), context.allocator)
if !testing.expectf(t, read_err == nil, "%s: cannot read: %v", g.name, read_err) { continue }
defer delete(data)
// Evenly spaced, and never the whole file: 0 bytes and every prefix in
// between, which is what a download that stopped early looks like.
for i in 0 ..< GOLDEN_FUZZ_TRUNCATIONS {
n := len(data) * i / GOLDEN_FUZZ_TRUNCATIONS
golden_probe(t, data[:n], g.name)
}
}
}
@(test)
test_golden_fuzz_bit_flips :: proc(t: ^testing.T) {
defer free_all(context.temp_allocator)
for g, index in GOLDEN {
data, read_err := os.read_entire_file(golden_path(g.name), context.allocator)
if !testing.expectf(t, read_err == nil, "%s: cannot read: %v", g.name, read_err) { continue }
defer delete(data)
if len(data) == 0 { continue }
scratch := make([]byte, len(data))
defer delete(scratch)
// Seeded from the file's position in the table, so every file gets a
// different sequence and every run gets the same one.
state := GOLDEN_FUZZ_SEED + u64(index) * 0x0100_0193
for _ in 0 ..< GOLDEN_FUZZ_FLIPS {
copy(scratch, data)
off := int(golden_rand(&state) % u64(len(scratch)))
bit := u8(golden_rand(&state) & 7)
scratch[off] ~= 1 << bit
golden_probe(t, scratch, g.name)
}
}
}