806 lines
28 KiB
Odin
806 lines
28 KiB
Odin
package pdf
|
|||
|
|
|
||
|
|
import "core:crypto/legacy/md5"
|
||
|
|
import "core:testing"
|
||
|
|
|
||
|
|
// The sample-PDF builders live in xref_test.odin; an object stream is only
|
||
|
|
// reachable through a cross-reference stream, so the two are tested with the
|
||
|
|
// same tools.
|
||
|
|
|
||
|
|
@(private = "file")
|
||
|
|
Objstm_Opts :: struct {
|
||
|
|
// What the dictionary declares, or -1 to declare the truth.
|
||
|
|
declared_n: int,
|
||
|
|
declared_first: int,
|
||
|
|
// Overrides the /Type of the container, for the wrong-object case.
|
||
|
|
type_name: string,
|
||
|
|
// The /Index field of the type-2 entries for objects 2, 3 and 4.
|
||
|
|
indices: [3]u64,
|
||
|
|
// Write the container as a plain dictionary rather than a stream.
|
||
|
|
not_a_stream: bool,
|
||
|
|
// Point every type-2 entry at this container instead of object 1.
|
||
|
|
container: u64,
|
||
|
|
}
|
||
|
|
|
||
|
|
@(private = "file")
|
||
|
|
objstm_defaults :: proc() -> Objstm_Opts {
|
||
|
|
return Objstm_Opts{
|
||
|
|
declared_n = -1,
|
||
|
|
declared_first = -1,
|
||
|
|
indices = {0, 1, 2},
|
||
|
|
container = 1,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// build_objstm_file writes a modern file: one object stream holding the
|
||
|
|
// catalog, the page tree and a page, and a cross-reference stream pointing at
|
||
|
|
// all three.
|
||
|
|
@(private = "file")
|
||
|
|
build_objstm_file :: proc(opts: Objstm_Opts) -> Pdf_Builder {
|
||
|
|
pb := pb_make("%PDF-1.5\n")
|
||
|
|
|
||
|
|
bodies := [3]string{
|
||
|
|
"<< /Type /Catalog /Pages 3 0 R >>",
|
||
|
|
"<< /Type /Pages /Kids [4 0 R] /Count 1 >>",
|
||
|
|
"<< /Type /Page /Parent 3 0 R /MediaBox [0 0 250 250] >>",
|
||
|
|
}
|
||
|
|
nums := [3]u32{2, 3, 4}
|
||
|
|
payload, first := objstm_payload(nums[:], bodies[:])
|
||
|
|
defer delete(payload)
|
||
|
|
|
||
|
|
ty := opts.type_name if opts.type_name != "" else "/ObjStm"
|
||
|
|
|
||
|
|
pb_mark(&pb, 1)
|
||
|
|
pb_str(&pb, "1 0 obj\n<< /Type ")
|
||
|
|
pb_str(&pb, ty)
|
||
|
|
pb_str(&pb, " /N ")
|
||
|
|
pb_num(&pb, opts.declared_n >= 0 ? opts.declared_n : 3)
|
||
|
|
pb_str(&pb, " /First ")
|
||
|
|
pb_num(&pb, opts.declared_first >= 0 ? opts.declared_first : first)
|
||
|
|
if opts.not_a_stream {
|
||
|
|
pb_str(&pb, " >>\nendobj\n")
|
||
|
|
} else {
|
||
|
|
pb_str(&pb, " /Length ")
|
||
|
|
pb_num(&pb, len(payload))
|
||
|
|
pb_str(&pb, " >>\nstream\n")
|
||
|
|
pb_raw(&pb, payload)
|
||
|
|
pb_str(&pb, "\nendstream\nendobj\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
at := pb_here(&pb)
|
||
|
|
c := opts.container
|
||
|
|
rows := [6]Xref_Row{
|
||
|
|
{0, 0, 65535},
|
||
|
|
{1, u64(pb.offs[1]), 0},
|
||
|
|
{2, c, opts.indices[0]},
|
||
|
|
{2, c, opts.indices[1]},
|
||
|
|
{2, c, opts.indices[2]},
|
||
|
|
{1, u64(at), 0},
|
||
|
|
}
|
||
|
|
raw := xref_rows_encode(rows[:], 1, 4, 2)
|
||
|
|
defer delete(raw)
|
||
|
|
pb_stream_obj(&pb, 5, "/Type /XRef /Size 6 /W [1 4 2] /Root 2 0 R", raw)
|
||
|
|
pb_startxref(&pb, at)
|
||
|
|
return pb
|
||
|
|
}
|
||
|
|
|
||
|
|
@(private = "file")
|
||
|
|
expect_objstm_page :: proc(t: ^testing.T, doc: ^Document, width: f64) {
|
||
|
|
d := get_dict(doc, Ref{4, 0})
|
||
|
|
if !testing.expect(t, d != nil, "the page did not come out of the object stream") {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
testing.expect(t, dict_name(doc, d, "Type") == "Page", "object 4 is not a /Page")
|
||
|
|
r := dict_rect(doc, d, "MediaBox")
|
||
|
|
testing.expectf(t, r.x1 == width, "MediaBox width %v, wanted %v", r.x1, width)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// The ordinary case
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_basic :: proc(t: ^testing.T) {
|
||
|
|
pb := build_objstm_file(objstm_defaults())
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "open failed")
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
testing.expect(t, !doc.is_damaged, "a well-formed object stream recorded a repair")
|
||
|
|
testing.expect(t, doc.catalog != nil, "the catalog did not come out of the object stream")
|
||
|
|
testing.expect(t, doc.xref[2].kind == .Compressed, "object 2 is not a compressed entry")
|
||
|
|
testing.expect(t, dict_name(doc, get_dict(doc, Ref{3, 0}), "Type") == "Pages",
|
||
|
|
"object 3 is not the page tree root")
|
||
|
|
expect_objstm_page(t, doc, 250)
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_header_is_cached :: proc(t: ^testing.T) {
|
||
|
|
pb := build_objstm_file(objstm_defaults())
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "open failed")
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
_ = get_object(doc, Ref{2, 0})
|
||
|
|
stm, ok := doc.objstm_cache[1]
|
||
|
|
testing.expect(t, ok && stm != nil, "the object stream header was not cached")
|
||
|
|
if stm == nil { return }
|
||
|
|
testing.expectf(t, len(stm.nums) == 3, "cached %v pairs, wanted 3", len(stm.nums))
|
||
|
|
testing.expect(t, stm.nums[0] == 2 && stm.nums[1] == 3 && stm.nums[2] == 4,
|
||
|
|
"the pair table was read in the wrong order")
|
||
|
|
testing.expect(t, !stm.loading, "the loading flag was left set")
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// A pair table that disagrees with the cross-reference table
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_stale_index_is_searched_for :: proc(t: ^testing.T) {
|
||
|
|
// Every type-2 entry claims index 0. The pair table is the thing that was
|
||
|
|
// written next to the bytes, so it wins and the object is found by number.
|
||
|
|
opts := objstm_defaults()
|
||
|
|
opts.indices = {0, 0, 0}
|
||
|
|
pb := build_objstm_file(opts)
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "open failed")
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
expect_objstm_page(t, doc, 250)
|
||
|
|
testing.expect(t, dict_name(doc, get_dict(doc, Ref{3, 0}), "Type") == "Pages",
|
||
|
|
"object 3 was not found by number")
|
||
|
|
testing.expect(t, doc_has_repair(doc, .Xref_Entry_Mismatch),
|
||
|
|
"a stale index should be recorded as a repair")
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_index_past_the_end :: proc(t: ^testing.T) {
|
||
|
|
opts := objstm_defaults()
|
||
|
|
opts.indices = {900, 901, 902}
|
||
|
|
pb := build_objstm_file(opts)
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "open failed")
|
||
|
|
if doc == nil { return }
|
||
|
|
expect_objstm_page(t, doc, 250)
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_get_object_rejects_absent_number :: proc(t: ^testing.T) {
|
||
|
|
pb := build_objstm_file(objstm_defaults())
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "open failed")
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
obj, gerr := objstm_get_object(doc, 1, 0, 99)
|
||
|
|
testing.expect(t, obj == nil && gerr == .Object_Not_Found,
|
||
|
|
"an object number the stream does not hold must not resolve")
|
||
|
|
|
||
|
|
_, zerr := objstm_get_object(doc, 1, 0, 0)
|
||
|
|
testing.expect(t, zerr == .Invalid_Argument, "object 0 is not a legal request")
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// /N and /First lying
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_first_past_end_is_repaired :: proc(t: ^testing.T) {
|
||
|
|
// /First outside the decoded stream. The pair table itself says where the
|
||
|
|
// objects start, so the file is still readable.
|
||
|
|
opts := objstm_defaults()
|
||
|
|
opts.declared_first = 9999
|
||
|
|
pb := build_objstm_file(opts)
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "open failed")
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
testing.expect(t, doc_has_repair(doc, .Bad_Stream_Length), "no repair was recorded")
|
||
|
|
expect_objstm_page(t, doc, 250)
|
||
|
|
|
||
|
|
stm, ok := doc.objstm_cache[1]
|
||
|
|
if testing.expect(t, ok && stm != nil, "the object stream was not cached") {
|
||
|
|
testing.expectf(t, stm.first > 0 && stm.first <= len(stm.data),
|
||
|
|
"/First was repaired to %v, outside a %v byte stream", stm.first, len(stm.data))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_first_inside_pair_table_is_repaired :: proc(t: ^testing.T) {
|
||
|
|
// /First 1 puts the objects on top of the pair table, which cannot be.
|
||
|
|
opts := objstm_defaults()
|
||
|
|
opts.declared_first = 1
|
||
|
|
pb := build_objstm_file(opts)
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "open failed")
|
||
|
|
if doc == nil { return }
|
||
|
|
testing.expect(t, doc_has_repair(doc, .Bad_Stream_Length), "no repair was recorded")
|
||
|
|
// Reading the pair table again without the bogus ceiling recovers the file
|
||
|
|
// completely.
|
||
|
|
expect_objstm_page(t, doc, 250)
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_n_too_large_is_clamped :: proc(t: ^testing.T) {
|
||
|
|
opts := objstm_defaults()
|
||
|
|
opts.declared_n = 100_000_000
|
||
|
|
pb := build_objstm_file(opts)
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "open failed")
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
stm, ok := doc.objstm_cache[1]
|
||
|
|
if testing.expect(t, ok && stm != nil, "the object stream was not read at all") {
|
||
|
|
testing.expectf(t, len(stm.nums) <= 64, "read %v pairs from a 3-object stream",
|
||
|
|
len(stm.nums))
|
||
|
|
}
|
||
|
|
expect_objstm_page(t, doc, 250)
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_n_missing_is_derived :: proc(t: ^testing.T) {
|
||
|
|
opts := objstm_defaults()
|
||
|
|
opts.declared_n = 0
|
||
|
|
pb := build_objstm_file(opts)
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "open failed")
|
||
|
|
if doc == nil { return }
|
||
|
|
expect_objstm_page(t, doc, 250)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Containers that are not object streams
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// The cross-reference stream says objects 2, 3 and 4 live inside object 1, but
|
||
|
|
// object 1 was written as a plain dictionary. There are no bytes to take
|
||
|
|
// apart, so the lookup has to fail rather than parse whatever follows the
|
||
|
|
// dictionary in the file -- which is the cross-reference stream itself.
|
||
|
|
@(test)
|
||
|
|
test_objstm_container_is_not_a_stream :: proc(t: ^testing.T) {
|
||
|
|
opts := objstm_defaults()
|
||
|
|
opts.not_a_stream = true
|
||
|
|
pb := build_objstm_file(opts)
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, _ := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
obj, gerr := objstm_get_object(doc, 1, 0, 2)
|
||
|
|
testing.expect(t, obj == nil, "a dictionary was taken apart as an object stream")
|
||
|
|
// Which refusal comes back depends on whether the container has been tried
|
||
|
|
// before -- `find_catalog` already walked the file during open, and a
|
||
|
|
// container that failed once is remembered as failed. Both are clean.
|
||
|
|
testing.expectf(t, gerr == .Object_Not_Found || gerr == .Bad_Stream,
|
||
|
|
"container that is not a stream: got %s", error_string(gerr))
|
||
|
|
|
||
|
|
// The container is remembered as failed, so the second and third objects
|
||
|
|
// do not each retry the decode.
|
||
|
|
stm, cached := doc.objstm_cache[1]
|
||
|
|
testing.expect(t, cached && stm != nil, "the failed container was not cached")
|
||
|
|
if stm != nil {
|
||
|
|
testing.expect(t, stm.failed, "the container was not marked failed")
|
||
|
|
testing.expect(t, !stm.loading, "the loading flag was left set")
|
||
|
|
testing.expect(t, len(stm.nums) == 0, "a failed container kept a pair table")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Everything the container was supposed to hold reads as null. Whether the
|
||
|
|
// xref still calls those entries compressed or the loader gave up on the
|
||
|
|
// section and rebuilt -- which is what happens here, and leaves 2, 3 and 4
|
||
|
|
// free, because the rebuild finds only the objects physically in the file
|
||
|
|
// -- the answer a caller gets is the same.
|
||
|
|
for num in 2 ..= 4 {
|
||
|
|
testing.expectf(t, get_object(doc, Ref{u32(num), 0}) == nil,
|
||
|
|
"object %v came back out of a container that is not a stream", num)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Silence would be the real bug: a caller has no other way to tell an
|
||
|
|
// empty document from one whose objects were lost.
|
||
|
|
testing.expect(t, doc.is_damaged, "losing three objects was not recorded as damage")
|
||
|
|
testing.expect(t, doc_has_repair(doc, .Missing_Object),
|
||
|
|
"no Missing_Object repair for the objects the container was supposed to hold")
|
||
|
|
|
||
|
|
// Object 1 itself is a perfectly good dictionary and still reads.
|
||
|
|
d := get_dict(doc, Ref{1, 0})
|
||
|
|
testing.expect(t, d != nil && dict_name(doc, d, "Type") == "ObjStm",
|
||
|
|
"the container dictionary itself stopped resolving")
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_container_has_wrong_type :: proc(t: ^testing.T) {
|
||
|
|
opts := objstm_defaults()
|
||
|
|
opts.type_name = "/XObject"
|
||
|
|
pb := build_objstm_file(opts)
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, _ := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
obj, gerr := objstm_get_object(doc, 1, 0, 2)
|
||
|
|
testing.expect(t, obj == nil && gerr == .Type_Mismatch,
|
||
|
|
"a container that says it is something else must be refused")
|
||
|
|
}
|
||
|
|
|
||
|
|
// build_split_file writes a file whose catalog and page tree are ordinary
|
||
|
|
// top-level objects and whose page lives in an object stream. /Root therefore
|
||
|
|
// resolves without touching the object stream, so a broken entry for the page
|
||
|
|
// is a broken entry rather than a reason to rebuild the whole file -- which is
|
||
|
|
// what makes the degradation visible.
|
||
|
|
@(private = "file")
|
||
|
|
build_split_file :: proc(page_container: u64, objstm_self_hosted: bool) -> Pdf_Builder {
|
||
|
|
pb := pb_make("%PDF-1.5\n")
|
||
|
|
|
||
|
|
bodies := [1]string{"<< /Type /Page /Parent 3 0 R /MediaBox [0 0 250 250] >>"}
|
||
|
|
nums := [1]u32{4}
|
||
|
|
payload, first := objstm_payload(nums[:], bodies[:])
|
||
|
|
defer delete(payload)
|
||
|
|
|
||
|
|
pb_mark(&pb, 1)
|
||
|
|
pb_str(&pb, "1 0 obj\n<< /Type /ObjStm /N 1 /First ")
|
||
|
|
pb_num(&pb, first)
|
||
|
|
pb_str(&pb, " /Length ")
|
||
|
|
pb_num(&pb, len(payload))
|
||
|
|
pb_str(&pb, " >>\nstream\n")
|
||
|
|
pb_raw(&pb, payload)
|
||
|
|
pb_str(&pb, "\nendstream\nendobj\n")
|
||
|
|
|
||
|
|
pb_obj(&pb, 2, "<< /Type /Catalog /Pages 3 0 R >>")
|
||
|
|
pb_obj(&pb, 3, "<< /Type /Pages /Kids [4 0 R] /Count 1 >>")
|
||
|
|
|
||
|
|
at := pb_here(&pb)
|
||
|
|
// Object 1 either sits in the file where it really is, or claims to live
|
||
|
|
// inside itself.
|
||
|
|
row1 := Xref_Row{1, u64(pb.offs[1]), 0}
|
||
|
|
if objstm_self_hosted {
|
||
|
|
row1 = Xref_Row{2, 1, 0}
|
||
|
|
}
|
||
|
|
rows := [6]Xref_Row{
|
||
|
|
{0, 0, 65535},
|
||
|
|
row1,
|
||
|
|
{1, u64(pb.offs[2]), 0},
|
||
|
|
{1, u64(pb.offs[3]), 0},
|
||
|
|
{2, page_container, 0},
|
||
|
|
{1, u64(at), 0},
|
||
|
|
}
|
||
|
|
raw := xref_rows_encode(rows[:], 1, 4, 2)
|
||
|
|
defer delete(raw)
|
||
|
|
pb_stream_obj(&pb, 5, "/Type /XRef /Size 6 /W [1 4 2] /Root 2 0 R", raw)
|
||
|
|
pb_startxref(&pb, at)
|
||
|
|
return pb
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_split_file_baseline :: proc(t: ^testing.T) {
|
||
|
|
pb := build_split_file(1, false)
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "open failed")
|
||
|
|
if doc == nil { return }
|
||
|
|
testing.expect(t, !doc.is_damaged, "a well-formed split file recorded a repair")
|
||
|
|
expect_objstm_page(t, doc, 250)
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_container_does_not_exist :: proc(t: ^testing.T) {
|
||
|
|
// The page claims to live inside object 90, which the file does not have.
|
||
|
|
// /Root still resolves, so nothing triggers a rebuild and the reference
|
||
|
|
// has to degrade to null on its own.
|
||
|
|
pb := build_split_file(90, false)
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "open failed")
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
testing.expect(t, get_object(doc, Ref{4, 0}) == nil,
|
||
|
|
"an entry pointing at a container that is not there must read as null")
|
||
|
|
testing.expect(t, doc.catalog != nil, "one bad entry must not cost the rest of the file")
|
||
|
|
testing.expect(t, doc_has_repair(doc, .Missing_Object), "no repair was recorded")
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Cycles
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_contains_itself :: proc(t: ^testing.T) {
|
||
|
|
// The cross-reference table says the object stream itself is one of the
|
||
|
|
// objects inside it. Reading it needs its own bytes, which needs reading
|
||
|
|
// it: without a guard this is an unbounded recursion.
|
||
|
|
pb := build_split_file(1, true)
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "open failed")
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
testing.expect(t, get_object(doc, Ref{1, 0}) == nil,
|
||
|
|
"a self-containing object stream must read as null, not recurse")
|
||
|
|
// Everything the stream held goes with it, but the rest of the file does
|
||
|
|
// not.
|
||
|
|
testing.expect(t, doc.catalog != nil, "the catalog was lost with the object stream")
|
||
|
|
testing.expect(t, doc.is_damaged, "the cycle should have been recorded")
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_mutual_cycle :: proc(t: ^testing.T) {
|
||
|
|
// Object 1 says it lives in object 2's stream, and object 2 says it lives
|
||
|
|
// in object 1's.
|
||
|
|
pb := pb_make("%PDF-1.5\n")
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
bodies := [1]string{"<< /Type /Catalog >>"}
|
||
|
|
for num in ([2]u32{1, 2}) {
|
||
|
|
nums := [1]u32{num == 1 ? 2 : 1}
|
||
|
|
payload, first := objstm_payload(nums[:], bodies[:])
|
||
|
|
defer delete(payload)
|
||
|
|
pb_mark(&pb, num)
|
||
|
|
pb_num(&pb, int(num))
|
||
|
|
pb_str(&pb, " 0 obj\n<< /Type /ObjStm /N 1 /First ")
|
||
|
|
pb_num(&pb, first)
|
||
|
|
pb_str(&pb, " /Length ")
|
||
|
|
pb_num(&pb, len(payload))
|
||
|
|
pb_str(&pb, " >>\nstream\n")
|
||
|
|
pb_raw(&pb, payload)
|
||
|
|
pb_str(&pb, "\nendstream\nendobj\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
at := pb_here(&pb)
|
||
|
|
rows := [4]Xref_Row{
|
||
|
|
{0, 0, 65535},
|
||
|
|
{2, 2, 0},
|
||
|
|
{2, 1, 0},
|
||
|
|
{1, u64(at), 0},
|
||
|
|
}
|
||
|
|
raw := xref_rows_encode(rows[:], 1, 4, 2)
|
||
|
|
defer delete(raw)
|
||
|
|
pb_stream_obj(&pb, 3, "/Type /XRef /Size 4 /W [1 4 2] /Root 1 0 R", raw)
|
||
|
|
pb_startxref(&pb, at)
|
||
|
|
|
||
|
|
doc, _ := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
if doc == nil { return }
|
||
|
|
_ = get_object(doc, Ref{1, 0})
|
||
|
|
_ = get_object(doc, Ref{2, 0})
|
||
|
|
testing.expect(t, true, "a mutual object-stream cycle must terminate")
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Object streams found by a rebuild
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_found_by_rebuild :: proc(t: ^testing.T) {
|
||
|
|
// The cross-reference stream is unreachable, so every object in the file
|
||
|
|
// has to be found by scanning -- and three of the four only exist inside
|
||
|
|
// an object stream.
|
||
|
|
pb := build_objstm_file(objstm_defaults())
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
// Point startxref at nothing.
|
||
|
|
at := last_index_bytes(pb.buf[:], transmute([]byte)string("startxref"))
|
||
|
|
testing.expect(t, at > 0, "the sample has no startxref")
|
||
|
|
if at <= 0 { return }
|
||
|
|
resize(&pb.buf, at)
|
||
|
|
pb_str(&pb, "startxref\n99000000\n%%EOF\n")
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "a rebuild should have opened this file")
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
testing.expect(t, doc_has_repair(doc, .Rebuilt_Xref), "no rebuild was recorded")
|
||
|
|
testing.expect(t, doc.xref[4].kind == .Compressed,
|
||
|
|
"the rebuild did not register the object stream's contents")
|
||
|
|
testing.expect(t, doc.catalog != nil, "the catalog inside the object stream was lost")
|
||
|
|
expect_objstm_page(t, doc, 250)
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_rebuild_prefers_later_top_level_definition :: proc(t: ^testing.T) {
|
||
|
|
// The file was updated by appending an uncompressed object 4, which is
|
||
|
|
// newer than the object stream and must win.
|
||
|
|
pb := build_objstm_file(objstm_defaults())
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
at := last_index_bytes(pb.buf[:], transmute([]byte)string("startxref"))
|
||
|
|
if at <= 0 { return }
|
||
|
|
resize(&pb.buf, at)
|
||
|
|
pb_obj(&pb, 4, "<< /Type /Page /Parent 3 0 R /MediaBox [0 0 700 700] >>")
|
||
|
|
pb_str(&pb, "startxref\n99000000\n%%EOF\n")
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expect(t, err == .None, "a rebuild should have opened this file")
|
||
|
|
if doc == nil { return }
|
||
|
|
expect_objstm_page(t, doc, 700)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Damaged payloads
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_truncation_survives :: proc(t: ^testing.T) {
|
||
|
|
pb := build_objstm_file(objstm_defaults())
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
for cut := 1; cut <= len(pb.buf); cut += 5 {
|
||
|
|
doc, _ := open_bytes(pb.buf[:cut], Open_Options{skip_page_tree = true})
|
||
|
|
if doc != nil {
|
||
|
|
for num in 1 ..< object_count(doc) {
|
||
|
|
_ = get_object(doc, Ref{u32(num), 0})
|
||
|
|
}
|
||
|
|
close(doc)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
testing.expect(t, true, "a truncated object stream must never crash or hang")
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_garbage_payload_survives :: proc(t: ^testing.T) {
|
||
|
|
cases := [?]string{
|
||
|
|
"",
|
||
|
|
" ",
|
||
|
|
"1",
|
||
|
|
"1 ",
|
||
|
|
"1 0",
|
||
|
|
"1 999999999999 << >>",
|
||
|
|
"-4 -4 << >>",
|
||
|
|
"1 0 <<",
|
||
|
|
"99999999999999999999 0 << >>",
|
||
|
|
}
|
||
|
|
for body in cases {
|
||
|
|
pb := pb_make("%PDF-1.5\n")
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
payload := transmute([]byte)body
|
||
|
|
pb_mark(&pb, 1)
|
||
|
|
pb_str(&pb, "1 0 obj\n<< /Type /ObjStm /N 1 /First 4 /Length ")
|
||
|
|
pb_num(&pb, len(payload))
|
||
|
|
pb_str(&pb, " >>\nstream\n")
|
||
|
|
pb_raw(&pb, payload)
|
||
|
|
pb_str(&pb, "\nendstream\nendobj\n")
|
||
|
|
|
||
|
|
at := pb_here(&pb)
|
||
|
|
rows := [3]Xref_Row{
|
||
|
|
{0, 0, 65535},
|
||
|
|
{1, u64(pb.offs[1]), 0},
|
||
|
|
{1, u64(at), 0},
|
||
|
|
}
|
||
|
|
raw := xref_rows_encode(rows[:], 1, 4, 2)
|
||
|
|
defer delete(raw)
|
||
|
|
pb_stream_obj(&pb, 2, "/Type /XRef /Size 3 /W [1 4 2] /Root 1 0 R", raw)
|
||
|
|
pb_startxref(&pb, at)
|
||
|
|
|
||
|
|
doc, _ := pb_open(&pb)
|
||
|
|
if doc != nil {
|
||
|
|
_, _ = objstm_get_object(doc, 1, 0, 7)
|
||
|
|
close(doc)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
testing.expect(t, true, "a garbage object-stream payload must never crash")
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Encrypted documents
|
||
|
|
//
|
||
|
|
// A modern encrypted file keeps its catalog inside an object stream, and that
|
||
|
|
// stream cannot be decoded until the file key exists -- which is after the
|
||
|
|
// cross-reference table has been read. Two things in xref.odin exist for this
|
||
|
|
// case and are only exercised here: /Root is not resolved as a validity check
|
||
|
|
// when /Encrypt is present, and every object and object stream read during the
|
||
|
|
// load is dropped afterwards, because all of it was read in the dark.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@(private = "file")
|
||
|
|
rc4_object_key :: proc(file_key: []byte, num: u32, gen: u16, out: []byte) -> int {
|
||
|
|
ctx: md5.Context
|
||
|
|
md5.init(&ctx)
|
||
|
|
md5.update(&ctx, file_key)
|
||
|
|
tail := [5]byte{
|
||
|
|
byte(num), byte(num >> 8), byte(num >> 16),
|
||
|
|
byte(gen), byte(gen >> 8),
|
||
|
|
}
|
||
|
|
md5.update(&ctx, tail[:])
|
||
|
|
digest: [md5.DIGEST_SIZE]byte
|
||
|
|
md5.final(&ctx, digest[:])
|
||
|
|
n := min(len(file_key) + 5, 16)
|
||
|
|
copy(out[:n], digest[:n])
|
||
|
|
return n
|
||
|
|
}
|
||
|
|
|
||
|
|
@(private = "file")
|
||
|
|
pb_hex :: proc(pb: ^Pdf_Builder, data: []byte) {
|
||
|
|
hex := transmute([]byte)string("0123456789abcdef")
|
||
|
|
pb_str(pb, "<")
|
||
|
|
for c in data {
|
||
|
|
append(&pb.buf, hex[c >> 4])
|
||
|
|
append(&pb.buf, hex[c & 0x0f])
|
||
|
|
}
|
||
|
|
pb_str(pb, ">")
|
||
|
|
}
|
||
|
|
|
||
|
|
// build_encrypted_objstm_file writes an RC4 40-bit, revision 2 document with an
|
||
|
|
// empty user password -- the oldest scheme, and the one whose key derivation is
|
||
|
|
// short enough to spell out here -- whose catalog, page tree and page all live
|
||
|
|
// inside an encrypted object stream.
|
||
|
|
@(private = "file")
|
||
|
|
build_encrypted_objstm_file :: proc() -> Pdf_Builder {
|
||
|
|
id0 := [16]byte{
|
||
|
|
0x2b, 0x1c, 0x7d, 0x0e, 0x55, 0x91, 0xa3, 0x44,
|
||
|
|
0x10, 0xff, 0x6c, 0x22, 0x83, 0x39, 0xd7, 0x00,
|
||
|
|
}
|
||
|
|
permissions := i32(-1)
|
||
|
|
|
||
|
|
// /O, Algorithm 3 with an empty owner password.
|
||
|
|
padded: [32]byte
|
||
|
|
crypt_pad_password(nil, padded[:])
|
||
|
|
ctx: md5.Context
|
||
|
|
md5.init(&ctx)
|
||
|
|
md5.update(&ctx, padded[:])
|
||
|
|
digest: [md5.DIGEST_SIZE]byte
|
||
|
|
md5.final(&ctx, digest[:])
|
||
|
|
o_value: [32]byte
|
||
|
|
crypt_pad_password(nil, o_value[:])
|
||
|
|
rc4_crypt(digest[:5], o_value[:], o_value[:])
|
||
|
|
|
||
|
|
file_key: [5]byte
|
||
|
|
crypt_key_r234(nil, o_value[:], permissions, id0[:], 2, 5, true, file_key[:])
|
||
|
|
|
||
|
|
u_value: [32]byte
|
||
|
|
crypt_user_value(file_key[:], 2, id0[:], u_value[:])
|
||
|
|
|
||
|
|
bodies := [3]string{
|
||
|
|
"<< /Type /Catalog /Pages 3 0 R >>",
|
||
|
|
"<< /Type /Pages /Kids [4 0 R] /Count 1 >>",
|
||
|
|
"<< /Type /Page /Parent 3 0 R /MediaBox [0 0 321 321] >>",
|
||
|
|
}
|
||
|
|
nums := [3]u32{2, 3, 4}
|
||
|
|
plain, first := objstm_payload(nums[:], bodies[:])
|
||
|
|
defer delete(plain)
|
||
|
|
|
||
|
|
// The whole object stream is encrypted with object 1's key; the objects
|
||
|
|
// inside it are then in the clear (ISO 32000-2, 7.5.7).
|
||
|
|
cipher := make([]byte, len(plain))
|
||
|
|
defer delete(cipher)
|
||
|
|
okey: [16]byte
|
||
|
|
n_key := rc4_object_key(file_key[:], 1, 0, okey[:])
|
||
|
|
rc4_crypt(okey[:n_key], cipher, plain)
|
||
|
|
|
||
|
|
pb := pb_make("%PDF-1.5\n")
|
||
|
|
|
||
|
|
pb_mark(&pb, 1)
|
||
|
|
pb_str(&pb, "1 0 obj\n<< /Type /ObjStm /N 3 /First ")
|
||
|
|
pb_num(&pb, first)
|
||
|
|
pb_str(&pb, " /Length ")
|
||
|
|
pb_num(&pb, len(cipher))
|
||
|
|
pb_str(&pb, " >>\nstream\n")
|
||
|
|
pb_raw(&pb, cipher)
|
||
|
|
pb_str(&pb, "\nendstream\nendobj\n")
|
||
|
|
|
||
|
|
// The /Encrypt dictionary's own strings are never encrypted: they are what
|
||
|
|
// the key is derived from.
|
||
|
|
pb_mark(&pb, 5)
|
||
|
|
pb_str(&pb, "5 0 obj\n<< /Filter /Standard /V 1 /R 2 /P -1 /O ")
|
||
|
|
pb_hex(&pb, o_value[:])
|
||
|
|
pb_str(&pb, " /U ")
|
||
|
|
pb_hex(&pb, u_value[:])
|
||
|
|
pb_str(&pb, " >>\nendobj\n")
|
||
|
|
|
||
|
|
at := pb_here(&pb)
|
||
|
|
rows := [7]Xref_Row{
|
||
|
|
{0, 0, 65535},
|
||
|
|
{1, u64(pb.offs[1]), 0},
|
||
|
|
{2, 1, 0},
|
||
|
|
{2, 1, 1},
|
||
|
|
{2, 1, 2},
|
||
|
|
{1, u64(pb.offs[5]), 0},
|
||
|
|
{1, u64(at), 0},
|
||
|
|
}
|
||
|
|
raw := xref_rows_encode(rows[:], 1, 4, 2)
|
||
|
|
defer delete(raw)
|
||
|
|
|
||
|
|
// A cross-reference stream is never encrypted either, so it is written in
|
||
|
|
// the clear and its /ID strings stay as they are.
|
||
|
|
pb_mark(&pb, 6)
|
||
|
|
pb_str(&pb, "6 0 obj\n<< /Type /XRef /Size 7 /W [1 4 2] /Root 2 0 R /Encrypt 5 0 R /ID [")
|
||
|
|
pb_hex(&pb, id0[:])
|
||
|
|
pb_str(&pb, " ")
|
||
|
|
pb_hex(&pb, id0[:])
|
||
|
|
pb_str(&pb, "] /Length ")
|
||
|
|
pb_num(&pb, len(raw))
|
||
|
|
pb_str(&pb, " >>\nstream\n")
|
||
|
|
pb_raw(&pb, raw)
|
||
|
|
pb_str(&pb, "\nendstream\nendobj\n")
|
||
|
|
pb_startxref(&pb, at)
|
||
|
|
return pb
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_encrypted_catalog_in_object_stream :: proc(t: ^testing.T) {
|
||
|
|
pb := build_encrypted_objstm_file()
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expectf(t, err == .None, "open failed: %v", error_string(err))
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
testing.expect(t, document_is_encrypted(doc), "the document is not marked encrypted")
|
||
|
|
testing.expect(t, !doc_has_repair(doc, .Rebuilt_Xref),
|
||
|
|
"an encrypted catalog inside an object stream must not force a rebuild")
|
||
|
|
testing.expect(t, doc.catalog != nil, "the catalog was not decrypted")
|
||
|
|
testing.expect(t, dict_name(doc, doc.catalog, "Type") == "Catalog",
|
||
|
|
"the catalog decoded to something else")
|
||
|
|
expect_objstm_page(t, doc, 321)
|
||
|
|
}
|
||
|
|
|
||
|
|
@(test)
|
||
|
|
test_objstm_encrypted_rebuild_degrades_cleanly :: proc(t: ^testing.T) {
|
||
|
|
// An encrypted file that also needs a rebuild is the one case where the
|
||
|
|
// compressed objects genuinely cannot be recovered: the file key is not
|
||
|
|
// derived until after the table is read, so the object streams decode to
|
||
|
|
// ciphertext. What must not happen is that the garbage gets registered as
|
||
|
|
// if it were a pair table -- the invented entries would overwrite the real
|
||
|
|
// ones the scan found.
|
||
|
|
pb := build_encrypted_objstm_file()
|
||
|
|
defer pb_destroy(&pb)
|
||
|
|
|
||
|
|
at := last_index_bytes(pb.buf[:], transmute([]byte)string("startxref"))
|
||
|
|
testing.expect(t, at > 0, "the sample has no startxref")
|
||
|
|
if at <= 0 { return }
|
||
|
|
resize(&pb.buf, at)
|
||
|
|
pb_str(&pb, "startxref\n99000000\n%%EOF\n")
|
||
|
|
|
||
|
|
doc, err := pb_open(&pb)
|
||
|
|
defer close(doc)
|
||
|
|
testing.expectf(t, err == .None, "open failed: %v", error_string(err))
|
||
|
|
if doc == nil { return }
|
||
|
|
|
||
|
|
testing.expect(t, doc_has_repair(doc, .Rebuilt_Xref), "no rebuild was recorded")
|
||
|
|
testing.expect(t, document_is_encrypted(doc),
|
||
|
|
"the /Encrypt dictionary survived the rebuild, so the file is still encrypted")
|
||
|
|
// The three objects in the stream are gone, but the objects that were
|
||
|
|
// written at the top level are exactly where the scan found them.
|
||
|
|
testing.expect(t, doc.xref[1].kind == .Normal, "the object stream itself was lost")
|
||
|
|
testing.expect(t, doc.xref[5].kind == .Normal, "the /Encrypt dictionary was lost")
|
||
|
|
for num in 1 ..< object_count(doc) {
|
||
|
|
testing.expect(t, doc.xref[num].kind != .Compressed,
|
||
|
|
"ciphertext was read as a pair table")
|
||
|
|
_ = get_object(doc, Ref{u32(num), 0})
|
||
|
|
}
|
||
|
|
}
|