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

329 lines
12 KiB
Odin

package pdf
import "core:fmt"
import "core:os"
import "core:testing"
// ---------------------------------------------------------------------------
// Recursion bounds
//
// The two tests below are regressions for a pair of crashes, not hypotheticals.
// Both worked the same way: an object's definition cannot be finished until
// another object is parsed, and a file can name a long chain of such objects in
// which no object number ever repeats. The cycle guards look for repetition, so
// they never fire, and the native stack runs out. `MAX_LOAD_DEPTH` bounds the
// nesting itself, which is the only thing that catches an acyclic chain.
// ---------------------------------------------------------------------------
// Objects 3..N are streams whose /Length is an indirect reference to the next
// one, so parsing object 3 cannot finish until object N has been parsed.
// Before the depth bound this segfaulted at roughly six thousand links.
@(test)
test_indirect_length_chain_terminates :: proc(t: ^testing.T) {
N :: 8000
pb := pb_make()
defer pb_destroy(&pb)
pb_obj(&pb, 1, "<< /Type /Catalog /Pages 2 0 R >>")
pb_obj(&pb, 2, "<< /Type /Pages /Kids [] /Count 0 >>")
for k in 3 ..< N {
pb_mark(&pb, u32(k))
pb_num(&pb, k)
pb_str(&pb, " 0 obj\n<< /Length ")
pb_num(&pb, k + 1)
pb_str(&pb, " 0 R >>\nstream\nX\nendstream\nendobj\n")
}
pb_mark(&pb, u32(N))
pb_num(&pb, N)
pb_str(&pb, " 0 obj\n<< /Length 1 >>\nstream\nX\nendstream\nendobj\n")
at := pb_classic_xref(&pb)
pb_trailer(&pb, fmt.tprintf("<< /Size %d /Root 1 0 R >>", N + 1), at)
doc, err := open_bytes(pb.buf[:], Open_Options{skip_page_tree = true})
testing.expect_value(t, err, Error.None)
if doc == nil { return }
defer close(doc)
// Walking the chain must return, and must give up rather than pretend.
head := get_object(doc, Ref{3, 0})
testing.expect(t, head != nil, "the head of the chain should still parse")
testing.expect(t, doc_has_repair(doc, .Recursion_Limit_Hit),
"hitting the nesting bound must be recorded as a repair")
// The bound is on nesting, so only a bounded prefix of the chain is ever
// touched -- proof the walk stopped rather than merely surviving.
testing.expect(t, len(doc.cache) <= MAX_LOAD_DEPTH + 8,
"only a bounded prefix of the chain should have been parsed")
}
// Object k's xref entry says it lives inside object stream k+1, whose own entry
// says the same of k+2, and so on. Resolving object 2 walks the whole chain.
@(test)
test_objstm_container_chain_terminates :: proc(t: ^testing.T) {
N :: 3000
pb := pb_make()
defer pb_destroy(&pb)
pb_obj(&pb, 1, "<< /Type /Catalog /Pages 2 0 R >>")
// Rows 0..N+1: 0 free, 1 the catalog, 2..N-1 compressed pointing one
// further along, N the xref stream itself.
rows := make([]Xref_Row, N + 1)
defer delete(rows)
rows[0] = Xref_Row{0, 0, 65535}
rows[1] = Xref_Row{1, u64(pb.offs[1]), 0}
for k in 2 ..< N {
rows[k] = Xref_Row{2, u64(k + 1), 0}
}
raw := xref_rows_encode(rows[:], 1, 4, 2)
defer delete(raw)
at := pb_here(&pb)
rows[N] = Xref_Row{1, u64(at), 0}
// The stream object's own row has to carry its real offset, so re-encode
// now that it is known.
raw2 := xref_rows_encode(rows[:], 1, 4, 2)
defer delete(raw2)
pb_stream_obj(&pb, u32(N), fmt.tprintf("/Type /XRef /Size %d /W [1 4 2] /Root 1 0 R", N + 1), raw2)
pb_startxref(&pb, at)
doc, err := open_bytes(pb.buf[:], Open_Options{skip_page_tree = true})
testing.expect_value(t, err, Error.None)
if doc == nil { return }
defer close(doc)
// The chain resolves to null instead of walking the stack off its end.
obj := get_object(doc, Ref{2, 0})
testing.expect(t, obj == nil, "a container chain that goes nowhere resolves to null")
testing.expect(t, doc_has_repair(doc, .Recursion_Limit_Hit),
"hitting the nesting bound must be recorded as a repair")
}
// ---------------------------------------------------------------------------
// Object access
// ---------------------------------------------------------------------------
@(test)
test_get_object_rejects_bad_refs :: proc(t: ^testing.T) {
pb := pb_sample_classic()
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
testing.expect_value(t, err, Error.None)
if doc == nil { return }
defer close(doc)
// Object 0 is the head of the free list and is never a real object.
testing.expect(t, get_object(doc, Ref{0, 0}) == nil, "object 0 is not an object")
testing.expect(t, get_object(doc, Ref{9999, 0}) == nil, "past the table reads as null")
testing.expect(t, get_object(doc, Ref{MAX_OBJECT_NUMBER + 1, 0}) == nil,
"an object number above the format's cap reads as null")
testing.expect(t, get_object(nil, Ref{1, 0}) == nil, "a nil document reads as null")
// The catalog is really there, so the checks above are not passing by
// accident on a document where nothing resolves.
testing.expect(t, get_dict(doc, Ref{1, 0}) != nil, "the catalog should resolve")
}
// A reference pointing at a reference pointing at a reference ... is legal but
// only ever written to make a reader loop. It must terminate at null.
@(test)
test_resolve_ref_chain_is_capped :: proc(t: ^testing.T) {
LINKS :: MAX_REF_CHAIN + 20
pb := pb_make()
defer pb_destroy(&pb)
pb_obj(&pb, 1, "<< /Type /Catalog /Pages 2 0 R >>")
pb_obj(&pb, 2, "<< /Type /Pages /Kids [] /Count 0 >>")
for k in 3 ..< 3 + LINKS {
pb_obj(&pb, u32(k), fmt.tprintf("%d 0 R", k + 1))
}
pb_obj(&pb, u32(3 + LINKS), "42")
at := pb_classic_xref(&pb)
pb_trailer(&pb, fmt.tprintf("<< /Size %d /Root 1 0 R >>", 4 + LINKS), at)
doc, err := open_bytes(pb.buf[:], Open_Options{skip_page_tree = true})
testing.expect_value(t, err, Error.None)
if doc == nil { return }
defer close(doc)
testing.expect(t, resolve(doc, Ref{3, 0}) == nil, "an over-long ref chain resolves to null")
// A chain inside the cap still resolves to its value.
short := resolve(doc, Ref{u32(2 + LINKS), 0})
v, ok := to_int(short)
testing.expect(t, ok, "a short chain should reach the integer")
testing.expect_value(t, v, 42)
}
// ---------------------------------------------------------------------------
// open_file, ownership and teardown
//
// open_file is the entry point a viewer actually calls and the only place the
// library frees memory it owns outside the arena, so it needs a test that does
// not depend on a corpus directory being present.
// ---------------------------------------------------------------------------
@(test)
test_open_file_owns_and_frees_its_bytes :: proc(t: ^testing.T) {
pb := pb_sample_classic()
defer pb_destroy(&pb)
path := "pdf_odin_open_file_test.pdf"
werr := os.write_entire_file(path, pb.buf[:])
if !testing.expect(t, werr == nil, "could not write the temporary file") {
return
}
defer os.remove(path)
doc, err := open_file(path)
testing.expect_value(t, err, Error.None)
if doc == nil { return }
testing.expect(t, doc.owns_data, "open_file must take ownership of the bytes it read")
testing.expect_value(t, page_count(doc), 1)
// The test allocator tracks leaks, so this call is the assertion: if close
// failed to free the file buffer it owns, the suite reports it.
close(doc)
}
@(test)
test_open_file_missing :: proc(t: ^testing.T) {
doc, err := open_file("this_file_does_not_exist_pdf_odin.pdf")
testing.expect_value(t, err, Error.Cant_Open_File)
testing.expect(t, doc == nil, "a failed open must not hand back a document")
}
@(test)
test_open_bytes_rejects_empty :: proc(t: ^testing.T) {
doc, err := open_bytes(nil)
testing.expect_value(t, err, Error.Not_A_PDF)
testing.expect(t, doc == nil, "")
}
// ---------------------------------------------------------------------------
// Version and header
// ---------------------------------------------------------------------------
@(test)
test_document_version :: proc(t: ^testing.T) {
check :: proc(t: ^testing.T, header, catalog_extra: string, want_major, want_minor: int) {
pb := pb_make(header)
defer pb_destroy(&pb)
pb_obj(&pb, 1, fmt.tprintf("<< /Type /Catalog /Pages 2 0 R %s >>", catalog_extra))
pb_obj(&pb, 2, "<< /Type /Pages /Kids [] /Count 0 >>")
at := pb_classic_xref(&pb)
pb_trailer(&pb, "<< /Size 3 /Root 1 0 R >>", at)
doc, err := open_bytes(pb.buf[:], Open_Options{skip_page_tree = true})
if !testing.expect(t, err == .None && doc != nil, "should open") { return }
defer close(doc)
major, minor := document_version(doc)
testing.expect_value(t, major, want_major)
testing.expect_value(t, minor, want_minor)
}
check(t, "%PDF-1.4\n", "", 1, 4)
// An incremental update may raise the version without rewriting the header.
check(t, "%PDF-1.4\n", "/Version /1.7", 1, 7)
// It may not lower it.
check(t, "%PDF-1.7\n", "/Version /1.4", 1, 7)
check(t, "%PDF-2.0\n", "", 2, 0)
// Garbage in /Version must be ignored, not read as arithmetic on bytes.
check(t, "%PDF-1.4\n", "/Version /XX", 1, 4)
check(t, "%PDF-1.4\n", "/Version /A.B", 1, 4)
check(t, "%PDF-1.4\n", "/Version /1", 1, 4)
}
// Junk before %PDF- is common: a mail header, an HTTP response, a wrapper.
// Every offset in such a file is relative to the header, not to zero.
@(test)
test_header_offset :: proc(t: ^testing.T) {
pb := pb_sample_classic()
defer pb_destroy(&pb)
JUNK :: "GARBAGE BEFORE THE HEADER\r\n"
shifted := make([dynamic]byte)
defer delete(shifted)
append(&shifted, JUNK)
append(&shifted, ..pb.buf[:])
doc, err := open_bytes(shifted[:], Open_Options{skip_page_tree = true})
testing.expect_value(t, err, Error.None)
if doc == nil { return }
defer close(doc)
testing.expect_value(t, doc.header_offset, i64(len(JUNK)))
testing.expect(t, get_dict(doc, Ref{1, 0}) != nil, "objects must still resolve")
}
// ---------------------------------------------------------------------------
// Memory accounting
// ---------------------------------------------------------------------------
@(test)
test_document_memory_used :: proc(t: ^testing.T) {
testing.expect_value(t, document_memory_used(nil), 0)
testing.expect_value(t, document_memory_reserved(nil), 0)
pb := pb_sample_classic()
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
if !testing.expect(t, err == .None && doc != nil, "should open") { return }
defer close(doc)
used := document_memory_used(doc)
testing.expect(t, used > 0, "an opened document has parsed something")
testing.expect(t, document_memory_reserved(doc) >= used, "reserved covers used")
// Parsing more must be visible, since this is the only way a caller can
// account for a document's footprint.
_ = get_dict(doc, Ref{3, 0})
testing.expect(t, document_memory_used(doc) >= used, "usage does not go backwards")
}
// ---------------------------------------------------------------------------
// Repairs
// ---------------------------------------------------------------------------
@(test)
test_add_repair_is_bounded :: proc(t: ^testing.T) {
pb := pb_sample_classic()
defer pb_destroy(&pb)
doc, err := pb_open(&pb)
if !testing.expect(t, err == .None && doc != nil, "should open") { return }
defer close(doc)
for _ in 0 ..< MAX_REPAIRS + 100 {
add_repair(doc, .Missing_Object, 0, Ref{1, 0})
}
testing.expect(t, len(doc.repairs) <= MAX_REPAIRS, "the repair log must be capped")
testing.expect(t, doc.is_damaged, "recording a repair marks the document damaged")
// A nil document must be safe to report against.
add_repair(nil, .Missing_Object, 0, {})
}
@(test)
test_repair_describe :: proc(t: ^testing.T) {
with_ref := repair_describe(Repair{kind = .Bad_Stream_Length, offset = 42, ref = Ref{7, 0}})
defer delete(with_ref)
testing.expect(t, len(with_ref) > 0, "")
no_ref := repair_describe(Repair{kind = .Rebuilt_Xref, offset = 9})
defer delete(no_ref)
testing.expect(t, len(no_ref) > 0, "")
// Every kind must have a description, so a log line never says "unknown".
for k in Repair_Kind {
testing.expect(t, repair_string(k) != "unknown repair", "every repair kind needs a description")
}
for e in Error {
testing.expect(t, error_string(e) != "unknown error", "every error needs a description")
}
}