Files
pdf-odin-llm/parser_test.odin
T

896 lines
35 KiB
Odin
Raw Normal View History

2026-09-25 23:32:29 +02:00
package pdf
import "core:mem"
import "core:mem/virtual"
import "core:slice"
import "core:strings"
import "core:testing"
@(private = "file")
scratch_begin :: proc(t: ^testing.T, a: ^virtual.Arena) -> mem.Allocator {
err := virtual.arena_init_growing(a)
testing.expect(t, err == nil, "arena init failed")
return virtual.arena_allocator(a)
}
@(private = "file")
bytes_of :: proc(s: string) -> []byte {
return transmute([]byte)s
}
// test_doc builds a Document by hand. `open_bytes` cannot be used here: it
// needs an xref loader, and the point of these tests is the parser on its own.
// Nothing calls `close`, so the Document's own arena is never initialised --
// everything lives in the caller's scratch arena instead.
@(private = "file")
test_doc :: proc(src: string, alloc: mem.Allocator) -> ^Document {
doc := new(Document, alloc)
doc.data = bytes_of(src)
doc.allocator = alloc
doc.backing = alloc
doc.xref = make([dynamic]Xref_Entry, alloc)
doc.cache = make(map[u32]Object, 16, alloc)
doc.loading = make(map[u32]bool, 8, alloc)
doc.objstm_cache = make(map[u32]^Objstm, 4, alloc)
doc.pages = make([dynamic]^Dict, alloc)
doc.page_refs = make([dynamic]Ref, alloc)
doc.repairs = make([dynamic]Repair, alloc)
return doc
}
// test_xref_at registers object `num` as living at the offset of `header` in
// the file, so `get_object` can find it.
@(private = "file")
test_xref_at :: proc(doc: ^Document, num: u32, header: string) {
for len(doc.xref) <= int(num) {
append(&doc.xref, Xref_Entry{})
}
doc.xref[num] = Xref_Entry{
offset = i64(strings.index(string(doc.data), header)),
kind = .Normal,
}
}
// Object holds a slice (String) and pointers, so it is not comparable with
// '=='. Direct objects are compared by value here; containers by identity.
@(private = "file")
object_same :: proc(a, b: Object) -> bool {
sa, a_is_str := a.(String)
sb, b_is_str := b.(String)
if a_is_str || b_is_str {
return a_is_str && b_is_str && slice.equal(transmute([]byte)sa, transmute([]byte)sb)
}
switch va in a {
case bool: vb, ok := b.(bool); return ok && va == vb
case i64: vb, ok := b.(i64); return ok && va == vb
case f64: vb, ok := b.(f64); return ok && va == vb
case Name: vb, ok := b.(Name); return ok && va == vb
case Ref: vb, ok := b.(Ref); return ok && va == vb
case String: return false
case ^Array: vb, ok := b.(^Array); return ok && va == vb
case ^Dict: vb, ok := b.(^Dict); return ok && va == vb
case ^Stream: vb, ok := b.(^Stream); return ok && va == vb
}
return b == nil
}
@(private = "file")
parse_one :: proc(src: string, alloc: mem.Allocator) -> (Object, Error) {
p := parser_make(nil, bytes_of(src), alloc)
return parse_object(&p)
}
@(private = "file")
has_repair :: proc(doc: ^Document, kind: Repair_Kind) -> bool {
for r in doc.repairs {
if r.kind == kind { return true }
}
return false
}
// ---------------------------------------------------------------------------
// Scalars
// ---------------------------------------------------------------------------
@(test)
test_parser_scalars :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
Case :: struct {
src: string,
want: Object,
}
cases := []Case{
{"true", true},
{"false", false},
{"null", nil},
{"42", i64(42)},
{"-7", i64(-7)},
{"3.5", f64(3.5)},
{"-.25", f64(-0.25)},
{"/Name", Name("Name")},
{"/", Name("")},
{"/a#20b", Name("a b")},
// A number a viewer would read as 0 must still be an object.
{"--3", i64(0)},
}
for c in cases {
obj, err := parse_one(c.src, alloc)
testing.expectf(t, err == .None, "%q: error %v", c.src, err)
testing.expectf(t, object_same(obj, c.want), "%q: got %v, want %v", c.src, obj, c.want)
}
// Strings compare by content, not by pointer.
String_Case :: struct {
src: string,
want: string,
}
for c in ([]String_Case{
{"(hi)", "hi"},
{"<4869>", "Hi"},
{"()", ""},
{"(a\\061b)", "a1b"},
}) {
obj, err := parse_one(c.src, alloc)
testing.expectf(t, err == .None, "%q: error %v", c.src, err)
b, ok := to_bytes(obj)
testing.expectf(t, ok, "%q: not a string", c.src)
testing.expectf(t, slice.equal(b, bytes_of(c.want)), "%q: got %q", c.src, string(b))
}
}
@(test)
test_parser_empty_and_junk :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
_, err := parse_one("", alloc)
testing.expect_value(t, err, Error.Truncated)
_, err2 := parse_one(" % nothing but a comment\n", alloc)
testing.expect_value(t, err2, Error.Truncated)
_, err3 := parse_one("endobj", alloc)
testing.expect_value(t, err3, Error.Unexpected_Token)
_, err4 := parse_one(">", alloc)
testing.expect_value(t, err4, Error.Syntax_Error)
_, err5 := parse_one("]", alloc)
testing.expect_value(t, err5, Error.Unexpected_Token)
}
// ---------------------------------------------------------------------------
// References
// ---------------------------------------------------------------------------
@(test)
test_parser_reference_vs_header :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
Case :: struct {
src: string,
want: Object,
}
cases := []Case{
{"1 0 R", Ref{1, 0}},
{"12 65535 R", Ref{12, 65535}},
{"1 0 R ", Ref{1, 0}},
// "12 0 obj" is a header, not a reference: the first integer is the
// object here, and the caller sees the rest.
{"12 0 obj", i64(12)},
{"12 0", i64(12)},
{"12", i64(12)},
// "1 0 0 RG" in a content stream is three numbers and an operator.
{"1 0 0 RG", i64(1)},
{"1 0 RG", i64(1)},
// Object 0 is the free-list head and can never be referenced.
{"0 0 R", nil},
{"-1 0 R", nil},
// Past the format's object-number ceiling.
{"8388608 0 R", nil},
// A generation that cannot fit is not a reference at all.
{"1 70000 R", i64(1)},
}
for c in cases {
obj, err := parse_one(c.src, alloc)
testing.expectf(t, err == .None, "%q: error %v", c.src, err)
testing.expectf(t, object_same(obj, c.want), "%q: got %v, want %v", c.src, obj, c.want)
}
}
@(test)
test_parser_references_in_containers :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
obj, err := parse_one("[1 0 R 2 0 R 3 4 R]", alloc)
testing.expect_value(t, err, Error.None)
a, ok := to_array(obj)
testing.expect(t, ok, "not an array")
testing.expect_value(t, array_len(a), 3)
testing.expect(t, object_same(array_get_raw(a, 0), Ref{1, 0}), "element 0 is not 1 0 R")
testing.expect(t, object_same(array_get_raw(a, 2), Ref{3, 4}), "element 2 is not 3 4 R")
dobj, derr := parse_one("<< /Kids [4 0 R] /Parent 9 0 R /Count 1 >>", alloc)
testing.expect_value(t, derr, Error.None)
d, dok := to_dict(dobj)
testing.expect(t, dok, "not a dictionary")
ref, rok := dict_ref(d, "Parent")
testing.expect(t, rok, "/Parent is not a reference")
testing.expect_value(t, ref, Ref{9, 0})
}
// ---------------------------------------------------------------------------
// Arrays
// ---------------------------------------------------------------------------
@(test)
test_parser_nested_arrays :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
obj, err := parse_one("[1 [2 [3 [4 5]]] 6]", alloc)
testing.expect_value(t, err, Error.None)
a, _ := to_array(obj)
testing.expect_value(t, array_len(a), 3)
testing.expect(t, object_same(array_get_raw(a, 0), i64(1)), "element 0 is not 1")
testing.expect(t, object_same(array_get_raw(a, 2), i64(6)), "element 2 is not 6")
b, _ := to_array(array_get_raw(a, 1))
testing.expect_value(t, array_len(b), 2)
c, _ := to_array(array_get_raw(b, 1))
testing.expect_value(t, array_len(c), 2)
d, _ := to_array(array_get_raw(c, 1))
testing.expect_value(t, array_len(d), 2)
testing.expect(t, object_same(array_get_raw(d, 1), i64(5)), "innermost element is not 5")
// Nulls hold their position: a /Widths or /Decode array is read by index.
nobj, nerr := parse_one("[1 null 2]", alloc)
testing.expect_value(t, nerr, Error.None)
na, _ := to_array(nobj)
testing.expect_value(t, array_len(na), 3)
testing.expect(t, is_null(array_get_raw(na, 1)), "the null element was dropped")
// Mixed types, no separators needed between delimited objects.
mobj, merr := parse_one("[/A(b)<43>[]<< >>true]", alloc)
testing.expect_value(t, merr, Error.None)
ma, _ := to_array(mobj)
testing.expect_value(t, array_len(ma), 6)
}
@(test)
test_parser_depth_limit :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
build :: proc(n: int, alloc: mem.Allocator) -> string {
b := strings.builder_make(0, 2 * n, alloc)
for _ in 0 ..< n { strings.write_byte(&b, '[') }
for _ in 0 ..< n { strings.write_byte(&b, ']') }
return strings.to_string(b)
}
ok_src := build(MAX_DEPTH, alloc)
_, err := parse_one(ok_src, alloc)
testing.expectf(t, err == .None, "%d levels should parse, got %v", MAX_DEPTH, err)
deep := build(MAX_DEPTH + 1, alloc)
_, derr := parse_one(deep, alloc)
testing.expect_value(t, derr, Error.Recursion_Limit)
// Dictionaries are bounded by the same counter, and a mixed nest too.
dict_src := strings.builder_make(0, 8 * MAX_DEPTH, alloc)
for _ in 0 ..< MAX_DEPTH + 1 { strings.write_string(&dict_src, "<< /A ") }
for _ in 0 ..< MAX_DEPTH + 1 { strings.write_string(&dict_src, ">>") }
_, dicterr := parse_one(strings.to_string(dict_src), alloc)
testing.expect_value(t, dicterr, Error.Recursion_Limit)
// A pathological file is not allowed to recurse the stack away either.
runaway := strings.builder_make(0, 4096, alloc)
for _ in 0 ..< 4000 { strings.write_byte(&runaway, '[') }
_, rerr := parse_one(strings.to_string(runaway), alloc)
testing.expect_value(t, rerr, Error.Recursion_Limit)
}
@(test)
test_parser_unterminated_array :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
doc := test_doc("[1 2 3", alloc)
p := parser_make(doc, doc.data, alloc)
obj, err := parse_object(&p)
testing.expect_value(t, err, Error.None)
a, _ := to_array(obj)
testing.expect_value(t, array_len(a), 3)
testing.expect(t, has_repair(doc, .Unterminated_Array), "no Unterminated_Array repair")
testing.expect(t, doc.is_damaged, "document should be marked damaged")
// A '>>' inside an array closes the array without being eaten: the
// enclosing dictionary still needs it.
doc2 := test_doc("<< /A [1 2 >> /B 3", alloc)
p2 := parser_make(doc2, doc2.data, alloc)
obj2, err2 := parse_object(&p2)
testing.expect_value(t, err2, Error.None)
d2, _ := to_dict(obj2)
a2 := dict_array(nil, d2, "A")
testing.expect_value(t, array_len(a2), 2)
// Likewise a keyword that can only appear outside a container.
doc3 := test_doc("[1 2 endobj", alloc)
p3 := parser_make(doc3, doc3.data, alloc)
obj3, _ := parse_object(&p3)
a3, _ := to_array(obj3)
testing.expect_value(t, array_len(a3), 2)
// Junk between elements is stepped over, not fatal.
doc4 := test_doc("[1 ) 2 endobj]", alloc)
p4 := parser_make(doc4, doc4.data, alloc)
obj4, err4 := parse_object(&p4)
testing.expect_value(t, err4, Error.None)
a4, _ := to_array(obj4)
testing.expect_value(t, array_len(a4), 2)
}
// ---------------------------------------------------------------------------
// Dictionaries
// ---------------------------------------------------------------------------
@(test)
test_parser_dict_basics :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
obj, err := parse_one("<< /Type /Page /Count 3 /Box [0 0 612 792] /S (x) /Null null >>", alloc)
testing.expect_value(t, err, Error.None)
d, ok := to_dict(obj)
testing.expect(t, ok, "not a dictionary")
testing.expect_value(t, dict_name(nil, d, "Type"), "Page")
testing.expect_value(t, dict_int(nil, d, "Count"), i64(3))
testing.expect_value(t, array_len(dict_array(nil, d, "Box")), 4)
// A key with a null value is indistinguishable from an absent key, so it
// is never stored.
testing.expect(t, !dict_has(d, "Null"), "a null value must be dropped")
testing.expect_value(t, len(d^), 4)
// The last of a repeated key wins, which is what every viewer does.
dup, _ := parse_one("<< /A 1 /A 2 >>", alloc)
dd, _ := to_dict(dup)
testing.expect_value(t, dict_int(nil, dd, "A"), i64(2))
// Nesting.
nest, _ := parse_one("<< /R << /Font << /F1 5 0 R >> >> >>", alloc)
nd, _ := to_dict(nest)
fonts := dict_dict(nil, dict_dict(nil, nd, "R"), "Font")
ref, rok := dict_ref(fonts, "F1")
testing.expect(t, rok, "/F1 is not a reference")
testing.expect_value(t, ref, Ref{5, 0})
empty, eerr := parse_one("<<>>", alloc)
testing.expect_value(t, eerr, Error.None)
ed, eok := to_dict(empty)
testing.expect(t, eok, "<<>> is a dictionary")
testing.expect_value(t, len(ed^), 0)
}
@(test)
test_parser_dict_bad_key :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
// A key that is not a name is dropped with its value; the rest of the
// dictionary is still read.
doc := test_doc("<< 5 6 /A 1 (k) (v) /B 2 >>", alloc)
p := parser_make(doc, doc.data, alloc)
obj, err := parse_object(&p)
testing.expect_value(t, err, Error.None)
d, _ := to_dict(obj)
testing.expect_value(t, dict_int(nil, d, "A"), i64(1))
testing.expect_value(t, dict_int(nil, d, "B"), i64(2))
testing.expect_value(t, len(d^), 2)
testing.expect(t, has_repair(doc, .Unterminated_Dict), "no repair recorded for the bad keys")
}
@(test)
test_parser_dict_unterminated :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
doc := test_doc("<< /A 1 /B 2", alloc)
p := parser_make(doc, doc.data, alloc)
obj, err := parse_object(&p)
testing.expect_value(t, err, Error.None)
d, _ := to_dict(obj)
testing.expect_value(t, dict_int(nil, d, "A"), i64(1))
testing.expect_value(t, dict_int(nil, d, "B"), i64(2))
testing.expect(t, has_repair(doc, .Unterminated_Dict), "no Unterminated_Dict repair")
// A key whose value is missing must not swallow the closing '>>'.
doc2 := test_doc("<< /A 1 /B >>", alloc)
p2 := parser_make(doc2, doc2.data, alloc)
obj2, _ := parse_object(&p2)
d2, _ := to_dict(obj2)
testing.expect_value(t, dict_int(nil, d2, "A"), i64(1))
testing.expect(t, !dict_has(d2, "B"), "/B has no value to store")
tail := lexer_next(&p2.lex, alloc)
testing.expect_value(t, tail.kind, Token_Kind.EOF)
// Truncated in the middle of a key.
doc3 := test_doc("<< /A 1 /B", alloc)
p3 := parser_make(doc3, doc3.data, alloc)
obj3, err3 := parse_object(&p3)
testing.expect_value(t, err3, Error.None)
d3, _ := to_dict(obj3)
testing.expect_value(t, len(d3^), 1)
}
@(test)
test_parser_string_repairs :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
doc := test_doc("<< /T (unterminated", alloc)
p := parser_make(doc, doc.data, alloc)
obj, err := parse_object(&p)
testing.expect_value(t, err, Error.None)
d, _ := to_dict(obj)
testing.expect(t, slice.equal(dict_bytes(nil, d, "T"), bytes_of("unterminated")),
"the value of an unterminated string is still usable")
testing.expect(t, has_repair(doc, .Unterminated_String), "no Unterminated_String repair")
}
// ---------------------------------------------------------------------------
// Streams
// ---------------------------------------------------------------------------
@(private = "file")
STREAM_SRC :: "%PDF-1.7\n1 0 obj\n<< /Length 5 >>\nstream\r\nHello\nendstream\nendobj\n"
@(private = "file")
parse_first_object :: proc(doc: ^Document, alloc: mem.Allocator) -> (Object, Ref, Error) {
return parse_indirect_object_at(doc, i64(strings.index(string(doc.data), "1 0 obj")), Ref{1, 0})
}
@(test)
test_parser_stream_exact_length :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
doc := test_doc(STREAM_SRC, alloc)
obj, got, err := parse_first_object(doc, alloc)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, got, Ref{1, 0})
s, ok := to_stream(obj)
testing.expect(t, ok, "not a stream")
testing.expect(t, slice.equal(s.raw, bytes_of("Hello")), "wrong payload")
testing.expect_value(t, s.ref, Ref{1, 0})
testing.expect_value(t, dict_int(doc, s.dict, "Length"), i64(5))
testing.expect(t, len(doc.repairs) == 0, "a well-formed stream needs no repair")
// The payload is a view into the file, not a copy: a 200 MB document must
// not be doubled in memory by parsing it.
base := uintptr(raw_data(doc.data))
at := uintptr(raw_data(s.raw))
testing.expect(t, at >= base && at < base + uintptr(len(doc.data)),
"the stream payload was copied out of doc.data")
}
@(test)
test_parser_stream_bad_length :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
Case :: struct {
src: string,
want: string,
}
cases := []Case{
// Too long, too short, absurd, negative, missing, not a number.
{"%PDF-1.7\n1 0 obj\n<< /Length 999 >>\nstream\r\nHello\nendstream\nendobj\n", "Hello"},
{"%PDF-1.7\n1 0 obj\n<< /Length 2 >>\nstream\r\nHello\nendstream\nendobj\n", "Hello"},
{"%PDF-1.7\n1 0 obj\n<< /Length 99999999999999 >>\nstream\r\nHello\nendstream\nendobj\n", "Hello"},
{"%PDF-1.7\n1 0 obj\n<< /Length -5 >>\nstream\r\nHello\nendstream\nendobj\n", "Hello"},
{"%PDF-1.7\n1 0 obj\n<< >>\nstream\r\nHello\nendstream\nendobj\n", "Hello"},
{"%PDF-1.7\n1 0 obj\n<< /Length /Bogus >>\nstream\r\nHello\nendstream\nendobj\n", "Hello"},
// The EOL before `endstream` is a separator, not payload -- one
// marker's worth, whether it is LF, CR or CRLF.
{"%PDF-1.7\n1 0 obj\n<< /Length 0 >>\nstream\r\nHello\r\nendstream\nendobj\n", "Hello"},
{"%PDF-1.7\n1 0 obj\n<< /Length 0 >>\nstream\r\nHello\rendstream\nendobj\n", "Hello"},
{"%PDF-1.7\n1 0 obj\n<< /Length 0 >>\nstream\r\nHelloendstream\nendobj\n", "Hello"},
// Binary payload with an embedded "endstream"-looking prefix.
{"%PDF-1.7\n1 0 obj\n<< /Length 7 >>\nstream\r\nend\x00\x01\x02x\nendstream\nendobj\n",
"end\x00\x01\x02x"},
}
for c in cases {
doc := test_doc(c.src, alloc)
obj, _, err := parse_first_object(doc, alloc)
testing.expectf(t, err == .None, "%q: error %v", c.src, err)
s, ok := to_stream(obj)
testing.expectf(t, ok, "%q: not a stream", c.src)
if !ok { continue }
testing.expectf(t, slice.equal(s.raw, bytes_of(c.want)),
"%q: payload %q, want %q", c.src, string(s.raw), c.want)
}
// The first case above is the canonical one: it must be reported.
doc := test_doc(cases[0].src, alloc)
parse_first_object(doc, alloc)
testing.expect(t, has_repair(doc, .Bad_Stream_Length), "no Bad_Stream_Length repair")
}
@(test)
test_parser_stream_indirect_length :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
src := "%PDF-1.7\n" +
"1 0 obj\n<< /Length 5 0 R >>\nstream\r\nHello\nendstream\nendobj\n" +
"5 0 obj\n5\nendobj\n"
doc := test_doc(src, alloc)
test_xref_at(doc, 5, "5 0 obj")
obj, _, err := parse_first_object(doc, alloc)
testing.expect_value(t, err, Error.None)
s, ok := to_stream(obj)
testing.expect(t, ok, "not a stream")
testing.expect(t, slice.equal(s.raw, bytes_of("Hello")), "wrong payload")
testing.expect(t, !has_repair(doc, .Bad_Stream_Length),
"an indirect /Length that is right needs no repair")
// An indirect /Length pointing at nothing falls back to the scan.
src2 := "%PDF-1.7\n1 0 obj\n<< /Length 9 0 R >>\nstream\r\nHello\nendstream\nendobj\n"
doc2 := test_doc(src2, alloc)
obj2, _, err2 := parse_first_object(doc2, alloc)
testing.expect_value(t, err2, Error.None)
s2, ok2 := to_stream(obj2)
testing.expect(t, ok2, "not a stream")
testing.expect(t, slice.equal(s2.raw, bytes_of("Hello")), "wrong payload")
testing.expect(t, has_repair(doc2, .Bad_Stream_Length), "no Bad_Stream_Length repair")
// An indirect /Length that points back at the stream's own object must not
// recurse: the cycle guard in get_object turns it into null.
src3 := "%PDF-1.7\n1 0 obj\n<< /Length 1 0 R >>\nstream\r\nHello\nendstream\nendobj\n"
doc3 := test_doc(src3, alloc)
test_xref_at(doc3, 1, "1 0 obj")
obj3, _, err3 := parse_first_object(doc3, alloc)
testing.expect_value(t, err3, Error.None)
s3, ok3 := to_stream(obj3)
testing.expect(t, ok3, "not a stream")
testing.expect(t, slice.equal(s3.raw, bytes_of("Hello")), "wrong payload")
}
@(test)
test_parser_stream_eol_tolerance :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
Case :: struct {
src: string,
want: string,
}
cases := []Case{
// CRLF (the only spelling the spec allows), LF, a bare CR, and a
// trailing space before the EOL. All four are in the wild.
{"%PDF\n1 0 obj\n<< /Length 5 >>\nstream\r\nHello\nendstream\nendobj\n", "Hello"},
{"%PDF\n1 0 obj\n<< /Length 5 >>\nstream\nHello\nendstream\nendobj\n", "Hello"},
{"%PDF\n1 0 obj\n<< /Length 5 >>\nstream\rHello\nendstream\nendobj\n", "Hello"},
{"%PDF\n1 0 obj\n<< /Length 5 >>\nstream \r\nHello\nendstream\nendobj\n", "Hello"},
// A payload that starts with what looks like an EOL: only one marker
// is consumed, so the leading LF here is data.
{"%PDF\n1 0 obj\n<< /Length 6 >>\nstream\r\n\nHello\nendstream\nendobj\n", "\nHello"},
// Zero-length streams are legal and common (empty appearance streams).
{"%PDF\n1 0 obj\n<< /Length 0 >>\nstream\r\n\nendstream\nendobj\n", ""},
}
for c in cases {
doc := test_doc(c.src, alloc)
obj, _, err := parse_first_object(doc, alloc)
testing.expectf(t, err == .None, "%q: error %v", c.src, err)
s, ok := to_stream(obj)
testing.expectf(t, ok, "%q: not a stream", c.src)
if !ok { continue }
testing.expectf(t, slice.equal(s.raw, bytes_of(c.want)),
"%q: payload %q, want %q", c.src, string(s.raw), c.want)
}
}
@(test)
test_parser_stream_missing_endstream :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
src := "%PDF-1.7\n1 0 obj\n<< /Length 9999 >>\nstream\r\nHello, truncated"
doc := test_doc(src, alloc)
obj, _, err := parse_first_object(doc, alloc)
testing.expect_value(t, err, Error.None)
s, ok := to_stream(obj)
testing.expect(t, ok, "not a stream")
testing.expect(t, slice.equal(s.raw, bytes_of("Hello, truncated")),
"a truncated stream keeps what is there")
testing.expect(t, has_repair(doc, .Missing_Endstream), "no Missing_Endstream repair")
}
@(test)
test_parser_stream_only_at_top_level :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
// A `stream` keyword after a *nested* dictionary is not a stream: only the
// body of an indirect object can be one.
obj, err := parse_one("[ << /A 1 >> stream ]", alloc)
testing.expect_value(t, err, Error.None)
a, _ := to_array(obj)
testing.expect_value(t, array_len(a), 1)
_, is_dict := to_dict(array_get_raw(a, 0))
testing.expect(t, is_dict, "the inner object is a plain dictionary")
_, is_stream := to_stream(array_get_raw(a, 0))
testing.expect(t, !is_stream, "a nested dictionary must not become a stream")
}
// ---------------------------------------------------------------------------
// Indirect objects
// ---------------------------------------------------------------------------
@(test)
test_parse_indirect_object_at :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
// The trailing padding puts the junk further from object 8 than the
// window search reaches.
src :: "%PDF-1.7\n7 0 obj\n<< /Type /Page >>\nendobj\n8 2 obj\n42\nendobj\n" +
" " +
" " +
"not an object at all\n"
doc := test_doc(src, alloc)
at7 := i64(strings.index(src, "7 0 obj"))
at8 := i64(strings.index(src, "8 2 obj"))
obj, got, err := parse_indirect_object_at(doc, at7, Ref{7, 0})
testing.expect_value(t, err, Error.None)
testing.expect_value(t, got, Ref{7, 0})
testing.expect_value(t, dict_name(doc, to_dict(obj) or_else nil, "Type"), "Page")
obj8, got8, err8 := parse_indirect_object_at(doc, at8, Ref{8, 2})
testing.expect_value(t, err8, Error.None)
testing.expect_value(t, got8, Ref{8, 2})
testing.expect(t, object_same(obj8, i64(42)), "object 8 is not 42")
// An offset a few bytes off is searched around, and the reference that was
// actually found comes back so a stale table can be detected.
for delta in ([]i64{-3, -1, 1, 4, 12}) {
o, g, e := parse_indirect_object_at(doc, at7 + delta, Ref{7, 0})
testing.expectf(t, e == .None, "delta %d: error %v", delta, e)
testing.expectf(t, g == Ref{7, 0}, "delta %d: got %v", delta, g)
testing.expectf(t, o != nil, "delta %d: no object", delta)
}
// The window must not match the digits inside a longer number: "8 2 obj"
// found from an offset near object 7 is still object 8, never object 2.
_, gnear, _ := parse_indirect_object_at(doc, at8 - 2, Ref{8, 0})
testing.expect_value(t, gnear, Ref{8, 2})
// Nowhere near an object header.
_, _, enone := parse_indirect_object_at(doc, i64(strings.index(src, "not an object")), Ref{9, 0})
testing.expect_value(t, enone, Error.Object_Not_Found)
// Out of range offsets, including the ones a broken xref produces.
for bad in ([]i64{-1, 0x7fff_ffff, i64(len(src)), i64(len(src)) + 1000}) {
_, _, ebad := parse_indirect_object_at(doc, bad, Ref{7, 0})
testing.expectf(t, ebad == .Object_Not_Found, "offset %d: error %v", bad, ebad)
}
// A nil Document is a caller error, not a crash.
_, _, enil := parse_indirect_object_at(nil, 0, Ref{1, 0})
testing.expect_value(t, enil, Error.Invalid_Argument)
}
@(test)
test_parse_indirect_object_stale_xref :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
// The offset points at object 4 while the table promised object 3. The
// caller gets the truth and decides; here it is close enough that the
// window search must not "helpfully" find something else.
src := "%PDF-1.7\n4 0 obj\n(four)\nendobj\n"
doc := test_doc(src, alloc)
_, got, err := parse_indirect_object_at(doc, i64(strings.index(src, "4 0 obj")), Ref{3, 0})
testing.expect_value(t, err, Error.None)
testing.expect_value(t, got, Ref{4, 0})
}
@(test)
test_parse_indirect_object_empty_body :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
// "1 0 obj endobj" is an object with no body, which is null.
src := "%PDF-1.7\n1 0 obj\nendobj\n"
doc := test_doc(src, alloc)
obj, got, err := parse_indirect_object_at(doc, i64(strings.index(src, "1 0 obj")), Ref{1, 0})
testing.expect_value(t, err, Error.None)
testing.expect_value(t, got, Ref{1, 0})
testing.expect(t, is_null(obj), "an empty object body is null")
// A missing generation number is tolerated: "1 obj" is generation 0.
src2 := "%PDF-1.7\n1 obj\n(x)\nendobj\n"
doc2 := test_doc(src2, alloc)
obj2, got2, err2 := parse_indirect_object_at(doc2, i64(strings.index(src2, "1 obj")), Ref{1, 0})
testing.expect_value(t, err2, Error.None)
testing.expect_value(t, got2, Ref{1, 0})
testing.expect(t, obj2 != nil, "the body should still be read")
}
@(test)
test_parse_indirect_object_header_offset :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
// Junk before "%PDF-" is common (mail headers, HTTP responses), and some
// of those files measure their offsets from the header instead of byte 0.
junk :: "MIME-Version: 1.0\r\nContent-Type: application/pdf\r\n\r\n"
src :: junk + "%PDF-1.4\n3 0 obj\n(payload)\nendobj\n"
doc := test_doc(src, alloc)
doc.header_offset = i64(len(junk))
relative := i64(strings.index(src, "3 0 obj")) - doc.header_offset
obj, got, err := parse_indirect_object_at(doc, relative, Ref{3, 0})
testing.expect_value(t, err, Error.None)
testing.expect_value(t, got, Ref{3, 0})
testing.expect(t, slice.equal(to_bytes(obj) or_else nil, bytes_of("payload")), "wrong payload")
}
// ---------------------------------------------------------------------------
// Fuzz-shaped
// ---------------------------------------------------------------------------
@(private = "file")
FUZZ_SRC :: "%PDF-1.7\n12 0 obj\n<< /Type /Page /Parent 3 0 R /Kids [4 0 R 5 0 R] " +
"/S (a\\(b\\) c) /H <41 42 43> /N /A#20B /D [1 [2 [3 [4]]]] /L 1e3 /Length 4 >>\n" +
"stream\r\nBODY\nendstream\nendobj\n"
@(test)
test_parser_survives_truncation :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
// Every prefix of a real object must parse or fail, never hang or crash.
whole := FUZZ_SRC
for n in 0 ..= len(whole) {
src := whole[:n]
doc := test_doc(src, alloc)
at := strings.index(src, "12 0 obj")
if at >= 0 {
_, _, _ = parse_indirect_object_at(doc, i64(at), Ref{12, 0})
}
p := parser_make(doc, doc.data, alloc)
_, _ = parse_object(&p)
testing.expectf(t, p.lex.pos <= len(src), "truncation %d ran past the end", n)
}
}
@(test)
test_parser_survives_corruption :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
// Single-byte substitutions over the same object, cycling through the
// bytes that carry syntactic weight.
poison := "()<>[]{}/%\\ \r\n0123456789.+-eRobj\x00\xff"
buf := make([]byte, len(FUZZ_SRC), alloc)
for i in 0 ..< len(FUZZ_SRC) {
copy(buf, FUZZ_SRC)
buf[i] = poison[i % len(poison)]
doc := test_doc(string(buf), alloc)
at := strings.index(string(buf), "12 0 obj")
if at >= 0 {
_, _, _ = parse_indirect_object_at(doc, i64(at), Ref{12, 0})
}
p := parser_make(doc, doc.data, alloc)
_, _ = parse_object(&p)
testing.expectf(t, p.lex.pos <= len(buf), "corruption at %d ran past the end", i)
}
}
@(test)
test_parser_cyclic_and_absurd :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
// A reference chain that loops. `resolve` bounds it; nothing here should
// recurse.
src := "%PDF\n1 0 obj\n2 0 R\nendobj\n2 0 obj\n1 0 R\nendobj\n"
doc := test_doc(src, alloc)
test_xref_at(doc, 1, "1 0 obj")
test_xref_at(doc, 2, "2 0 obj")
testing.expect(t, is_null(resolve(doc, Object(Ref{1, 0}))), "a reference loop resolves to null")
// An object that refers to itself. Resolving /Self is not an error -- it
// is the same object -- but it must terminate, and the parse that had to
// read /Length out of it must not have recursed.
src2 := "%PDF\n1 0 obj\n<< /Self 1 0 R >>\nendobj\n"
doc2 := test_doc(src2, alloc)
test_xref_at(doc2, 1, "1 0 obj")
d, ok := to_dict(get_object(doc2, Ref{1, 0}))
testing.expect(t, ok, "the object itself is still readable")
self, self_ok := to_dict(dict_get(doc2, d, "Self"))
testing.expect(t, self_ok && self == d, "the self-reference should resolve to the object")
// A dictionary whose value is twice as deep as the limit: the limit is
// reported, and the recursion stops at MAX_DEPTH frames rather than at the
// end of the stack.
deep := strings.builder_make(0, 256, alloc)
strings.write_string(&deep, "<< /A ")
for _ in 0 ..< 2 * MAX_DEPTH { strings.write_string(&deep, "[") }
_, err := parse_one(strings.to_string(deep), alloc)
testing.expect_value(t, err, Error.Recursion_Limit)
}
// ---------------------------------------------------------------------------
// Decryption wiring
//
// The cipher itself lives in crypt.odin; what belongs to the parser is which
// Ref a string or payload is decrypted with, and the rule that objects out of
// an object stream are not decrypted individually.
// ---------------------------------------------------------------------------
@(test)
test_parser_obj_ref_defaults_to_zero :: proc(t: ^testing.T) {
arena: virtual.Arena
alloc := scratch_begin(t, &arena)
defer virtual.arena_destroy(&arena)
// A fresh parser carries the zero Ref, which is the signal not to decrypt.
// Object-stream contents and inline images are parsed exactly this way.
doc := test_doc("<< /Length 4 >>\nstream\r\nBODY\nendstream\n", alloc)
p := parser_make(doc, doc.data, alloc)
testing.expect_value(t, p.obj_ref, Ref{})
obj, err := parse_object(&p)
testing.expect_value(t, err, Error.None)
s, ok := to_stream(obj)
testing.expect(t, ok, "not a stream")
testing.expect_value(t, s.ref, Ref{})
// Parsed as an indirect object instead, the stream carries the identity
// its key would be derived from.
doc2 := test_doc(STREAM_SRC, alloc)
obj2, _, err2 := parse_first_object(doc2, alloc)
testing.expect_value(t, err2, Error.None)
s2, ok2 := to_stream(obj2)
testing.expect(t, ok2, "not a stream")
testing.expect_value(t, s2.ref, Ref{1, 0})
}