From 3effb12ffb0d43b3f0b391b0718a0eb47a54e67e Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Mon, 22 Jun 2026 22:59:19 +0200 Subject: [PATCH] Add cmd/pdfdump smoke tests against hostile input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pdfdump is the entry point run on untrusted files, but main() was untestable (global flag state, log.Fatal). Extract run(args, stdout) error so the command can be exercised; main() stays a thin wrapper. Tests assert hostile inputs (empty, garbage, broken xref, NUL bytes, unclosed dict) yield a graceful error or valid JSON — never a panic — and that every testdata fixture dumps successfully. --- cmd/pdfdump/main.go | 41 ++++++++++++++++--------- cmd/pdfdump/main_test.go | 64 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 cmd/pdfdump/main_test.go diff --git a/cmd/pdfdump/main.go b/cmd/pdfdump/main.go index 8420f96..b9e9152 100644 --- a/cmd/pdfdump/main.go +++ b/cmd/pdfdump/main.go @@ -16,8 +16,10 @@ package main import ( + "errors" "flag" "fmt" + "io" "log" "os" @@ -25,20 +27,32 @@ import ( ) func main() { - inlineContent := flag.Bool("stream-content", false, "embed decoded stream bytes as hex under decoded.hex") - noPreview := flag.Bool("no-preview", false, "omit the preview_utf8 field on streams") - flag.Usage = func() { + if err := run(os.Args[1:], os.Stdout); err != nil { + if errors.Is(err, flag.ErrHelp) { + return + } + log.Fatal(err) + } +} + +func run(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("pdfdump", flag.ContinueOnError) + inlineContent := fs.Bool("stream-content", false, "embed decoded stream bytes as hex under decoded.hex") + noPreview := fs.Bool("no-preview", false, "omit the preview_utf8 field on streams") + fs.Usage = func() { fmt.Fprintln(os.Stderr, "usage: pdfdump [flags] ") - flag.PrintDefaults() + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err } - flag.Parse() - if flag.NArg() != 1 { - flag.Usage() - os.Exit(2) + if fs.NArg() != 1 { + fs.Usage() + return errors.New("pdfdump: exactly one input file is required") } - r, err := pdfdisassembler.OpenFile(flag.Arg(0)) + r, err := pdfdisassembler.OpenFile(fs.Arg(0)) if err != nil { - log.Fatal(err) + return err } defer r.Close() @@ -50,9 +64,8 @@ func main() { } data, err := pdfdisassembler.Dump(r, opts) if err != nil { - log.Fatal(err) - } - if _, err := os.Stdout.Write(data); err != nil { - log.Fatal(err) + return err } + _, err = stdout.Write(data) + return err } diff --git a/cmd/pdfdump/main_test.go b/cmd/pdfdump/main_test.go new file mode 100644 index 0000000..c172573 --- /dev/null +++ b/cmd/pdfdump/main_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" +) + +// pdfdump is run on untrusted files, so hostile input must yield a graceful +// error or valid JSON — never a panic (which the test framework would surface). +func TestRunHostileInputsNoPanic(t *testing.T) { + cases := map[string][]byte{ + "empty": {}, + "garbage": []byte("not a pdf at all"), + "header only": []byte("%PDF-1.7\n"), + "broken xref": []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\nstartxref\n999999\n%%EOF"), + "nul bytes": bytes.Repeat([]byte{0}, 256), + "unclosed dict": []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog\nendobj\ntrailer\n<< /Root 1 0 R >>\nstartxref\n9\n%%EOF"), + } + dir := t.TempDir() + for name, body := range cases { + t.Run(name, func(t *testing.T) { + p := filepath.Join(dir, "in.pdf") + if err := os.WriteFile(p, body, 0o600); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if err := run([]string{p}, &out); err == nil { + if out.Len() == 0 || out.Bytes()[0] != '{' { + t.Fatalf("succeeded but produced non-JSON output (%d bytes)", out.Len()) + } + } + }) + } +} + +func TestRunValidFixtures(t *testing.T) { + fixtures, err := filepath.Glob("../../testdata/fixtures/*/input.pdf") + if err != nil || len(fixtures) == 0 { + t.Fatalf("no fixtures found: %v", err) + } + for _, fx := range fixtures { + t.Run(filepath.Base(filepath.Dir(fx)), func(t *testing.T) { + var out bytes.Buffer + if err := run([]string{"-stream-content", fx}, &out); err != nil { + t.Fatalf("run: %v", err) + } + if out.Len() == 0 || out.Bytes()[0] != '{' { + t.Fatalf("expected JSON output, got %d bytes", out.Len()) + } + }) + } +} + +func TestRunArgErrors(t *testing.T) { + if err := run(nil, io.Discard); err == nil { + t.Error("expected an error with no file argument") + } + if err := run([]string{"/nonexistent/nope.pdf"}, io.Discard); err == nil { + t.Error("expected an error for a missing file") + } +}