Skip to content

Commit 52db5dc

Browse files
test: add Hypothesis fuzz coverage and macos-latest CI matrix
Add tests/test_parser_fuzz.py with strategies for malformed/truncated JSONL, unknown record types, missing/extra fields, deep nesting, long lines, empty lines, and null bytes. Harden parse_session against adversarial inputs found by fuzz (non-dict JSON values, unhashable type keys, non-str metadata fields). Extend CI matrix to macos-latest for pytest, integration-tests, js-tests, and lint-and-audit. Document Ubuntu + Windows + macOS CI in CONTRIBUTING.
1 parent 8083f3e commit 52db5dc

7 files changed

Lines changed: 278 additions & 20 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ jobs:
4949
strategy:
5050
fail-fast: false
5151
matrix:
52-
os: [ubuntu-latest, windows-latest]
52+
os: [ubuntu-latest, windows-latest, macos-latest]
5353
permissions:
5454
contents: read
5555
steps:
@@ -83,7 +83,7 @@ jobs:
8383
strategy:
8484
fail-fast: false
8585
matrix:
86-
os: [ubuntu-latest, windows-latest]
86+
os: [ubuntu-latest, windows-latest, macos-latest]
8787
permissions:
8888
contents: read
8989
steps:
@@ -138,7 +138,7 @@ jobs:
138138
strategy:
139139
fail-fast: false
140140
matrix:
141-
os: [ubuntu-latest, windows-latest]
141+
os: [ubuntu-latest, windows-latest, macos-latest]
142142
permissions:
143143
contents: read
144144
actions: write
@@ -174,7 +174,7 @@ jobs:
174174
strategy:
175175
fail-fast: false
176176
matrix:
177-
os: [ubuntu-latest, windows-latest]
177+
os: [ubuntu-latest, windows-latest, macos-latest]
178178
permissions:
179179
contents: read
180180
steps:

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Thanks for considering a patch. This repo is a small Flask app plus a hash-route
99
- **Python 3.12** (matches CI)
1010
- **Node 20+** (only if you change `static/js/` or run frontend unit tests)
1111

12-
CI runs **`ruff check`**, **`ruff format --check`**, **`pip-audit`**, **`pytest`**, **integration tests**, and **Vitest** on **ubuntu-latest** and **windows-latest** (Python 3.12, Node 20). Type-check (`mypy`) and production install smoke run on Ubuntu only.
12+
CI runs **`ruff check`**, **`ruff format --check`**, **`pip-audit`**, **`pytest`**, **integration tests**, and **Vitest** on **Ubuntu, Windows, and macOS** (`ubuntu-latest`, `windows-latest`, `macos-latest`; Python 3.12, Node 20). Type-check (`mypy`) and production install smoke run on Ubuntu only.
1313

1414
### Bootstrap (Windows PowerShell)
1515

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ select = ["E", "F", "W", "I"]
2727
[tool.ruff.lint.isort]
2828
combine-as-imports = true
2929

30+
[tool.hypothesis]
31+
max_examples = 200
32+
3033
[tool.ruff.lint.per-file-ignores]
3134
# CLI bootstrap: sys.path must be set before local imports.
3235
"scripts/export.py" = ["E402"]

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ types-Flask==1.1.6
55
pytest-cov>=5.0
66
ruff>=0.9.0
77
pip-audit>=2.7.0
8+
hypothesis>=6.100.0

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@
22

33
from __future__ import annotations
44

5+
import os
56
import shutil
67
from collections.abc import Mapping
78
from pathlib import Path
89

910
import pytest
11+
from hypothesis import settings
1012

1113
from app import create_app
1214

15+
if os.environ.get("CI"):
16+
settings.register_profile("ci", max_examples=100, deadline=None)
17+
settings.load_profile("ci")
18+
1319
FIXTURES = Path(__file__).parent / "fixtures"
1420

1521

tests/test_parser_fuzz.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""Hypothesis fuzz tests for parse_session — adversarial JSONL must not crash."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
import sys
8+
import tempfile
9+
from pathlib import Path
10+
11+
from hypothesis import given, settings, strategies as st
12+
13+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
14+
15+
from utils.jsonl_parser import parse_session
16+
17+
FUZZ_SETTINGS = settings(max_examples=200, deadline=5000)
18+
19+
ALLOWED_EXCEPTIONS: tuple[type[BaseException], ...] = ()
20+
21+
22+
def _fuzz_jsonl_path(name: str) -> Path:
23+
return Path(tempfile.mkdtemp()) / name
24+
25+
26+
def _parse_file_without_crash(path: str) -> None:
27+
try:
28+
parse_session(path)
29+
except Exception as exc:
30+
if ALLOWED_EXCEPTIONS and isinstance(exc, ALLOWED_EXCEPTIONS):
31+
return
32+
raise AssertionError(f"unhandled {type(exc).__name__}: {exc}") from exc
33+
34+
35+
def _write_jsonl(path: os.PathLike[str], lines: list[str]) -> str:
36+
path_str = str(path)
37+
with open(path_str, "w", encoding="utf-8", errors="replace") as f:
38+
for line in lines:
39+
f.write(line)
40+
if not line.endswith("\n"):
41+
f.write("\n")
42+
return path_str
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# Strategy building blocks
47+
# ---------------------------------------------------------------------------
48+
49+
_RECORD_TYPES = st.sampled_from(
50+
["user", "assistant", "system", "progress", "totally-new-claude-record", "future-record-v99"]
51+
)
52+
53+
_json_leaf = st.one_of(
54+
st.none(),
55+
st.booleans(),
56+
st.integers(),
57+
st.floats(allow_nan=False, allow_infinity=False),
58+
st.text(max_size=200),
59+
)
60+
61+
_json_value = st.recursive(
62+
_json_leaf,
63+
lambda children: st.one_of(
64+
st.lists(children, max_size=8),
65+
st.dictionaries(st.text(min_size=1, max_size=20), children, max_size=8),
66+
),
67+
max_leaves=40,
68+
)
69+
70+
_minimal_user = {
71+
"type": "user",
72+
"timestamp": "2026-06-11T00:00:00Z",
73+
"message": {"content": [{"type": "text", "text": "hello"}]},
74+
}
75+
76+
_minimal_assistant = {
77+
"type": "assistant",
78+
"timestamp": "2026-06-11T00:00:01Z",
79+
"message": {
80+
"model": "claude-test",
81+
"content": [{"type": "text", "text": "hi"}],
82+
"usage": {"input_tokens": 1, "output_tokens": 1},
83+
},
84+
}
85+
86+
87+
@st.composite
88+
def structured_entry(draw: st.DrawFn) -> dict:
89+
"""Fuzzed session record with optional missing/extra fields."""
90+
record_type = draw(_RECORD_TYPES)
91+
base: dict = {"type": record_type}
92+
if draw(st.booleans()):
93+
base["timestamp"] = draw(
94+
st.one_of(
95+
st.text(max_size=40),
96+
st.just("2026-06-11T00:00:00Z"),
97+
st.integers(),
98+
)
99+
)
100+
if record_type == "user":
101+
entry = dict(_minimal_user)
102+
entry.update(base)
103+
if draw(st.booleans()):
104+
entry.pop("message", None)
105+
if draw(st.booleans()):
106+
entry["message"] = draw(
107+
st.one_of(
108+
st.text(),
109+
st.dictionaries(st.text(max_size=10), _json_value, max_size=6),
110+
st.just({"content": draw(_json_value)}),
111+
)
112+
)
113+
elif record_type == "assistant":
114+
entry = dict(_minimal_assistant)
115+
entry.update(base)
116+
if draw(st.booleans()):
117+
msg = dict(entry.get("message", {}))
118+
if draw(st.booleans()):
119+
msg["usage"] = draw(
120+
st.one_of(st.text(), st.integers(), st.dictionaries(st.text(), _json_value))
121+
)
122+
if draw(st.booleans()):
123+
msg["model"] = draw(st.one_of(st.text(), st.integers(), st.none()))
124+
if draw(st.booleans()):
125+
msg["content"] = draw(_json_value)
126+
entry["message"] = msg
127+
elif record_type == "system":
128+
entry = {**base, "subtype": draw(st.text(max_size=30)), "content": draw(_json_value)}
129+
elif record_type == "progress":
130+
entry = {
131+
**base,
132+
"data": draw(st.dictionaries(st.text(max_size=10), _json_value, max_size=6)),
133+
}
134+
else:
135+
entry = {**base, "payload": draw(_json_value)}
136+
for _ in range(draw(st.integers(min_value=0, max_value=3))):
137+
entry[draw(st.text(min_size=1, max_size=15))] = draw(_json_value)
138+
return entry
139+
140+
141+
# ---------------------------------------------------------------------------
142+
# Fuzz strategies
143+
# ---------------------------------------------------------------------------
144+
145+
146+
@FUZZ_SETTINGS
147+
@given(st.lists(st.text(min_size=0, max_size=500), min_size=0, max_size=30))
148+
def test_raw_line_soup_does_not_crash(lines: list[str]) -> None:
149+
"""Malformed JSON lines, garbage text, and empty lines."""
150+
path = _write_jsonl(_fuzz_jsonl_path("soup.jsonl"), lines)
151+
_parse_file_without_crash(path)
152+
153+
154+
@FUZZ_SETTINGS
155+
@given(st.text(min_size=1, max_size=500))
156+
def test_truncated_json_line(prefix: str) -> None:
157+
"""Partial JSON simulating concurrent writes."""
158+
half = json.dumps(prefix)[: max(1, len(prefix) // 2)]
159+
line = '{"type": "user", "message": {"content": ' + half
160+
path = _write_jsonl(_fuzz_jsonl_path("trunc.jsonl"), [line])
161+
_parse_file_without_crash(path)
162+
163+
164+
@FUZZ_SETTINGS
165+
@given(st.lists(structured_entry(), min_size=0, max_size=15))
166+
def test_structured_entries_with_fuzzed_fields(entries: list[dict]) -> None:
167+
"""Unknown types, missing/extra fields, wrong-typed nested values."""
168+
lines = [json.dumps(e, default=str) for e in entries]
169+
path = _write_jsonl(_fuzz_jsonl_path("structured.jsonl"), lines)
170+
_parse_file_without_crash(path)
171+
172+
173+
@FUZZ_SETTINGS
174+
@given(st.lists(_json_value, min_size=1, max_size=5))
175+
def test_deep_nesting_in_message_content(nested_values: list) -> None:
176+
entry = {
177+
"type": "user",
178+
"timestamp": "2026-06-11T00:00:00Z",
179+
"message": {"content": nested_values},
180+
}
181+
path = _write_jsonl(_fuzz_jsonl_path("nest.jsonl"), [json.dumps(entry, default=str)])
182+
_parse_file_without_crash(path)
183+
184+
185+
@FUZZ_SETTINGS
186+
@given(st.integers(min_value=10_000, max_value=50_000))
187+
def test_long_line_payload(length: int) -> None:
188+
payload = "x" * length
189+
entry = {
190+
"type": "user",
191+
"timestamp": "2026-06-11T00:00:00Z",
192+
"message": {"content": [{"type": "text", "text": payload}]},
193+
}
194+
path = _write_jsonl(_fuzz_jsonl_path("long.jsonl"), [json.dumps(entry)])
195+
_parse_file_without_crash(path)
196+
197+
198+
@FUZZ_SETTINGS
199+
@given(st.lists(st.text(max_size=100), min_size=1, max_size=10))
200+
def test_empty_lines_between_records(texts: list[str]) -> None:
201+
lines: list[str] = []
202+
for text in texts:
203+
lines.append("")
204+
lines.append(
205+
json.dumps(
206+
{
207+
"type": "user",
208+
"timestamp": "2026-06-11T00:00:00Z",
209+
"message": {"content": [{"type": "text", "text": text}]},
210+
}
211+
)
212+
)
213+
lines.append(" ")
214+
path = _write_jsonl(_fuzz_jsonl_path("empty.jsonl"), lines)
215+
_parse_file_without_crash(path)
216+
217+
218+
def test_null_bytes_in_file(tmp_path: Path) -> None:
219+
"""Binary-safe write with null bytes; parser uses errors='replace'."""
220+
valid = json.dumps(
221+
{
222+
"type": "user",
223+
"timestamp": "2026-06-11T00:00:00Z",
224+
"message": {"content": [{"type": "text", "text": "after null"}]},
225+
}
226+
).encode("utf-8")
227+
blob = b"\x00garbage\x00\n" + valid + b"\n\x00"
228+
path = tmp_path / "nulls.jsonl"
229+
path.write_bytes(blob)
230+
_parse_file_without_crash(str(path))
231+
232+
233+
def test_unknown_record_type_is_graceful(tmp_path: Path) -> None:
234+
"""Unknown type values are counted but do not crash parsing."""
235+
lines = [
236+
'{"type": "totally-new-claude-record", "timestamp": "2026-06-11T00:00:00Z", "payload": {}}',
237+
'{"type": "user", "message": {"content": [{"type": "text", "text": "ok"}]}}',
238+
]
239+
path = _write_jsonl(tmp_path / "unknown.jsonl", lines)
240+
session = parse_session(path)
241+
assert session["metadata"]["entry_counts"].get("totally-new-claude-record") == 1
242+
assert len(session["messages"]) >= 1

0 commit comments

Comments
 (0)