diff --git a/contentstream/scanner_test.go b/contentstream/scanner_test.go index fd91e4e..4d0f9cd 100644 --- a/contentstream/scanner_test.go +++ b/contentstream/scanner_test.go @@ -294,3 +294,54 @@ func FuzzScanner(f *testing.F) { } }) } + +// One BDC property dict, deliberately built to hit every readValue value kind. +func TestBDCPropertyValueKinds(t *testing.T) { + src := `/P << /B true /F false /N null /Num 1 /Nm /X /S (s) /A [1 2] /D << /Inner 9 >> >> BDC` + ops := collect(t, src) + if len(ops) != 1 || ops[0].Operator != "BDC" { + t.Fatalf("want one BDC op, got %+v", ops) + } + d := ops[0].Operands[1].Dict + wantKind := map[string]contentstream.Kind{ + "B": contentstream.KindBool, + "F": contentstream.KindBool, + "N": contentstream.KindNull, + "Num": contentstream.KindNumber, + "Nm": contentstream.KindName, + "S": contentstream.KindString, + "A": contentstream.KindArray, + "D": contentstream.KindDict, + } + for k, want := range wantKind { + if d[k].Kind != want { + t.Errorf("%s.Kind = %v, want %v", k, d[k].Kind, want) + } + } + if !d["B"].Bool || d["F"].Bool { + t.Errorf("bool values wrong: B=%v F=%v, want true/false", d["B"].Bool, d["F"].Bool) + } + if string(d["S"].Bytes) != "s" { + t.Errorf("string value = %q, want s", d["S"].Bytes) + } + if len(d["A"].Array) != 2 { + t.Errorf("array value len = %d, want 2", len(d["A"].Array)) + } + if d["D"].Dict["Inner"].Kind != contentstream.KindNumber { + t.Errorf("nested dict /Inner kind = %v, want Number", d["D"].Dict["Inner"].Kind) + } +} + +func TestReadDictValueErrors(t *testing.T) { + for _, src := range []string{ + "/P << /K", // EOF mid-value + "/P << /K ] >> BDC", // delimiter where a value is expected + "/P << /K foo >> BDC", // unexpected keyword where a value is expected + } { + t.Run(src, func(t *testing.T) { + if _, err := contentstream.New([]byte(src)).Next(); err == nil { + t.Fatal("expected an error, got nil") + } + }) + } +} diff --git a/filter_test.go b/filter_test.go index 51aab90..6ac7cdf 100644 --- a/filter_test.go +++ b/filter_test.go @@ -101,3 +101,32 @@ func TestStreamFilterChainArray(t *testing.T) { t.Fatalf("chained decode = %q, want %q", got, orig) } } + +func TestParamsFromDict(t *testing.T) { + d := newDict(nil) + d.set("Predictor", Integer(12)) + d.set("Columns", Integer(5)) + d.set("Colors", Integer(3)) + d.set("BitsPerComponent", Integer(16)) + d.set("EarlyChange", Integer(0)) + p := paramsFromDict(d) + if p.Predictor != 12 || p.Columns != 5 || p.Colors != 3 || p.BitsPerComponent != 16 { + t.Errorf("predictor params = %+v, want Predictor=12 Columns=5 Colors=3 BitsPerComponent=16", p) + } + if !p.NoEarlyChange { + t.Error("/EarlyChange 0 must set NoEarlyChange") + } + + // 1 is the LZW default, so it must NOT set NoEarlyChange. + d1 := newDict(nil) + d1.set("EarlyChange", Integer(1)) + if paramsFromDict(d1).NoEarlyChange { + t.Error("/EarlyChange 1 must not set NoEarlyChange") + } + + empty := paramsFromDict(newDict(nil)) + if empty.Predictor != 0 || empty.Columns != 0 || empty.Colors != 0 || + empty.BitsPerComponent != 0 || empty.NoEarlyChange { + t.Errorf("empty dict = %+v, want zero Params", empty) + } +} diff --git a/internal/crypt/crypt_test.go b/internal/crypt/crypt_test.go index b514775..33a9575 100644 --- a/internal/crypt/crypt_test.go +++ b/internal/crypt/crypt_test.go @@ -315,3 +315,59 @@ func TestComputeV5KeyRejectsShortEntries(t *testing.T) { }) } } + +// Owner-password path. Unlike the user path, the owner hash mixes in the +// 48-byte /U entry (§7.6.4.4.10) — so userEntry here must be well-formed. +func TestComputeV5KeyOwnerPath(t *testing.T) { + const R = 6 + password := []byte("owner-pw") + fileKey := bytes.Repeat([]byte{0x37}, 32) + + // All-zero validation hash (first 32 bytes) can never equal v5Hash output, + // forcing the user path to miss so the owner path is taken. + uVS := bytes.Repeat([]byte{0x11}, 8) + uKS := bytes.Repeat([]byte{0x22}, 8) + userEntry := append(append(make([]byte, 32), uVS...), uKS...) // 48 bytes + + oVS := bytes.Repeat([]byte{0x33}, 8) + oKS := bytes.Repeat([]byte{0x44}, 8) + oValHash, err := v5Hash(password, oVS, userEntry, R) + if err != nil { + t.Fatalf("v5Hash(owner validation): %v", err) + } + oKeyHash, err := v5Hash(password, oKS, userEntry, R) + if err != nil { + t.Fatalf("v5Hash(owner key): %v", err) + } + oe := aesCBCEncryptRaw(t, oKeyHash, make([]byte, aes.BlockSize), fileKey) + ownerEntry := append(append(append([]byte{}, oValHash...), oVS...), oKS...) + + p := Params{ + V: 5, R: R, + UserEntry: userEntry, + OwnerEntry: ownerEntry, + UE: make([]byte, 32), // present but never reached + OE: oe, + } + key, err := computeV5Key(p, password) + if err != nil { + t.Fatalf("computeV5Key: %v", err) + } + if !bytes.Equal(key, fileKey) { + t.Fatalf("owner-path key mismatch:\n got %x\nwant %x", key, fileKey) + } +} + +func TestComputeV5KeyWrongPassword(t *testing.T) { + p := Params{ + V: 5, R: 6, + UserEntry: bytes.Repeat([]byte{0x01}, 48), + OwnerEntry: bytes.Repeat([]byte{0x02}, 48), + UE: bytes.Repeat([]byte{0x03}, 32), + OE: bytes.Repeat([]byte{0x04}, 32), + } + overlong := bytes.Repeat([]byte{'z'}, 200) // > 127: exercises the spec truncation + if _, err := computeV5Key(p, overlong); err == nil { + t.Fatal("expected an error for a non-matching password, got nil") + } +} diff --git a/internal/filter/filter_test.go b/internal/filter/filter_test.go index 2a25556..d6395c2 100644 --- a/internal/filter/filter_test.go +++ b/internal/filter/filter_test.go @@ -5,6 +5,7 @@ import ( "compress/lzw" "compress/zlib" "encoding/ascii85" + "errors" "fmt" "testing" ) @@ -396,3 +397,38 @@ func FuzzDecode(f *testing.F) { } }) } + +// Expected values hand-computed from PNG §6.6 (not copied from output). +// paeth(0,0,10) makes p=a+b-c negative — the one vector here that reaches +// abs's negative branch; don't drop it. +func TestPaeth(t *testing.T) { + cases := []struct { + a, b, c, want byte + }{ + {0, 0, 0, 0}, + {1, 2, 3, 1}, // p=0: pa=1 pb=2 pc=3 -> a + {255, 0, 0, 255}, // p=255: pa=0 -> a + {0, 0, 10, 0}, // p=-10: pa=10 pb=10 pc=20 -> a (negative estimate) + {10, 20, 11, 20}, // p=19: pa=9 pb=1 pc=8 -> b + {0, 10, 6, 6}, // p=4: pa=4 pb=6 pc=2 -> c + } + for _, tc := range cases { + if got := paeth(tc.a, tc.b, tc.c); got != tc.want { + t.Errorf("paeth(%d,%d,%d) = %d, want %d", tc.a, tc.b, tc.c, got, tc.want) + } + } +} + +func TestErrUnsupported(t *testing.T) { + _, err := Decode("DCTDecode", []byte("x"), Params{}) + var unsup ErrUnsupported + if !errors.As(err, &unsup) { + t.Fatalf("Decode(DCTDecode) error = %v, want ErrUnsupported", err) + } + if unsup.Name != "DCTDecode" { + t.Errorf("ErrUnsupported.Name = %q, want DCTDecode", unsup.Name) + } + if want := `pdfdisassembler/filter: unsupported filter "DCTDecode"`; unsup.Error() != want { + t.Errorf("Error() = %q, want %q", unsup.Error(), want) + } +} diff --git a/internal/lex/lex_test.go b/internal/lex/lex_test.go index 3c470f1..b587324 100644 --- a/internal/lex/lex_test.go +++ b/internal/lex/lex_test.go @@ -1,6 +1,7 @@ package lex import ( + "bytes" "testing" ) @@ -199,3 +200,22 @@ func FuzzLexer(f *testing.F) { } }) } + +// Position accessors that back the xref reader's manual seeking. +func TestLexerAccessors(t *testing.T) { + src := []byte("hello world") + lx := New(src) + if lx.Pos() != 0 { + t.Errorf("initial Pos = %d, want 0", lx.Pos()) + } + if !bytes.Equal(lx.Source(), src) { + t.Errorf("Source = %q, want %q", lx.Source(), src) + } + lx.SetPos(6) + if lx.Pos() != 6 { + t.Errorf("Pos after SetPos(6) = %d, want 6", lx.Pos()) + } + if got := lx.Remaining(); string(got) != "world" { + t.Errorf("Remaining = %q, want world", got) + } +} diff --git a/reader_test.go b/reader_test.go index e6b3985..1a9413d 100644 --- a/reader_test.go +++ b/reader_test.go @@ -431,3 +431,35 @@ func TestVersionCatalogOverride(t *testing.T) { t.Errorf("Version = %q, want 2.0 (catalog override)", v) } } + +// A non-Reference /Length resolves to itself, so a bare Reader (no xref) drives +// the missing/non-integer/negative guards directly. +func TestStreamLengthRejectsBadLength(t *testing.T) { + r := &Reader{} + bad := []struct { + name string + set func(d *Dict) + }{ + {"missing", func(d *Dict) {}}, + {"non_integer", func(d *Dict) { d.set("Length", String("x")) }}, + {"negative", func(d *Dict) { d.set("Length", Integer(-5)) }}, + } + for _, tc := range bad { + t.Run(tc.name, func(t *testing.T) { + d := newDict(nil) + tc.set(d) + if _, err := r.streamLength(d); err == nil { + t.Fatal("expected an error, got nil") + } + }) + } + + // Control: a valid non-negative /Length must still resolve. + t.Run("valid", func(t *testing.T) { + d := newDict(nil) + d.set("Length", Integer(42)) + if n, err := r.streamLength(d); err != nil || n != 42 { + t.Fatalf("streamLength = %d, %v; want 42, nil", n, err) + } + }) +} diff --git a/xref_test.go b/xref_test.go index 988bf0b..26f3d9c 100644 --- a/xref_test.go +++ b/xref_test.go @@ -412,3 +412,31 @@ func TestClassicalXrefHugeCountRecovers(t *testing.T) { t.Fatalf("Catalog: %v", err) } } + +// CR/LF/CRLF line-ending handling for the classical xref reader (§7.5.4). +func TestSkipEOL(t *testing.T) { + cases := []struct { + name string + buf string + pos int + want int + }{ + {"crlf", "\r\nX", 0, 2}, + {"lf", "\nX", 0, 1}, + {"lone_cr", "\rX", 0, 1}, + {"cr_at_eof", "\r", 0, 1}, + {"leading_spaces_then_lf", " \nX", 0, 3}, + {"tab_then_crlf", "\t\r\n", 0, 3}, + {"non_whitespace_stays_put", "Xyz", 0, 0}, + {"spaces_then_eof", " ", 0, 2}, + {"empty", "", 0, 0}, + {"pos_already_past_end", "ab", 2, 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := skipEOL([]byte(tc.buf), tc.pos); got != tc.want { + t.Errorf("skipEOL(%q, %d) = %d, want %d", tc.buf, tc.pos, got, tc.want) + } + }) + } +}