Skip to content

Commit 9d54e5c

Browse files
committed
Cover under-tested parser helper branches
Backfill characterization tests for logic-bearing untrusted-input paths that were below coverage; no production code changed. - xref skipEOL: CR/LF/CRLF/leading-space line-ending rules - filter paramsFromDict: predictor knobs + /EarlyChange semantics - reader streamLength: missing/non-integer/negative /Length guards - contentstream readValue: every value kind + EOF/delimiter/keyword errors - internal/filter paeth/abs + ErrUnsupported message - crypt computeV5Key: owner-password path + >127 truncation - lex position accessors
1 parent 893cbe3 commit 9d54e5c

7 files changed

Lines changed: 252 additions & 0 deletions

File tree

contentstream/scanner_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,54 @@ func FuzzScanner(f *testing.F) {
294294
}
295295
})
296296
}
297+
298+
// One BDC property dict, deliberately built to hit every readValue value kind.
299+
func TestBDCPropertyValueKinds(t *testing.T) {
300+
src := `/P << /B true /F false /N null /Num 1 /Nm /X /S (s) /A [1 2] /D << /Inner 9 >> >> BDC`
301+
ops := collect(t, src)
302+
if len(ops) != 1 || ops[0].Operator != "BDC" {
303+
t.Fatalf("want one BDC op, got %+v", ops)
304+
}
305+
d := ops[0].Operands[1].Dict
306+
wantKind := map[string]contentstream.Kind{
307+
"B": contentstream.KindBool,
308+
"F": contentstream.KindBool,
309+
"N": contentstream.KindNull,
310+
"Num": contentstream.KindNumber,
311+
"Nm": contentstream.KindName,
312+
"S": contentstream.KindString,
313+
"A": contentstream.KindArray,
314+
"D": contentstream.KindDict,
315+
}
316+
for k, want := range wantKind {
317+
if d[k].Kind != want {
318+
t.Errorf("%s.Kind = %v, want %v", k, d[k].Kind, want)
319+
}
320+
}
321+
if !d["B"].Bool || d["F"].Bool {
322+
t.Errorf("bool values wrong: B=%v F=%v, want true/false", d["B"].Bool, d["F"].Bool)
323+
}
324+
if string(d["S"].Bytes) != "s" {
325+
t.Errorf("string value = %q, want s", d["S"].Bytes)
326+
}
327+
if len(d["A"].Array) != 2 {
328+
t.Errorf("array value len = %d, want 2", len(d["A"].Array))
329+
}
330+
if d["D"].Dict["Inner"].Kind != contentstream.KindNumber {
331+
t.Errorf("nested dict /Inner kind = %v, want Number", d["D"].Dict["Inner"].Kind)
332+
}
333+
}
334+
335+
func TestReadDictValueErrors(t *testing.T) {
336+
for _, src := range []string{
337+
"/P << /K", // EOF mid-value
338+
"/P << /K ] >> BDC", // delimiter where a value is expected
339+
"/P << /K foo >> BDC", // unexpected keyword where a value is expected
340+
} {
341+
t.Run(src, func(t *testing.T) {
342+
if _, err := contentstream.New([]byte(src)).Next(); err == nil {
343+
t.Fatal("expected an error, got nil")
344+
}
345+
})
346+
}
347+
}

filter_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,32 @@ func TestStreamFilterChainArray(t *testing.T) {
101101
t.Fatalf("chained decode = %q, want %q", got, orig)
102102
}
103103
}
104+
105+
func TestParamsFromDict(t *testing.T) {
106+
d := newDict(nil)
107+
d.set("Predictor", Integer(12))
108+
d.set("Columns", Integer(5))
109+
d.set("Colors", Integer(3))
110+
d.set("BitsPerComponent", Integer(16))
111+
d.set("EarlyChange", Integer(0))
112+
p := paramsFromDict(d)
113+
if p.Predictor != 12 || p.Columns != 5 || p.Colors != 3 || p.BitsPerComponent != 16 {
114+
t.Errorf("predictor params = %+v, want Predictor=12 Columns=5 Colors=3 BitsPerComponent=16", p)
115+
}
116+
if !p.NoEarlyChange {
117+
t.Error("/EarlyChange 0 must set NoEarlyChange")
118+
}
119+
120+
// 1 is the LZW default, so it must NOT set NoEarlyChange.
121+
d1 := newDict(nil)
122+
d1.set("EarlyChange", Integer(1))
123+
if paramsFromDict(d1).NoEarlyChange {
124+
t.Error("/EarlyChange 1 must not set NoEarlyChange")
125+
}
126+
127+
empty := paramsFromDict(newDict(nil))
128+
if empty.Predictor != 0 || empty.Columns != 0 || empty.Colors != 0 ||
129+
empty.BitsPerComponent != 0 || empty.NoEarlyChange {
130+
t.Errorf("empty dict = %+v, want zero Params", empty)
131+
}
132+
}

internal/crypt/crypt_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,59 @@ func TestComputeV5KeyRejectsShortEntries(t *testing.T) {
315315
})
316316
}
317317
}
318+
319+
// Owner-password path. Unlike the user path, the owner hash mixes in the
320+
// 48-byte /U entry (§7.6.4.4.10) — so userEntry here must be well-formed.
321+
func TestComputeV5KeyOwnerPath(t *testing.T) {
322+
const R = 6
323+
password := []byte("owner-pw")
324+
fileKey := bytes.Repeat([]byte{0x37}, 32)
325+
326+
// All-zero validation hash (first 32 bytes) can never equal v5Hash output,
327+
// forcing the user path to miss so the owner path is taken.
328+
uVS := bytes.Repeat([]byte{0x11}, 8)
329+
uKS := bytes.Repeat([]byte{0x22}, 8)
330+
userEntry := append(append(make([]byte, 32), uVS...), uKS...) // 48 bytes
331+
332+
oVS := bytes.Repeat([]byte{0x33}, 8)
333+
oKS := bytes.Repeat([]byte{0x44}, 8)
334+
oValHash, err := v5Hash(password, oVS, userEntry, R)
335+
if err != nil {
336+
t.Fatalf("v5Hash(owner validation): %v", err)
337+
}
338+
oKeyHash, err := v5Hash(password, oKS, userEntry, R)
339+
if err != nil {
340+
t.Fatalf("v5Hash(owner key): %v", err)
341+
}
342+
oe := aesCBCEncryptRaw(t, oKeyHash, make([]byte, aes.BlockSize), fileKey)
343+
ownerEntry := append(append(append([]byte{}, oValHash...), oVS...), oKS...)
344+
345+
p := Params{
346+
V: 5, R: R,
347+
UserEntry: userEntry,
348+
OwnerEntry: ownerEntry,
349+
UE: make([]byte, 32), // present but never reached
350+
OE: oe,
351+
}
352+
key, err := computeV5Key(p, password)
353+
if err != nil {
354+
t.Fatalf("computeV5Key: %v", err)
355+
}
356+
if !bytes.Equal(key, fileKey) {
357+
t.Fatalf("owner-path key mismatch:\n got %x\nwant %x", key, fileKey)
358+
}
359+
}
360+
361+
func TestComputeV5KeyWrongPassword(t *testing.T) {
362+
p := Params{
363+
V: 5, R: 6,
364+
UserEntry: bytes.Repeat([]byte{0x01}, 48),
365+
OwnerEntry: bytes.Repeat([]byte{0x02}, 48),
366+
UE: bytes.Repeat([]byte{0x03}, 32),
367+
OE: bytes.Repeat([]byte{0x04}, 32),
368+
}
369+
overlong := bytes.Repeat([]byte{'z'}, 200) // > 127: exercises the spec truncation
370+
if _, err := computeV5Key(p, overlong); err == nil {
371+
t.Fatal("expected an error for a non-matching password, got nil")
372+
}
373+
}

internal/filter/filter_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"compress/lzw"
66
"compress/zlib"
77
"encoding/ascii85"
8+
"errors"
89
"fmt"
910
"testing"
1011
)
@@ -396,3 +397,38 @@ func FuzzDecode(f *testing.F) {
396397
}
397398
})
398399
}
400+
401+
// Expected values hand-computed from PNG §6.6 (not copied from output).
402+
// paeth(0,0,10) makes p=a+b-c negative — the one vector here that reaches
403+
// abs's negative branch; don't drop it.
404+
func TestPaeth(t *testing.T) {
405+
cases := []struct {
406+
a, b, c, want byte
407+
}{
408+
{0, 0, 0, 0},
409+
{1, 2, 3, 1}, // p=0: pa=1 pb=2 pc=3 -> a
410+
{255, 0, 0, 255}, // p=255: pa=0 -> a
411+
{0, 0, 10, 0}, // p=-10: pa=10 pb=10 pc=20 -> a (negative estimate)
412+
{10, 20, 11, 20}, // p=19: pa=9 pb=1 pc=8 -> b
413+
{0, 10, 6, 6}, // p=4: pa=4 pb=6 pc=2 -> c
414+
}
415+
for _, tc := range cases {
416+
if got := paeth(tc.a, tc.b, tc.c); got != tc.want {
417+
t.Errorf("paeth(%d,%d,%d) = %d, want %d", tc.a, tc.b, tc.c, got, tc.want)
418+
}
419+
}
420+
}
421+
422+
func TestErrUnsupported(t *testing.T) {
423+
_, err := Decode("DCTDecode", []byte("x"), Params{})
424+
var unsup ErrUnsupported
425+
if !errors.As(err, &unsup) {
426+
t.Fatalf("Decode(DCTDecode) error = %v, want ErrUnsupported", err)
427+
}
428+
if unsup.Name != "DCTDecode" {
429+
t.Errorf("ErrUnsupported.Name = %q, want DCTDecode", unsup.Name)
430+
}
431+
if want := `pdfdisassembler/filter: unsupported filter "DCTDecode"`; unsup.Error() != want {
432+
t.Errorf("Error() = %q, want %q", unsup.Error(), want)
433+
}
434+
}

internal/lex/lex_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package lex
22

33
import (
4+
"bytes"
45
"testing"
56
)
67

@@ -199,3 +200,22 @@ func FuzzLexer(f *testing.F) {
199200
}
200201
})
201202
}
203+
204+
// Position accessors that back the xref reader's manual seeking.
205+
func TestLexerAccessors(t *testing.T) {
206+
src := []byte("hello world")
207+
lx := New(src)
208+
if lx.Pos() != 0 {
209+
t.Errorf("initial Pos = %d, want 0", lx.Pos())
210+
}
211+
if !bytes.Equal(lx.Source(), src) {
212+
t.Errorf("Source = %q, want %q", lx.Source(), src)
213+
}
214+
lx.SetPos(6)
215+
if lx.Pos() != 6 {
216+
t.Errorf("Pos after SetPos(6) = %d, want 6", lx.Pos())
217+
}
218+
if got := lx.Remaining(); string(got) != "world" {
219+
t.Errorf("Remaining = %q, want world", got)
220+
}
221+
}

reader_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,35 @@ func TestVersionCatalogOverride(t *testing.T) {
431431
t.Errorf("Version = %q, want 2.0 (catalog override)", v)
432432
}
433433
}
434+
435+
// A non-Reference /Length resolves to itself, so a bare Reader (no xref) drives
436+
// the missing/non-integer/negative guards directly.
437+
func TestStreamLengthRejectsBadLength(t *testing.T) {
438+
r := &Reader{}
439+
bad := []struct {
440+
name string
441+
set func(d *Dict)
442+
}{
443+
{"missing", func(d *Dict) {}},
444+
{"non_integer", func(d *Dict) { d.set("Length", String("x")) }},
445+
{"negative", func(d *Dict) { d.set("Length", Integer(-5)) }},
446+
}
447+
for _, tc := range bad {
448+
t.Run(tc.name, func(t *testing.T) {
449+
d := newDict(nil)
450+
tc.set(d)
451+
if _, err := r.streamLength(d); err == nil {
452+
t.Fatal("expected an error, got nil")
453+
}
454+
})
455+
}
456+
457+
// Control: a valid non-negative /Length must still resolve.
458+
t.Run("valid", func(t *testing.T) {
459+
d := newDict(nil)
460+
d.set("Length", Integer(42))
461+
if n, err := r.streamLength(d); err != nil || n != 42 {
462+
t.Fatalf("streamLength = %d, %v; want 42, nil", n, err)
463+
}
464+
})
465+
}

xref_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,31 @@ func TestClassicalXrefHugeCountRecovers(t *testing.T) {
412412
t.Fatalf("Catalog: %v", err)
413413
}
414414
}
415+
416+
// CR/LF/CRLF line-ending handling for the classical xref reader (§7.5.4).
417+
func TestSkipEOL(t *testing.T) {
418+
cases := []struct {
419+
name string
420+
buf string
421+
pos int
422+
want int
423+
}{
424+
{"crlf", "\r\nX", 0, 2},
425+
{"lf", "\nX", 0, 1},
426+
{"lone_cr", "\rX", 0, 1},
427+
{"cr_at_eof", "\r", 0, 1},
428+
{"leading_spaces_then_lf", " \nX", 0, 3},
429+
{"tab_then_crlf", "\t\r\n", 0, 3},
430+
{"non_whitespace_stays_put", "Xyz", 0, 0},
431+
{"spaces_then_eof", " ", 0, 2},
432+
{"empty", "", 0, 0},
433+
{"pos_already_past_end", "ab", 2, 2},
434+
}
435+
for _, tc := range cases {
436+
t.Run(tc.name, func(t *testing.T) {
437+
if got := skipEOL([]byte(tc.buf), tc.pos); got != tc.want {
438+
t.Errorf("skipEOL(%q, %d) = %d, want %d", tc.buf, tc.pos, got, tc.want)
439+
}
440+
})
441+
}
442+
}

0 commit comments

Comments
 (0)