diff --git a/.gitattributes b/.gitattributes index 69b92ae06..e710a9e36 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,7 @@ UnitTests/testdata/interaction-traces/** text eol=lf Fuzz/corpus/regression/** binary Fuzz/corpus/regression/README.md text Fuzz/corpus/regression/manifest.json text +Fuzz/corpus/**/*.bin binary LoopLibCore/cmaps/** binary LoopLibCore/liberation-fonts-ttf/** -whitespace 3rdparty_licenses/** -whitespace diff --git a/Fuzz/corpus/README.md b/Fuzz/corpus/README.md index 9eed67b96..df60de47c 100644 --- a/Fuzz/corpus/README.md +++ b/Fuzz/corpus/README.md @@ -21,7 +21,51 @@ fuzzer-generated against Loop and redistributable under `LICENSE`. | `Fuzz/corpus/fuzz_images/` | `fuzz_images` | `scripts/ci/check_fuzz_corpus.py` enforces that every tracked seed has a -manifest entry and that checksums, harness ownership, and paths stay in sync. +manifest entry, that checksums, harness ownership, and paths stay in sync, and +that each harness owns at least one manifested seed. Empty `.gitkeep` directories +are not a corpus. Harness seed files (`*.bin`) are marked `binary` in +`.gitattributes` so ASCII PDFs are not rewritten with CRLF. + +Synthetic coverage seeds use `origin: synthetic` and `issue: 0`. They are not +crash reproductions; they exist so `fuzz_pdf_parser`, `fuzz_content_stream`, and +`fuzz_stream_filters` do not start mutation from an empty directory. + +## Synthetic coverage seeds + +These files are hand-built, tiny, and redistributable. They were chosen to hit +the harness entry points deterministically instead of waiting for libFuzzer to +invent a `%PDF` header or a filter payload. + +### `fuzz_pdf_parser` + +| File | Why it is here | +|------|----------------| +| `minimal-one-page.bin` | Valid one-page PDF so mutation starts from a parseable document. | +| `truncated-xref.bin` | Objects present, xref/trailer cut off. | +| `cyclic-kids.bin` | `/Kids` array that references its own Pages node. | +| `wrong-generation.bin` | Xref and `/Root` generation 1 vs object header generation 0. | +| `bad-object-stream.bin` | `/ObjStm` with `/N 1000000000` and a compressed xref entry. | +| `unknown-encrypt-filter.bin` | `/Encrypt` names a Filter the reader does not implement. | +| `not-pdf.bin` | Non-PDF bytes (same class as `malformed-not-pdf.pdf`). | + +`encrypted-without-password.pdf` is a preflight/operator golden only. It is not a +fuzz seed: `fuzz_pdf_parser`'s password callback always reports success, so an +encrypted corpus file would hang `-runs=0`. Changing that harness is deferred +until a `LOOP_BUILD_FUZZERS` compile-commands entry exists for clang-tidy. + +The same malformed PDFs (except the valid page and the unknown-filter blob) are +also preflight goldens under `loop-preflight/testdata/fixtures/`. + +### `fuzz_content_stream` + +Raw page-content operators for `PDFPageContentProcessor::processForm`: path +ops, text ops, unbalanced `q`, an unknown operator, and whitespace. + +### `fuzz_stream_filters` + +First byte selects the filter (`FlateDecode`, `LZWDecode`, `ASCII85Decode`, +`ASCIIHexDecode`, `RunLengthDecode`). Payloads are a valid zlib "hello", +truncated Flate/LZW/ASCIIHex, a short ASCII85 string, and a RunLength EOD. ## MIC-326 / R-003 (JBIG2, `fuzz_images`) diff --git a/Fuzz/corpus/fuzz_content_stream/empty-whitespace.bin b/Fuzz/corpus/fuzz_content_stream/empty-whitespace.bin new file mode 100644 index 000000000..3518e527e --- /dev/null +++ b/Fuzz/corpus/fuzz_content_stream/empty-whitespace.bin @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Fuzz/corpus/fuzz_content_stream/path-ops.bin b/Fuzz/corpus/fuzz_content_stream/path-ops.bin new file mode 100644 index 000000000..af413ec2a --- /dev/null +++ b/Fuzz/corpus/fuzz_content_stream/path-ops.bin @@ -0,0 +1 @@ +q 1 0 0 1 0 0 cm 0 0 100 100 re f Q \ No newline at end of file diff --git a/Fuzz/corpus/fuzz_content_stream/text-ops.bin b/Fuzz/corpus/fuzz_content_stream/text-ops.bin new file mode 100644 index 000000000..c7948544e --- /dev/null +++ b/Fuzz/corpus/fuzz_content_stream/text-ops.bin @@ -0,0 +1 @@ +BT 72 720 Td (Hello) Tj ET \ No newline at end of file diff --git a/Fuzz/corpus/fuzz_content_stream/unbalanced-q.bin b/Fuzz/corpus/fuzz_content_stream/unbalanced-q.bin new file mode 100644 index 000000000..0cc8588d2 --- /dev/null +++ b/Fuzz/corpus/fuzz_content_stream/unbalanced-q.bin @@ -0,0 +1 @@ +q q q 0 0 10 10 re f \ No newline at end of file diff --git a/Fuzz/corpus/fuzz_content_stream/unknown-operator.bin b/Fuzz/corpus/fuzz_content_stream/unknown-operator.bin new file mode 100644 index 000000000..d3f6e79ec --- /dev/null +++ b/Fuzz/corpus/fuzz_content_stream/unknown-operator.bin @@ -0,0 +1 @@ +1 2 3 notanoperator 0 0 1 1 re f \ No newline at end of file diff --git a/Fuzz/corpus/fuzz_pdf_parser/bad-object-stream.bin b/Fuzz/corpus/fuzz_pdf_parser/bad-object-stream.bin new file mode 100644 index 000000000..57768adeb Binary files /dev/null and b/Fuzz/corpus/fuzz_pdf_parser/bad-object-stream.bin differ diff --git a/Fuzz/corpus/fuzz_pdf_parser/cyclic-kids.bin b/Fuzz/corpus/fuzz_pdf_parser/cyclic-kids.bin new file mode 100644 index 000000000..c7244c02b --- /dev/null +++ b/Fuzz/corpus/fuzz_pdf_parser/cyclic-kids.bin @@ -0,0 +1,17 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [2 0 R] /Count 1 >> +endobj +xref +0 3 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +trailer +<< /Size 3 /Root 1 0 R >> +startxref +115 +%%EOF diff --git a/Fuzz/corpus/fuzz_pdf_parser/minimal-one-page.bin b/Fuzz/corpus/fuzz_pdf_parser/minimal-one-page.bin new file mode 100644 index 000000000..39fbb5836 --- /dev/null +++ b/Fuzz/corpus/fuzz_pdf_parser/minimal-one-page.bin @@ -0,0 +1,21 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> +endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer +<< /Size 4 /Root 1 0 R >> +startxref +186 +%%EOF diff --git a/Fuzz/corpus/fuzz_pdf_parser/not-pdf.bin b/Fuzz/corpus/fuzz_pdf_parser/not-pdf.bin new file mode 100644 index 000000000..44aa5ff61 --- /dev/null +++ b/Fuzz/corpus/fuzz_pdf_parser/not-pdf.bin @@ -0,0 +1 @@ +this is not a PDF file diff --git a/Fuzz/corpus/fuzz_pdf_parser/truncated-xref.bin b/Fuzz/corpus/fuzz_pdf_parser/truncated-xref.bin new file mode 100644 index 000000000..effd77252 --- /dev/null +++ b/Fuzz/corpus/fuzz_pdf_parser/truncated-xref.bin @@ -0,0 +1,10 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> +endobj diff --git a/Fuzz/corpus/fuzz_pdf_parser/unknown-encrypt-filter.bin b/Fuzz/corpus/fuzz_pdf_parser/unknown-encrypt-filter.bin new file mode 100644 index 000000000..ca29c2861 --- /dev/null +++ b/Fuzz/corpus/fuzz_pdf_parser/unknown-encrypt-filter.bin @@ -0,0 +1,25 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> +endobj +4 0 obj +<< /Filter /NotARealFilter /V 1 /R 2 >> +endobj +xref +0 5 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000186 00000 n +trailer +<< /Size 5 /Root 1 0 R /Encrypt 4 0 R >> +startxref +241 +%%EOF diff --git a/Fuzz/corpus/fuzz_pdf_parser/wrong-generation.bin b/Fuzz/corpus/fuzz_pdf_parser/wrong-generation.bin new file mode 100644 index 000000000..16e854344 --- /dev/null +++ b/Fuzz/corpus/fuzz_pdf_parser/wrong-generation.bin @@ -0,0 +1,21 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> +endobj +xref +0 4 +0000000000 65535 f +0000000009 00001 n +0000000058 00000 n +0000000115 00000 n +trailer +<< /Size 4 /Root 1 1 R >> +startxref +186 +%%EOF diff --git a/Fuzz/corpus/fuzz_stream_filters/ascii85-hello.bin b/Fuzz/corpus/fuzz_stream_filters/ascii85-hello.bin new file mode 100644 index 000000000..d1f73d81b --- /dev/null +++ b/Fuzz/corpus/fuzz_stream_filters/ascii85-hello.bin @@ -0,0 +1 @@ +87cURDZ> \ No newline at end of file diff --git a/Fuzz/corpus/fuzz_stream_filters/asciihex-truncated.bin b/Fuzz/corpus/fuzz_stream_filters/asciihex-truncated.bin new file mode 100644 index 000000000..a9696a16f --- /dev/null +++ b/Fuzz/corpus/fuzz_stream_filters/asciihex-truncated.bin @@ -0,0 +1 @@ +68656c6c6 \ No newline at end of file diff --git a/Fuzz/corpus/fuzz_stream_filters/flate-hello.bin b/Fuzz/corpus/fuzz_stream_filters/flate-hello.bin new file mode 100644 index 000000000..f54239029 Binary files /dev/null and b/Fuzz/corpus/fuzz_stream_filters/flate-hello.bin differ diff --git a/Fuzz/corpus/fuzz_stream_filters/flate-truncated.bin b/Fuzz/corpus/fuzz_stream_filters/flate-truncated.bin new file mode 100644 index 000000000..6c8377946 Binary files /dev/null and b/Fuzz/corpus/fuzz_stream_filters/flate-truncated.bin differ diff --git a/Fuzz/corpus/fuzz_stream_filters/lzw-truncated.bin b/Fuzz/corpus/fuzz_stream_filters/lzw-truncated.bin new file mode 100644 index 000000000..4369be250 Binary files /dev/null and b/Fuzz/corpus/fuzz_stream_filters/lzw-truncated.bin differ diff --git a/Fuzz/corpus/fuzz_stream_filters/runlength-eod.bin b/Fuzz/corpus/fuzz_stream_filters/runlength-eod.bin new file mode 100644 index 000000000..0d213da6f Binary files /dev/null and b/Fuzz/corpus/fuzz_stream_filters/runlength-eod.bin differ diff --git a/Fuzz/corpus/manifest.json b/Fuzz/corpus/manifest.json index db61515d1..bc22091b2 100644 --- a/Fuzz/corpus/manifest.json +++ b/Fuzz/corpus/manifest.json @@ -20,6 +20,204 @@ "sha256": "f1a4da02f8010927a81512a95fbfe007b0bfe9e3357d50d4e573a2f6b16cbb15", "expected": "terminates-without-crash", "minimized": true + }, + { + "id": "minimal-one-page", + "path": "Fuzz/corpus/fuzz_pdf_parser/minimal-one-page.bin", + "harness": "fuzz_pdf_parser", + "origin": "synthetic", + "issue": 0, + "sha256": "7d5371d3d9d5588cdb4c7851773488380aa8e9644001ad32ba5dad8084272746", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Tiny valid one-page PDF so mutation starts from a parseable document rather than empty input." + }, + { + "id": "truncated-xref", + "path": "Fuzz/corpus/fuzz_pdf_parser/truncated-xref.bin", + "harness": "fuzz_pdf_parser", + "origin": "synthetic", + "issue": 0, + "sha256": "80f923f2942e28fcc70e7c90e64d24c360ec45abe3f6044cfa377c93dcae1e67", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Header and objects with the xref/trailer cut off; reader must fail closed instead of hanging on a missing startxref." + }, + { + "id": "cyclic-kids", + "path": "Fuzz/corpus/fuzz_pdf_parser/cyclic-kids.bin", + "harness": "fuzz_pdf_parser", + "origin": "synthetic", + "issue": 0, + "sha256": "a6064012c9fc3ba84a7ea5fbc090ba25b924a3c8f0d665e042df6e6c4e561600", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Pages tree /Kids array that references itself; catalog parse already throws on page-tree cycles." + }, + { + "id": "wrong-generation", + "path": "Fuzz/corpus/fuzz_pdf_parser/wrong-generation.bin", + "harness": "fuzz_pdf_parser", + "origin": "synthetic", + "issue": 0, + "sha256": "8e7f5df3a3855ea1a3bb8fde4f646ff86832b86495b16d9777e9ea351760ca29", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Xref and /Root use generation 1; object header is 1 0 obj so permissive recovery cannot bind the catalog." + }, + { + "id": "bad-object-stream", + "path": "Fuzz/corpus/fuzz_pdf_parser/bad-object-stream.bin", + "harness": "fuzz_pdf_parser", + "origin": "synthetic", + "issue": 0, + "sha256": "1e44a341740f690a19232927211617523ac477850acf38914c71584e767b9c22", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "ObjStm with /N 1000000000 plus a compressed xref entry; object-stream budget must refuse it." + }, + { + "id": "unknown-encrypt-filter", + "path": "Fuzz/corpus/fuzz_pdf_parser/unknown-encrypt-filter.bin", + "harness": "fuzz_pdf_parser", + "origin": "synthetic", + "issue": 0, + "sha256": "f61f1aa3eda15214e11e64be1fe31809a12160ff36d6115133f63a13b9bf22d6", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Trailer /Encrypt names a Filter the reader does not implement; must throw, not hang." + }, + { + "id": "not-pdf", + "path": "Fuzz/corpus/fuzz_pdf_parser/not-pdf.bin", + "harness": "fuzz_pdf_parser", + "origin": "synthetic", + "issue": 0, + "sha256": "9e67370e5d4000310a444bb02bc324cb68c4f58536b558603f9d8d5f308a70e1", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Non-PDF bytes so the parser corpus covers the same fail-closed path as malformed-not-pdf.pdf." + }, + { + "id": "path-ops", + "path": "Fuzz/corpus/fuzz_content_stream/path-ops.bin", + "harness": "fuzz_content_stream", + "origin": "synthetic", + "issue": 0, + "sha256": "486902521e5a52e18ee452752e4975be4a5c88f208295f78016f070451e47c5c", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Graphics-state and path operators for PDFPageContentProcessor::processForm." + }, + { + "id": "text-ops", + "path": "Fuzz/corpus/fuzz_content_stream/text-ops.bin", + "harness": "fuzz_content_stream", + "origin": "synthetic", + "issue": 0, + "sha256": "2e3fcec9e1530e391d662429366fe4f43616f833c38379f044f17ec7740e78fa", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Text-object operators without a font resource; processor must tolerate missing fonts." + }, + { + "id": "unbalanced-q", + "path": "Fuzz/corpus/fuzz_content_stream/unbalanced-q.bin", + "harness": "fuzz_content_stream", + "origin": "synthetic", + "issue": 0, + "sha256": "3869b03df19ccbd551dc512ea9021af8a7013946fa6e6a675ac4eb8b54401e50", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Nested q without matching Q to exercise graphics-state nesting." + }, + { + "id": "unknown-operator", + "path": "Fuzz/corpus/fuzz_content_stream/unknown-operator.bin", + "harness": "fuzz_content_stream", + "origin": "synthetic", + "issue": 0, + "sha256": "15739194e2eab7cc693a465afb744d0487ea95d1597178331dde2b04f942b73d", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Unknown operator mixed with a legal path so the tokenizer cannot skip the whole stream." + }, + { + "id": "empty-whitespace", + "path": "Fuzz/corpus/fuzz_content_stream/empty-whitespace.bin", + "harness": "fuzz_content_stream", + "origin": "synthetic", + "issue": 0, + "sha256": "01f99895c51c5c8c34e193dc93f742e059fe25bbdb1b240d062c41c47a5f1ba1", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Whitespace-only content stream." + }, + { + "id": "flate-hello", + "path": "Fuzz/corpus/fuzz_stream_filters/flate-hello.bin", + "harness": "fuzz_stream_filters", + "origin": "synthetic", + "issue": 0, + "sha256": "529495a527b0304cb854d0c64323142630aae37ccf61011c4efe5c5f5494523a", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Selector 0 (FlateDecode) plus a valid zlib payload." + }, + { + "id": "flate-truncated", + "path": "Fuzz/corpus/fuzz_stream_filters/flate-truncated.bin", + "harness": "fuzz_stream_filters", + "origin": "synthetic", + "issue": 0, + "sha256": "b4c81cc52ac5638f010b89360c795e94dd2e3d6b8a655df06674087b98031dca", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Selector 0 plus a truncated zlib stream." + }, + { + "id": "lzw-truncated", + "path": "Fuzz/corpus/fuzz_stream_filters/lzw-truncated.bin", + "harness": "fuzz_stream_filters", + "origin": "synthetic", + "issue": 0, + "sha256": "ccbe8b97cafaa6525ed5d67b5b0eab41d7f4a1e0424e73eac5df2b02e15ae34d", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Selector 1 (LZWDecode) plus a short incomplete payload." + }, + { + "id": "ascii85-hello", + "path": "Fuzz/corpus/fuzz_stream_filters/ascii85-hello.bin", + "harness": "fuzz_stream_filters", + "origin": "synthetic", + "issue": 0, + "sha256": "afb89fa1e1c651f97443650d8bb1e6efc8d25c39cbc61d20b06868d70d63e606", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Selector 2 (ASCII85Decode) plus a short ASCII85 payload." + }, + { + "id": "asciihex-truncated", + "path": "Fuzz/corpus/fuzz_stream_filters/asciihex-truncated.bin", + "harness": "fuzz_stream_filters", + "origin": "synthetic", + "issue": 0, + "sha256": "252758b82b396f15f44d3d354d19bbb9c9dcb2ed1b15f480ded8dea2b1d68119", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Selector 3 (ASCIIHexDecode) plus odd-length hex without EOD." + }, + { + "id": "runlength-eod", + "path": "Fuzz/corpus/fuzz_stream_filters/runlength-eod.bin", + "harness": "fuzz_stream_filters", + "origin": "synthetic", + "issue": 0, + "sha256": "474faade882292c4efe6c9b84873857cc289d7f8f1671932e77883719548909f", + "expected": "terminates-without-crash", + "minimized": true, + "notes": "Selector 4 (RunLengthDecode) plus one literal byte and EOD." } ] } diff --git a/UnitTests/operatoracceptancehelpers.h b/UnitTests/operatoracceptancehelpers.h index b450ce4b1..3e9cebd2a 100644 --- a/UnitTests/operatoracceptancehelpers.h +++ b/UnitTests/operatoracceptancehelpers.h @@ -212,7 +212,8 @@ inline bool runPdfTool(const QString& pdfToolPath, QByteArray* stdOut, QByteArray* stdErr, int* exitCode, - qint64* peakChildMemoryKb = nullptr) + qint64* peakChildMemoryKb = nullptr, + int timeoutMs = 120000) { QProcess process; QTemporaryDir captureDirectory; @@ -259,13 +260,13 @@ inline bool runPdfTool(const QString& pdfToolPath, runTimer.start(); while (!process.waitForFinished(250)) { - if (runTimer.elapsed() > 120000) + if (runTimer.elapsed() > timeoutMs) { process.kill(); process.waitForFinished(5000); if (stdErr) { - *stdErr = QByteArrayLiteral("process timed out after 120000 ms"); + *stdErr = QByteArrayLiteral("process timed out after ") + QByteArray::number(timeoutMs) + QByteArrayLiteral(" ms"); } return false; } diff --git a/UnitTests/tst_operatoracceptance.cpp b/UnitTests/tst_operatoracceptance.cpp index f4fa67430..28511e5f0 100644 --- a/UnitTests/tst_operatoracceptance.cpp +++ b/UnitTests/tst_operatoracceptance.cpp @@ -65,6 +65,11 @@ constexpr OperatorCorpusEntry OPERATOR_CORPUS[] = { { "live-text-not-embedded", "font-not-embedded.pdf", false, "embedded-fonts", false }, { "image-only-raster", "image-dpi-ok.pdf", true, nullptr, false }, { "malformed-input", "malformed-not-pdf.pdf", false, nullptr, true }, + { "malformed-truncated-xref", "truncated-xref.pdf", false, nullptr, true }, + { "malformed-cyclic-kids", "cyclic-kids.pdf", false, nullptr, true }, + { "malformed-wrong-generation", "wrong-generation.pdf", false, nullptr, true }, + { "malformed-bad-object-stream", "bad-object-stream.pdf", false, nullptr, true }, + { "malformed-encrypted-without-password", "encrypted-without-password.pdf", false, nullptr, true }, }; QStringList checkIdsOf(const QJsonObject& report) @@ -316,7 +321,8 @@ private slots: QByteArray* stdOut, QByteArray* stdErr, int* exitCode, - qint64* peakChildMemoryKb = nullptr) const; + qint64* peakChildMemoryKb = nullptr, + int timeoutMs = 120000) const; bool runPreflight(const QString& pdfPath, const QString& profilePath, QJsonObject* report, @@ -349,9 +355,10 @@ bool OperatorAcceptanceTest::runPdfTool(const QStringList& arguments, QByteArray* stdOut, QByteArray* stdErr, int* exitCode, - qint64* peakChildMemoryKb) const + qint64* peakChildMemoryKb, + int timeoutMs) const { - return operatoracceptance::runPdfTool(m_pdfToolPath, arguments, stdOut, stdErr, exitCode, peakChildMemoryKb); + return operatoracceptance::runPdfTool(m_pdfToolPath, arguments, stdOut, stdErr, exitCode, peakChildMemoryKb, timeoutMs); } bool OperatorAcceptanceTest::runPreflight(const QString& pdfPath, @@ -423,10 +430,17 @@ void OperatorAcceptanceTest::assertMalformedPreflightFailure(const QString& pdfP { int exitCode = -1; QByteArray stdErr; - QVERIFY(runPdfTool({ QStringLiteral("preflight"), pdfPath, QStringLiteral("--profile"), m_defaultProfilePath }, - nullptr, - &stdErr, - &exitCode)); + constexpr int malformedTimeoutMs = 15000; + QVERIFY2(runPdfTool({ QStringLiteral("preflight"), pdfPath, QStringLiteral("--profile"), m_defaultProfilePath }, + nullptr, + &stdErr, + &exitCode, + nullptr, + malformedTimeoutMs), + qPrintable(QStringLiteral("Malformed input must fail closed within %1 ms (%2): %3") + .arg(malformedTimeoutMs) + .arg(pdfPath) + .arg(QString::fromUtf8(stdErr)))); QVERIFY2(exitCode != 0, "Malformed input must not report a successful preflight run."); QVERIFY2(exitCode != 1, "Malformed input must not masquerade as a findings exit code."); } diff --git a/changes/cursor-fuzz-malformed-seeds-bf51.md b/changes/cursor-fuzz-malformed-seeds-bf51.md new file mode 100644 index 000000000..82b167590 --- /dev/null +++ b/changes/cursor-fuzz-malformed-seeds-bf51.md @@ -0,0 +1,4 @@ +Category: added +Audience: developers +Breaking-Change: no +Summary: Seed the empty fuzz_pdf_parser, fuzz_content_stream, and fuzz_stream_filters corpora with small synthetic inputs, add representative malformed-PDF preflight goldens (truncated xref, cyclic Kids, wrong generation, hostile object stream, encrypted-without-password) so parser hangs and crashes are covered without mutation luck, enforce a 15s PdfTool watchdog for malformed operator acceptance, reject .gitkeep and other ignored basenames as manifest seeds, require every harness to own at least one real manifested seed, and mark Fuzz/corpus/**/*.bin as binary so ASCII seeds keep stable checksums. Encrypted-without-password is a preflight golden only; it is not a fuzz seed because the parser harness password callback always succeeds. diff --git a/docs/CI.md b/docs/CI.md index 8da773f97..ca914024d 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -77,7 +77,8 @@ index (`git ls-files`) and fails when tracked content includes: - tracked files over 5 MB without an explicit allowlist entry - whitespace problems reported by `git diff --check` over the full tree - fuzz regression seeds or preflight fixture PDFs that are not listed in their - corpus manifests + corpus manifests. `check_fuzz_corpus.py` also requires every libFuzzer harness + to own at least one manifested seed. Negative fixtures live in `scripts/ci/test_check_source_integrity.py`. diff --git a/docs/v1-operator-acceptance.md b/docs/v1-operator-acceptance.md index 9177f439f..ce26f3592 100644 --- a/docs/v1-operator-acceptance.md +++ b/docs/v1-operator-acceptance.md @@ -14,6 +14,11 @@ Automated coverage lives in `UnitTests/tst_operatoracceptance.cpp` (`ctest -R Un | Live text (not embedded) | `font-not-embedded.pdf` | Fail (`embedded-fonts`) | | Image-only raster | `image-dpi-ok.pdf` | Pass | | Malformed / unsupported | `malformed-not-pdf.pdf` | Non-zero exit (not findings exit code 1); no `%PDF` header | +| Truncated xref | `truncated-xref.pdf` | input-error; no hang | +| Cyclic `/Kids` | `cyclic-kids.pdf` | input-error; page-tree cycle refused | +| Wrong generation | `wrong-generation.pdf` | input-error | +| Hostile object stream | `bad-object-stream.pdf` | input-error | +| Encrypted, no password | `encrypted-without-password.pdf` | input-error (cancelled auth), not findings | Additional stress fixtures (`ai-art-*.pdf`) are exercised by `UnitTestsBleedStress`. diff --git a/loop-preflight/README.md b/loop-preflight/README.md index 50db72638..2e218bce2 100644 --- a/loop-preflight/README.md +++ b/loop-preflight/README.md @@ -485,6 +485,26 @@ still pass/fail the way it's supposed to. The snapshot check (`preflightMatchesS the regression gate: did anything about the report's *content* (message text, bbox, severity, finding order) change, even if pass/fail didn't. Both run for every corpus entry. +### Malformed PDF goldens + +Representative malformed inputs live next to the rest of the corpus so +`UnitTestsPreflightCorpus` and `UnitTestsOperatorAcceptance` share them. Each case +must resolve to the synthesized `input-error` envelope (`inspection_complete: false`, +no findings); the snapshots do not assert which parser stage emitted the failure. + +| id | What it is | Expected | +|----|------------|----------| +| `malformed-not-pdf` | Non-PDF bytes | `input-error` | +| `truncated-xref` | Header and objects, xref/trailer cut off | `input-error` | +| `cyclic-kids` | Pages tree with a self-referencing `/Kids` entry | `input-error` | +| `wrong-generation` | Catalog xref generation mismatched against object headers | `input-error` | +| `bad-object-stream` | Object stream declaring an absurd `/N` | `input-error` | +| `encrypted-without-password` | Standard `/Encrypt`, empty password | `input-error` | + +These are hand-built and tiny. Operator acceptance kills each run after 15 seconds +and rejects findings exit code 1. Matching fuzz seeds live under +`Fuzz/corpus/fuzz_pdf_parser/` where noted in the manifest. + ### Hand-built custom-check fixtures (MIC-145) Public corpora (veraPDF, Isartor, GWG — MIC-146) cover standards-backed checks but not diff --git a/loop-preflight/testdata/fixtures/bad-object-stream.pdf b/loop-preflight/testdata/fixtures/bad-object-stream.pdf new file mode 100644 index 000000000..57768adeb Binary files /dev/null and b/loop-preflight/testdata/fixtures/bad-object-stream.pdf differ diff --git a/loop-preflight/testdata/fixtures/cyclic-kids.pdf b/loop-preflight/testdata/fixtures/cyclic-kids.pdf new file mode 100644 index 000000000..c7244c02b Binary files /dev/null and b/loop-preflight/testdata/fixtures/cyclic-kids.pdf differ diff --git a/loop-preflight/testdata/fixtures/encrypted-without-password.pdf b/loop-preflight/testdata/fixtures/encrypted-without-password.pdf new file mode 100644 index 000000000..b76c7528f Binary files /dev/null and b/loop-preflight/testdata/fixtures/encrypted-without-password.pdf differ diff --git a/loop-preflight/testdata/fixtures/manifest.json b/loop-preflight/testdata/fixtures/manifest.json index 14b20bf9f..016cd57a2 100644 --- a/loop-preflight/testdata/fixtures/manifest.json +++ b/loop-preflight/testdata/fixtures/manifest.json @@ -343,6 +343,46 @@ "source": "hand-built", "notes": "Non-PDF input for malformed-input operator acceptance; manifest allowlist only." }, + { + "id": "truncated-xref", + "pdf": "truncated-xref.pdf", + "profile": "profiles/loop-default.json", + "expect": { "pass": false, "check_ids": [] }, + "source": "hand-built", + "notes": "PDF header and objects with xref/trailer removed; PdfTool must return input-error without hanging." + }, + { + "id": "cyclic-kids", + "pdf": "cyclic-kids.pdf", + "profile": "profiles/loop-default.json", + "expect": { "pass": false, "check_ids": [] }, + "source": "hand-built", + "notes": "Pages tree with a self-referencing /Kids entry; must fail closed as input-error." + }, + { + "id": "wrong-generation", + "pdf": "wrong-generation.pdf", + "profile": "profiles/loop-default.json", + "expect": { "pass": false, "check_ids": [] }, + "source": "hand-built", + "notes": "Catalog xref generation mismatched against object headers; must fail closed as input-error." + }, + { + "id": "bad-object-stream", + "pdf": "bad-object-stream.pdf", + "profile": "profiles/loop-default.json", + "expect": { "pass": false, "check_ids": [] }, + "source": "hand-built", + "notes": "Object stream declaring an absurd /N; must fail closed as input-error." + }, + { + "id": "encrypted-without-password", + "pdf": "encrypted-without-password.pdf", + "profile": "profiles/loop-default.json", + "expect": { "pass": false, "check_ids": [] }, + "source": "hand-built", + "notes": "Standard encryption dictionary with dummy O/U and no password; first empty attempt must cancel as input-error." + }, { "id": "overprint-cmyk-mode0-off", "pdf": "overprint-cmyk-mode0-off.pdf", diff --git a/loop-preflight/testdata/fixtures/truncated-xref.pdf b/loop-preflight/testdata/fixtures/truncated-xref.pdf new file mode 100644 index 000000000..effd77252 Binary files /dev/null and b/loop-preflight/testdata/fixtures/truncated-xref.pdf differ diff --git a/loop-preflight/testdata/fixtures/wrong-generation.pdf b/loop-preflight/testdata/fixtures/wrong-generation.pdf new file mode 100644 index 000000000..16e854344 Binary files /dev/null and b/loop-preflight/testdata/fixtures/wrong-generation.pdf differ diff --git a/loop-preflight/testdata/snapshots/bad-object-stream.json b/loop-preflight/testdata/snapshots/bad-object-stream.json new file mode 100644 index 000000000..065e17eff --- /dev/null +++ b/loop-preflight/testdata/snapshots/bad-object-stream.json @@ -0,0 +1,16 @@ +{ + "checks": [ + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": false, + "pass": false, + "verdict": { + "reason_code": "input-error", + "state": "input-error" + }, + "warnings": [ + ] +} diff --git a/loop-preflight/testdata/snapshots/cyclic-kids.json b/loop-preflight/testdata/snapshots/cyclic-kids.json new file mode 100644 index 000000000..065e17eff --- /dev/null +++ b/loop-preflight/testdata/snapshots/cyclic-kids.json @@ -0,0 +1,16 @@ +{ + "checks": [ + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": false, + "pass": false, + "verdict": { + "reason_code": "input-error", + "state": "input-error" + }, + "warnings": [ + ] +} diff --git a/loop-preflight/testdata/snapshots/encrypted-without-password.json b/loop-preflight/testdata/snapshots/encrypted-without-password.json new file mode 100644 index 000000000..065e17eff --- /dev/null +++ b/loop-preflight/testdata/snapshots/encrypted-without-password.json @@ -0,0 +1,16 @@ +{ + "checks": [ + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": false, + "pass": false, + "verdict": { + "reason_code": "input-error", + "state": "input-error" + }, + "warnings": [ + ] +} diff --git a/loop-preflight/testdata/snapshots/truncated-xref.json b/loop-preflight/testdata/snapshots/truncated-xref.json new file mode 100644 index 000000000..065e17eff --- /dev/null +++ b/loop-preflight/testdata/snapshots/truncated-xref.json @@ -0,0 +1,16 @@ +{ + "checks": [ + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": false, + "pass": false, + "verdict": { + "reason_code": "input-error", + "state": "input-error" + }, + "warnings": [ + ] +} diff --git a/loop-preflight/testdata/snapshots/wrong-generation.json b/loop-preflight/testdata/snapshots/wrong-generation.json new file mode 100644 index 000000000..065e17eff --- /dev/null +++ b/loop-preflight/testdata/snapshots/wrong-generation.json @@ -0,0 +1,16 @@ +{ + "checks": [ + ], + "errors": [ + ], + "fixups_available": [ + ], + "inspection_complete": false, + "pass": false, + "verdict": { + "reason_code": "input-error", + "state": "input-error" + }, + "warnings": [ + ] +} diff --git a/scripts/ci/check_fuzz_corpus.py b/scripts/ci/check_fuzz_corpus.py index b9ba94a63..f511afd37 100644 --- a/scripts/ci/check_fuzz_corpus.py +++ b/scripts/ci/check_fuzz_corpus.py @@ -79,6 +79,7 @@ def validate_manifest(manifest: dict, root: Path = ROOT) -> list[tuple[str, str] seen_ids: set[str] = set() manifest_paths: set[str] = set() + harnesses_with_seeds: set[str] = set() for index, case in enumerate(cases): label = f"cases[{index}]" @@ -137,8 +138,12 @@ def validate_manifest(manifest: dict, root: Path = ROOT) -> list[tuple[str, str] ) basename = parts[-1] + if basename in IGNORED_BASENAMES: + violations.append((rel_path, "ignored basename cannot be a manifest seed")) + continue if HASH_NAME.match(basename): violations.append((rel_path, "hash-named seed files are not allowed")) + continue absolute = root / rel_path if not absolute.is_file(): @@ -155,8 +160,14 @@ def validate_manifest(manifest: dict, root: Path = ROOT) -> list[tuple[str, str] violations.append( (rel_path, f"sha256 mismatch (manifest {digest}, actual {actual})") ) + continue + + harnesses_with_seeds.add(harness) for harness in sorted(HARNESS_TARGETS): + if harness not in harnesses_with_seeds: + violations.append((f"Fuzz/corpus/{harness}", "harness has no manifested seeds")) + harness_dir = root / "Fuzz" / "corpus" / harness if not harness_dir.is_dir(): violations.append((f"Fuzz/corpus/{harness}", "harness directory is missing")) diff --git a/scripts/ci/test_check_fuzz_corpus.py b/scripts/ci/test_check_fuzz_corpus.py index 6e1224d1e..1af27d2e3 100644 --- a/scripts/ci/test_check_fuzz_corpus.py +++ b/scripts/ci/test_check_fuzz_corpus.py @@ -87,6 +87,70 @@ def test_rejects_unowned_seed(self): violations = validate_manifest(manifest, root) self.assertTrue(any("missing from manifest" in reason for _, reason in violations)) + def test_rejects_harness_without_seeds(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + for harness in HARNESS_TARGETS: + (root / "Fuzz" / "corpus" / harness).mkdir(parents=True) + + seed = root / "Fuzz" / "corpus" / "fuzz_images" / "sample.bin" + seed.write_bytes(b"seed") + digest = "19b25856e1c150ca834cffc8b59b23adbd0ec0389e58eb22b3b64768098d002b" + manifest = { + "schema_version": 1, + "cases": [ + { + "id": "sample", + "path": "Fuzz/corpus/fuzz_images/sample.bin", + "harness": "fuzz_images", + "origin": "synthetic", + "issue": 0, + "sha256": digest, + "expected": "terminates-without-crash", + "minimized": True, + } + ], + } + violations = validate_manifest(manifest, root) + missing = {subject for subject, reason in violations if "no manifested seeds" in reason} + self.assertIn("Fuzz/corpus/fuzz_pdf_parser", missing) + self.assertIn("Fuzz/corpus/fuzz_content_stream", missing) + self.assertIn("Fuzz/corpus/fuzz_stream_filters", missing) + self.assertNotIn("Fuzz/corpus/fuzz_images", missing) + + def test_rejects_manifested_gitkeep(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + for harness in HARNESS_TARGETS: + harness_dir = root / "Fuzz" / "corpus" / harness + harness_dir.mkdir(parents=True) + gitkeep = harness_dir / ".gitkeep" + gitkeep.write_text("", encoding="utf-8") + + gitkeep = root / "Fuzz" / "corpus" / "fuzz_images" / ".gitkeep" + digest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + manifest = { + "schema_version": 1, + "cases": [ + { + "id": "gitkeep-only", + "path": "Fuzz/corpus/fuzz_images/.gitkeep", + "harness": "fuzz_images", + "origin": "synthetic", + "issue": 0, + "sha256": digest, + "expected": "terminates-without-crash", + "minimized": True, + } + ], + } + violations = validate_manifest(manifest, root) + self.assertTrue( + any("ignored basename cannot be a manifest seed" in reason for _, reason in violations) + ) + missing = {subject for subject, reason in violations if "no manifested seeds" in reason} + self.assertIn("Fuzz/corpus/fuzz_images", missing) + def test_rejects_duplicate_ids(self): manifest = { "schema_version": 1,