initial commit

This commit is contained in:
2026-09-25 23:32:29 +02:00
commit fff8b677f8
114 changed files with 84233 additions and 0 deletions
+805
View File
@@ -0,0 +1,805 @@
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})
}
}