468 lines
19 KiB
Odin
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)
|
|
}
|
|
}
|
|
}
|