Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .clusterfuzzlite/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
ARG UV_IMAGE=ghcr.io/astral-sh/uv@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823

FROM ${UV_IMAGE} AS uv-bin

FROM gcr.io/oss-fuzz-base/base-builder-python@sha256:60e8ef87f2c0367254ff979a4dea61dad2684b001e3e666e2cc6fe992064dbbf

WORKDIR /src/newsdom-api
COPY --from=uv-bin /uv /uvx /usr/local/bin/
COPY . .
COPY .clusterfuzzlite/build.sh /src/build.sh
21 changes: 21 additions & 0 deletions .clusterfuzzlite/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/bash
set -euo pipefail

cd "$SRC/newsdom-api"

uv sync --frozen --extra fuzz
export PATH="$SRC/newsdom-api/.venv/bin:$PATH"

for fuzzer in $(find fuzzers -name '*_fuzzer.py'); do
fuzzer_basename=$(basename -s .py "$fuzzer")
fuzzer_package="${fuzzer_basename}.pkg"

pyinstaller --distpath "$OUT" --onefile --name "$fuzzer_package" "$fuzzer"

cat >"$OUT/$fuzzer_basename" <<EOF
#!/bin/sh
this_dir=\$(dirname "\$0")
exec "\$this_dir/$fuzzer_package" "\$@"
EOF
chmod +x "$OUT/$fuzzer_basename"
done
1 change: 1 addition & 0 deletions .clusterfuzzlite/project.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
language: python
32 changes: 32 additions & 0 deletions .github/workflows/clusterfuzzlite.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: clusterfuzzlite

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
fuzz:
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd

- name: Build fuzzers
uses: google/clusterfuzzlite/actions/build_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c
with:
language: python
sanitizer: address

- name: Run fuzzers
uses: google/clusterfuzzlite/actions/run_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c
with:
language: python
sanitizer: address
mode: code-change
fuzz-seconds: 300
github-token: ${{ github.token }}
11 changes: 8 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
FROM python:3.12-slim AS builder
ARG PYTHON_BASE=python:3.12-slim@sha256:5072b08ad74609c5329ab4085a96dfa873de565fb4751a4cfcd7dcc427661df0
ARG UV_IMAGE=ghcr.io/astral-sh/uv@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823

FROM ${UV_IMAGE} AS uv-bin

FROM ${PYTHON_BASE} AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1

WORKDIR /app

RUN python -m pip install --no-cache-dir "uv==0.11.3"
COPY --from=uv-bin /uv /uvx /bin/

COPY pyproject.toml uv.lock README.md ./
COPY src/ src/

RUN uv sync --frozen --no-dev

FROM python:3.12-slim AS runtime
FROM ${PYTHON_BASE} AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
Expand Down
14 changes: 9 additions & 5 deletions Dockerfile.nvidia
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04 AS builder
ARG NVIDIA_BASE=nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04@sha256:46cb48a4abfbc40c836fe57bc05a07101b6458fffc63bbdfd6a50db98c9358bd
ARG UV_IMAGE=ghcr.io/astral-sh/uv@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823

FROM ${UV_IMAGE} AS uv-bin

FROM --platform=linux/amd64 ${NVIDIA_BASE} AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
Expand All @@ -10,15 +15,14 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends python3 python3-pip python3-venv ca-certificates && \
rm -rf /var/lib/apt/lists/*

RUN python3 -m pip install --no-cache-dir "uv==0.11.3"
COPY --from=uv-bin /uv /uvx /usr/local/bin/

COPY pyproject.toml uv.lock README.md ./
COPY src/ src/

RUN uv sync --python python3 --frozen --no-dev && \
uv pip install --python .venv/bin/python "mineru[pipeline]==3.0.9"
RUN uv sync --python python3 --frozen --no-dev --extra nvidia

FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04 AS runtime
FROM --platform=linux/amd64 ${NVIDIA_BASE} AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ curl -F "file=@sample.pdf" http://127.0.0.1:8000/parse
pytest
```

### Fuzzing smoke

```bash
python fuzzers/dom_builder_fuzzer.py --smoke tests/fixtures/mineru_sample.json
```

The repository also enforces a `quality-gate` workflow with 100% source coverage and docstring audit coverage.

## Fixtures and provenance
Expand Down
6 changes: 6 additions & 0 deletions fuzzers/corpus/dom_builder_fuzzer/mineru_sample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[{
"type": "text",
"text": "次世代電池材料",
"bbox": [0, 0, 100, 20],
"text_level": 1
}]
62 changes: 62 additions & 0 deletions fuzzers/dom_builder_fuzzer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Fuzz and smoke-test the DOM normalization boundary."""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any

from newsdom_api.dom_builder import build_dom


def _coerce_content_list(candidate: Any) -> list[dict[str, Any]]:
"""Return a MinerU-like content list or an empty list."""

if not isinstance(candidate, list):
return []
return [item for item in candidate if isinstance(item, dict)]


def exercise_dom_builder(raw_bytes: bytes) -> None:
"""Exercise build_dom with bytes that may or may not decode into JSON blocks."""

try:
decoded = raw_bytes.decode("utf-8", errors="ignore")
candidate = json.loads(decoded)
except Exception:
return
build_dom(_coerce_content_list(candidate), document_id="fuzz")


def _run_smoke(seed_path: Path) -> None:
"""Run one deterministic normalization pass from a known corpus seed."""

sample = json.loads(seed_path.read_text(encoding="utf-8"))
build_dom(_coerce_content_list(sample), document_id="smoke")


def main(argv: list[str] | None = None) -> int:
"""Run either deterministic smoke mode or Atheris fuzz mode."""

parser = argparse.ArgumentParser()
parser.add_argument("--smoke", type=Path)
args = parser.parse_args(argv)

if args.smoke is not None:
_run_smoke(args.smoke)
return 0

import atheris

def test_one_input(data: bytes) -> None:
exercise_dom_builder(data)

atheris.Setup(sys.argv, test_one_input)
atheris.Fuzz()
return 0


if __name__ == "__main__":
raise SystemExit(main())
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ version = "0.1.0"
description = "DOM-style parser API for scanned Japanese newspaper PDFs"
readme = "README.md"
requires-python = ">=3.10,<3.14"
license = {text = "MIT"}
license = "MIT"
authors = [{name = "Seongho Bae"}]
dependencies = [
"fastapi>=0.115,<1.0",
Expand All @@ -31,6 +31,13 @@ docs = [
"mkdocs>=1.6,<2.0",
"mkdocs-material>=9.6,<9.7",
]
nvidia = [
"mineru[pipeline]==3.0.9 ; platform_system == 'Linux' and platform_machine == 'x86_64'",
]
fuzz = [
"atheris==3.0.0 ; platform_system == 'Linux' and python_version >= '3.11'",
"pyinstaller==6.16.0",
]

[tool.setuptools]
package-dir = {"" = "src"}
Expand Down
6 changes: 5 additions & 1 deletion tests/test_docker_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ def test_dockerfile_uses_project_metadata_and_src_layout():
assert "pyproject.toml" in text
assert "uv.lock" in text
assert "src/" in text
assert "python:3.12-slim@sha256:" in text
assert "ghcr.io/astral-sh/uv@sha256:" in text


def test_dockerfile_runs_uvicorn_with_healthcheck_and_external_mineru_path():
Expand All @@ -36,7 +38,9 @@ def test_dockerfile_runs_uvicorn_with_healthcheck_and_external_mineru_path():

def test_nvidia_dockerfile_installs_mineru_pipeline_stack():
text = Path("Dockerfile.nvidia").read_text(encoding="utf-8")
assert "mineru[pipeline]==3.0.9" in text
assert "nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04@sha256:" in text
assert "ghcr.io/astral-sh/uv@sha256:" in text
assert "--extra nvidia" in text
assert "NEWSDOM_MINERU_BIN" in text


Expand Down
56 changes: 56 additions & 0 deletions tests/test_fuzzing_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import subprocess
import sys
from pathlib import Path


def test_clusterfuzzlite_integration_files_exist():
assert Path(".clusterfuzzlite/project.yaml").exists()
assert Path(".clusterfuzzlite/Dockerfile").exists()
assert Path(".clusterfuzzlite/build.sh").exists()
assert Path(".github/workflows/clusterfuzzlite.yml").exists()
assert Path("fuzzers/dom_builder_fuzzer.py").exists()
assert Path("fuzzers/corpus/dom_builder_fuzzer/mineru_sample.json").exists()


def test_clusterfuzzlite_workflow_runs_pinned_python_code_change_fuzzing():
text = Path(".github/workflows/clusterfuzzlite.yml").read_text(encoding="utf-8")
assert (
"google/clusterfuzzlite/actions/build_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c"
in text
)
assert (
"google/clusterfuzzlite/actions/run_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c"
in text
)
assert "language: python" in text
assert "mode: code-change" in text
assert "fuzz-seconds: 300" in text


def test_clusterfuzzlite_dockerfile_places_build_script_at_src_root():
text = Path(".clusterfuzzlite/Dockerfile").read_text(encoding="utf-8")
assert "gcr.io/oss-fuzz-base/base-builder-python@sha256:" in text
assert "ghcr.io/astral-sh/uv@sha256:" in text
assert "COPY .clusterfuzzlite/build.sh /src/build.sh" in text


def test_clusterfuzzlite_build_script_uses_locked_uv_fuzz_extra():
text = Path(".clusterfuzzlite/build.sh").read_text(encoding="utf-8")
assert "uv sync --frozen --extra fuzz" in text
assert "pip3 install . pyinstaller atheris" not in text


def test_dom_builder_fuzzer_smoke_mode_runs_without_cluster():
completed = subprocess.run(
[
sys.executable,
"fuzzers/dom_builder_fuzzer.py",
"--smoke",
"tests/fixtures/mineru_sample.json",
],
capture_output=True,
text=True,
check=False,
)
assert completed.returncode == 0, completed.stderr
assert "Traceback" not in completed.stderr
18 changes: 17 additions & 1 deletion tests/test_project_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,25 @@

def test_project_metadata_does_not_bundle_mineru_extra():
text = Path("pyproject.toml").read_text(encoding="utf-8")
assert "mineru[pipeline]" not in text
dependencies_section = text.split("dependencies = [", 1)[1].split("]", 1)[0]
assert "mineru[pipeline]" not in dependencies_section


def test_docs_theme_range_stays_below_warning_release():
text = Path("pyproject.toml").read_text(encoding="utf-8")
assert '"mkdocs-material>=9.6,<9.7"' in text


def test_project_uses_spdx_license_string_not_deprecated_table():
text = Path("pyproject.toml").read_text(encoding="utf-8")
assert 'license = "MIT"' in text
assert 'license = {text = "MIT"}' not in text


def test_project_declares_locked_nvidia_and_fuzz_extras():
text = Path("pyproject.toml").read_text(encoding="utf-8")
assert "nvidia = [" in text
assert '"mineru[pipeline]==3.0.9 ;' in text
assert "fuzz = [" in text
assert '"atheris==3.0.0 ;' in text
assert '"pyinstaller==6.16.0"' in text
Loading