From a6d484f0a67b231c74773954fab66a54dc4c5b36 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Mon, 22 Jun 2026 22:55:02 +0200 Subject: [PATCH] Honour LZWDecode /EarlyChange 0; expand filter test coverage decodeLZW defaulted EarlyChange to 1 whenever the value was 0, conflating "unset" (PDF default 1) with an explicit /EarlyChange 0, so streams that set it to 0 were decoded with the wrong code-width timing. Replace the EarlyChange int field with NoEarlyChange bool: the zero value keeps the default early change and an explicit 0 is now honoured. This also clamps the flag to {0,1}, so a hostile /EarlyChange can no longer distort the width threshold. Adds filter tests: stdlib-LZW round-trips exercising dictionary reuse, KwKwK, 9->12-bit growth and the dictionary-full reset; an EarlyChange regression at the Open()/Content() surface; a truncated-stream no-panic check; ASCII85 round-trips and edge cases (z, whitespace, invalid byte, partial groups); and a chained /Filter array. Removes the dead errors import from lzw.go. internal/filter 80.7% -> 93.2%; decodeLZW/readBits/decodeASCII85 to 100%. --- filter.go | 4 +- filter_test.go | 103 ++++++++++++++++++++++++++++ internal/filter/filter.go | 5 +- internal/filter/filter_test.go | 122 +++++++++++++++++++++++++++++++++ internal/filter/lzw.go | 16 ++--- 5 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 filter_test.go diff --git a/filter.go b/filter.go index 03a7879..c8c8a6a 100644 --- a/filter.go +++ b/filter.go @@ -134,8 +134,8 @@ func paramsFromDict(d *Dict) filter.Params { if n, ok := d.Int("BitsPerComponent"); ok { p.BitsPerComponent = int(n) } - if n, ok := d.Int("EarlyChange"); ok { - p.EarlyChange = int(n) + if n, ok := d.Int("EarlyChange"); ok && n == 0 { + p.NoEarlyChange = true } return p } diff --git a/filter_test.go b/filter_test.go new file mode 100644 index 0000000..51aab90 --- /dev/null +++ b/filter_test.go @@ -0,0 +1,103 @@ +package pdfdisassembler + +import ( + "bytes" + "compress/lzw" + "compress/zlib" + "encoding/ascii85" + "fmt" + "testing" +) + +// buildStreamObjectPDF wraps stream as indirect object 3 with the given stream +// dict entries (e.g. "/Filter /LZWDecode ..."), reachable via a classical xref. +func buildStreamObjectPDF(t *testing.T, dictEntries string, stream []byte) []byte { + t.Helper() + var buf bytes.Buffer + off := func() int { return buf.Len() } + buf.WriteString("%PDF-1.7\n%\xe2\xe3\xcf\xd3\n") + offsets := make([]int, 4) + + offsets[1] = off() + buf.WriteString("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") + offsets[2] = off() + buf.WriteString("2 0 obj\n<< /Type /Pages /Count 0 /Kids [] >>\nendobj\n") + offsets[3] = off() + fmt.Fprintf(&buf, "3 0 obj\n<< %s /Length %d >>\nstream\n", dictEntries, len(stream)) + buf.Write(stream) + buf.WriteString("\nendstream\nendobj\n") + + xrefOff := off() + buf.WriteString("xref\n0 4\n") + fmt.Fprintf(&buf, "%010d %05d f \n", 0, 65535) + for i := 1; i <= 3; i++ { + fmt.Fprintf(&buf, "%010d %05d n \n", offsets[i], 0) + } + buf.WriteString("trailer\n<< /Size 4 /Root 1 0 R >>\n") + fmt.Fprintf(&buf, "startxref\n%d\n%%%%EOF\n", xrefOff) + return buf.Bytes() +} + +func streamObject3(t *testing.T, data []byte) []byte { + t.Helper() + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { r.Close() }) + v, err := r.Resolve(Reference{Number: 3, Generation: 0}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + stm, ok := v.(*Stream) + if !ok { + t.Fatalf("object 3 is %T, want *Stream", v) + } + got, err := stm.Content() + if err != nil { + t.Fatalf("Content: %v", err) + } + return got +} + +// A stream declaring /DecodeParms << /EarlyChange 0 >> must decode with early +// change off. The stdlib LZW writer emits the non-early convention, so honouring +// the parameter reproduces the input; ignoring it garbles a stream past the +// first code-width boundary. +func TestLZWStreamEarlyChangeZero(t *testing.T) { + orig := make([]byte, 4096) + x := uint32(99) + for i := range orig { + x = x*1664525 + 1013904223 + orig[i] = byte(x >> 24) + } + var enc bytes.Buffer + w := lzw.NewWriter(&enc, lzw.MSB, 8) + w.Write(orig) + w.Close() + + got := streamObject3(t, buildStreamObjectPDF(t, + "/Filter /LZWDecode /DecodeParms << /EarlyChange 0 >>", enc.Bytes())) + if !bytes.Equal(got, orig) { + t.Fatal("LZW stream with /EarlyChange 0 decoded incorrectly") + } +} + +// A /Filter array applies filters in order: the raw bytes are ASCII85 wrapping a +// FlateDecode stream, so the chain must un-ASCII85 then inflate. +func TestStreamFilterChainArray(t *testing.T) { + orig := []byte("chained filters: ASCII85 over Flate over the original bytes") + var fl bytes.Buffer + zw := zlib.NewWriter(&fl) + zw.Write(orig) + zw.Close() + a85 := make([]byte, ascii85.MaxEncodedLen(fl.Len())) + n := ascii85.Encode(a85, fl.Bytes()) + stream := append(a85[:n:n], '~', '>') + + got := streamObject3(t, buildStreamObjectPDF(t, + "/Filter [ /ASCII85Decode /FlateDecode ]", stream)) + if !bytes.Equal(got, orig) { + t.Fatalf("chained decode = %q, want %q", got, orig) + } +} diff --git a/internal/filter/filter.go b/internal/filter/filter.go index 5f2e337..76c6ce6 100644 --- a/internal/filter/filter.go +++ b/internal/filter/filter.go @@ -29,8 +29,9 @@ type Params struct { Columns int Colors int BitsPerComponent int - // LZWDecode early-change flag. - EarlyChange int + // NoEarlyChange honours the rare /EarlyChange 0; the zero value keeps + // LZWDecode's default early code-width change. + NoEarlyChange bool // MaxOutput caps decoded bytes per filter; <= 0 means unlimited. MaxOutput int64 } diff --git a/internal/filter/filter_test.go b/internal/filter/filter_test.go index c44b747..2a25556 100644 --- a/internal/filter/filter_test.go +++ b/internal/filter/filter_test.go @@ -2,11 +2,133 @@ package filter import ( "bytes" + "compress/lzw" "compress/zlib" + "encoding/ascii85" "fmt" "testing" ) +// lcg fills b with a deterministic pseudo-random byte stream — enough entropy +// to grow the LZW dictionary through every code width and trigger a reset. +func lcg(b []byte, seed uint32) { + x := seed + for i := range b { + x = x*1664525 + 1013904223 + b[i] = byte(x >> 24) + } +} + +// TestLZWRoundTripStdlib decodes streams produced by the standard library's +// MSB LZW writer. That writer uses the non-early code-width change, so decode +// with NoEarlyChange; the varied inputs exercise dictionary reuse, the KwKwK +// case, 9->12-bit width growth, and the dictionary-full reset. +func TestLZWRoundTripStdlib(t *testing.T) { + big := make([]byte, 64<<10) + lcg(big, 1) + for _, orig := range [][]byte{ + []byte("ABABABABABABABABABAB"), + []byte("TOBEORNOTTOBEORTOBEORNOT"), + bytes.Repeat([]byte("xyz "), 4096), + big, + {}, + {42}, + } { + var buf bytes.Buffer + w := lzw.NewWriter(&buf, lzw.MSB, 8) + if _, err := w.Write(orig); err != nil { + t.Fatal(err) + } + w.Close() + got, err := Decode("LZWDecode", buf.Bytes(), Params{NoEarlyChange: true}) + if err != nil { + t.Fatalf("decode (len %d): %v", len(orig), err) + } + if !bytes.Equal(got, orig) { + t.Fatalf("round-trip mismatch for len %d input", len(orig)) + } + } +} + +// TestLZWEarlyChangeHonored proves NoEarlyChange actually selects the decode +// convention: the stdlib stream (non-early) round-trips only with NoEarlyChange +// set; under the early-change default the same bytes must not reproduce the input. +func TestLZWEarlyChangeHonored(t *testing.T) { + orig := make([]byte, 4096) // long enough to cross the first width boundary + lcg(orig, 7) + var buf bytes.Buffer + w := lzw.NewWriter(&buf, lzw.MSB, 8) + w.Write(orig) + w.Close() + enc := buf.Bytes() + + got, err := Decode("LZWDecode", enc, Params{NoEarlyChange: true}) + if err != nil || !bytes.Equal(got, orig) { + t.Fatalf("NoEarlyChange decode of a non-early stream: err=%v equal=%v", err, bytes.Equal(got, orig)) + } + if d, err := Decode("LZWDecode", enc, Params{}); err == nil && bytes.Equal(d, orig) { + t.Fatal("early-change default reproduced a non-early stream: the flag is ignored") + } +} + +// A stream truncated mid-code must not panic: readBits pads the final partial +// word with zeros and decoding stops cleanly (partial output or error). +func TestLZWTruncatedNoPanic(t *testing.T) { + orig := make([]byte, 600) // long enough to reach 10-bit codes + lcg(orig, 3) + var buf bytes.Buffer + w := lzw.NewWriter(&buf, lzw.MSB, 8) + w.Write(orig) + w.Close() + enc := buf.Bytes() + for cut := 1; cut <= 4 && cut < len(enc); cut++ { + _, _ = Decode("LZWDecode", enc[:len(enc)-cut], Params{NoEarlyChange: true}) + } +} + +func TestASCII85RoundTripStdlib(t *testing.T) { + for _, orig := range [][]byte{ + []byte("Hello World!"), + {0, 0, 0, 0, 1, 2, 3}, // includes an all-zero group + {1}, // 1-byte partial group + {1, 2}, // 2-byte partial group + {1, 2, 3}, // 3-byte partial group + bytes.Repeat([]byte{255}, 17), + {}, + } { + enc := make([]byte, ascii85.MaxEncodedLen(len(orig))) + n := ascii85.Encode(enc, orig) + in := append(enc[:n:n], '~', '>') + out, err := Decode("ASCII85Decode", in, Params{}) + if err != nil { + t.Fatalf("decode %v: %v", orig, err) + } + if !bytes.Equal(out, orig) { + t.Fatalf("round-trip mismatch: in=%v out=%v", orig, out) + } + } +} + +func TestASCII85EdgeCases(t *testing.T) { + // 'z' is shorthand for a full zero group; <~ is an optional opening marker; + // whitespace between digits is ignored. + out, err := Decode("ASCII85Decode", []byte("<~z 8 7 c U R D ] i , \" E b o 8 0 ~>"), Params{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + if want := append([]byte{0, 0, 0, 0}, "Hello World!"...); !bytes.Equal(out, want) { + t.Fatalf("got %q, want %q", out, want) + } + + if _, err := Decode("ASCII85Decode", []byte("abc!\x01def~>"), Params{}); err == nil { + t.Fatal("expected an error on a byte outside the ASCII85 alphabet") + } + // 'z' may not appear mid-group (after a partial digit). + if _, err := Decode("ASCII85Decode", []byte("87z~>"), Params{}); err == nil { + t.Fatal("expected an error for 'z' inside a group") + } +} + func TestASCIIHex(t *testing.T) { cases := map[string]string{ "48656C6C6F>": "Hello", diff --git a/internal/filter/lzw.go b/internal/filter/lzw.go index 170bbac..53dad19 100644 --- a/internal/filter/lzw.go +++ b/internal/filter/lzw.go @@ -1,17 +1,14 @@ package filter -import ( - "errors" - "fmt" -) +import "fmt" // decodeLZW decodes a PDF LZW stream. Code widths grow from 9 to 12 bits; // the early-change flag (default 1) shrinks the threshold at which each // width step happens. func decodeLZW(in []byte, p Params) ([]byte, error) { - early := p.EarlyChange - if early == 0 { - early = 1 + early := 1 + if p.NoEarlyChange { + early = 0 } const ( clearCode = 256 @@ -78,9 +75,6 @@ func decodeLZW(in []byte, p Params) ([]byte, error) { codeWidth++ } } - if prev < 0 { - return out, nil - } return out, nil } @@ -115,5 +109,3 @@ func (b *bitReader) readBits(n int) (uint32, bool) { b.buf &= (uint64(1) << b.have) - 1 return v, true } - -var _ = errors.New