Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest, windows-latest, macos-latest]
permissions:
contents: read
steps:
Expand Down Expand Up @@ -83,7 +83,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest, windows-latest, macos-latest]
permissions:
contents: read
steps:
Expand Down Expand Up @@ -138,7 +138,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest, windows-latest, macos-latest]
permissions:
contents: read
actions: write
Expand Down Expand Up @@ -174,7 +174,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest, windows-latest, macos-latest]
permissions:
contents: read
steps:
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Thanks for considering a patch. This repo is a small Flask app plus a hash-route
- **Python 3.12** (matches CI)
- **Node 20+** (only if you change `static/js/` or run frontend unit tests)

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.
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.

### Bootstrap (Windows PowerShell)

Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ select = ["E", "F", "W", "I"]
[tool.ruff.lint.isort]
combine-as-imports = true

[tool.hypothesis]
max_examples = 200
Comment thread
clean6378-max-it marked this conversation as resolved.
Outdated

[tool.ruff.lint.per-file-ignores]
# CLI bootstrap: sys.path must be set before local imports.
"scripts/export.py" = ["E402"]
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ types-Flask==1.1.6
pytest-cov>=5.0
ruff>=0.9.0
pip-audit>=2.7.0
hypothesis>=6.100.0
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@

from __future__ import annotations

import os
import shutil
from collections.abc import Mapping
from pathlib import Path

import pytest
from hypothesis import settings

from app import create_app

if os.environ.get("CI"):
settings.register_profile("ci", max_examples=100, deadline=None)
settings.load_profile("ci")

FIXTURES = Path(__file__).parent / "fixtures"


Expand Down
242 changes: 242 additions & 0 deletions tests/test_parser_fuzz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
"""Hypothesis fuzz tests for parse_session — adversarial JSONL must not crash."""

from __future__ import annotations

import json
import os
import sys
import tempfile
from pathlib import Path

from hypothesis import given, settings, strategies as st

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from utils.jsonl_parser import parse_session

FUZZ_SETTINGS = settings(max_examples=200, deadline=5000)

ALLOWED_EXCEPTIONS: tuple[type[BaseException], ...] = ()


def _fuzz_jsonl_path(name: str) -> Path:
return Path(tempfile.mkdtemp()) / name
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


def _parse_file_without_crash(path: str) -> None:
try:
parse_session(path)
except Exception as exc:
if ALLOWED_EXCEPTIONS and isinstance(exc, ALLOWED_EXCEPTIONS):
return
raise AssertionError(f"unhandled {type(exc).__name__}: {exc}") from exc


def _write_jsonl(path: os.PathLike[str], lines: list[str]) -> str:
path_str = str(path)
with open(path_str, "w", encoding="utf-8", errors="replace") as f:
for line in lines:
f.write(line)
if not line.endswith("\n"):
f.write("\n")
return path_str


# ---------------------------------------------------------------------------
# Strategy building blocks
# ---------------------------------------------------------------------------

_RECORD_TYPES = st.sampled_from(
["user", "assistant", "system", "progress", "totally-new-claude-record", "future-record-v99"]
)

_json_leaf = st.one_of(
st.none(),
st.booleans(),
st.integers(),
st.floats(allow_nan=False, allow_infinity=False),
Comment thread
clean6378-max-it marked this conversation as resolved.
Outdated
st.text(max_size=200),
)

_json_value = st.recursive(
_json_leaf,
lambda children: st.one_of(
st.lists(children, max_size=8),
st.dictionaries(st.text(min_size=1, max_size=20), children, max_size=8),
),
max_leaves=40,
)

_minimal_user = {
"type": "user",
"timestamp": "2026-06-11T00:00:00Z",
"message": {"content": [{"type": "text", "text": "hello"}]},
}

_minimal_assistant = {
"type": "assistant",
"timestamp": "2026-06-11T00:00:01Z",
"message": {
"model": "claude-test",
"content": [{"type": "text", "text": "hi"}],
"usage": {"input_tokens": 1, "output_tokens": 1},
},
}


@st.composite
def structured_entry(draw: st.DrawFn) -> dict:
"""Fuzzed session record with optional missing/extra fields."""
record_type = draw(_RECORD_TYPES)
base: dict = {"type": record_type}
if draw(st.booleans()):
base["timestamp"] = draw(
st.one_of(
st.text(max_size=40),
st.just("2026-06-11T00:00:00Z"),
st.integers(),
)
)
if record_type == "user":
entry = dict(_minimal_user)
entry.update(base)
if draw(st.booleans()):
entry.pop("message", None)
if draw(st.booleans()):
entry["message"] = draw(
st.one_of(
st.text(),
st.dictionaries(st.text(max_size=10), _json_value, max_size=6),
st.just({"content": draw(_json_value)}),
)
)
elif record_type == "assistant":
entry = dict(_minimal_assistant)
entry.update(base)
if draw(st.booleans()):
msg = dict(entry.get("message", {}))
if draw(st.booleans()):
msg["usage"] = draw(
st.one_of(st.text(), st.integers(), st.dictionaries(st.text(), _json_value))
)
if draw(st.booleans()):
msg["model"] = draw(st.one_of(st.text(), st.integers(), st.none()))
if draw(st.booleans()):
msg["content"] = draw(_json_value)
entry["message"] = msg
elif record_type == "system":
entry = {**base, "subtype": draw(st.text(max_size=30)), "content": draw(_json_value)}
elif record_type == "progress":
entry = {
**base,
"data": draw(st.dictionaries(st.text(max_size=10), _json_value, max_size=6)),
}
else:
entry = {**base, "payload": draw(_json_value)}
for _ in range(draw(st.integers(min_value=0, max_value=3))):
entry[draw(st.text(min_size=1, max_size=15))] = draw(_json_value)
return entry


# ---------------------------------------------------------------------------
# Fuzz strategies
# ---------------------------------------------------------------------------


@FUZZ_SETTINGS
@given(st.lists(st.text(min_size=0, max_size=500), min_size=0, max_size=30))
def test_raw_line_soup_does_not_crash(lines: list[str]) -> None:
"""Malformed JSON lines, garbage text, and empty lines."""
path = _write_jsonl(_fuzz_jsonl_path("soup.jsonl"), lines)
_parse_file_without_crash(path)


@FUZZ_SETTINGS
@given(st.text(min_size=1, max_size=500))
def test_truncated_json_line(prefix: str) -> None:
"""Partial JSON simulating concurrent writes."""
half = json.dumps(prefix)[: max(1, len(prefix) // 2)]
line = '{"type": "user", "message": {"content": ' + half
path = _write_jsonl(_fuzz_jsonl_path("trunc.jsonl"), [line])
_parse_file_without_crash(path)


@FUZZ_SETTINGS
@given(st.lists(structured_entry(), min_size=0, max_size=15))
def test_structured_entries_with_fuzzed_fields(entries: list[dict]) -> None:
"""Unknown types, missing/extra fields, wrong-typed nested values."""
lines = [json.dumps(e, default=str) for e in entries]
path = _write_jsonl(_fuzz_jsonl_path("structured.jsonl"), lines)
_parse_file_without_crash(path)


@FUZZ_SETTINGS
@given(st.lists(_json_value, min_size=1, max_size=5))
def test_deep_nesting_in_message_content(nested_values: list) -> None:
entry = {
"type": "user",
"timestamp": "2026-06-11T00:00:00Z",
"message": {"content": nested_values},
}
path = _write_jsonl(_fuzz_jsonl_path("nest.jsonl"), [json.dumps(entry, default=str)])
_parse_file_without_crash(path)


@FUZZ_SETTINGS
@given(st.integers(min_value=10_000, max_value=50_000))
def test_long_line_payload(length: int) -> None:
payload = "x" * length
entry = {
"type": "user",
"timestamp": "2026-06-11T00:00:00Z",
"message": {"content": [{"type": "text", "text": payload}]},
}
path = _write_jsonl(_fuzz_jsonl_path("long.jsonl"), [json.dumps(entry)])
_parse_file_without_crash(path)


@FUZZ_SETTINGS
@given(st.lists(st.text(max_size=100), min_size=1, max_size=10))
def test_empty_lines_between_records(texts: list[str]) -> None:
lines: list[str] = []
for text in texts:
lines.append("")
lines.append(
json.dumps(
{
"type": "user",
"timestamp": "2026-06-11T00:00:00Z",
"message": {"content": [{"type": "text", "text": text}]},
}
)
)
lines.append(" ")
path = _write_jsonl(_fuzz_jsonl_path("empty.jsonl"), lines)
_parse_file_without_crash(path)


def test_null_bytes_in_file(tmp_path: Path) -> None:
"""Binary-safe write with null bytes; parser uses errors='replace'."""
valid = json.dumps(
{
"type": "user",
"timestamp": "2026-06-11T00:00:00Z",
"message": {"content": [{"type": "text", "text": "after null"}]},
}
).encode("utf-8")
blob = b"\x00garbage\x00\n" + valid + b"\n\x00"
path = tmp_path / "nulls.jsonl"
path.write_bytes(blob)
_parse_file_without_crash(str(path))


def test_unknown_record_type_is_graceful(tmp_path: Path) -> None:
"""Unknown type values are counted but do not crash parsing."""
lines = [
'{"type": "totally-new-claude-record", "timestamp": "2026-06-11T00:00:00Z", "payload": {}}',
'{"type": "user", "message": {"content": [{"type": "text", "text": "ok"}]}}',
]
path = _write_jsonl(tmp_path / "unknown.jsonl", lines)
session = parse_session(path)
assert session["metadata"]["entry_counts"].get("totally-new-claude-record") == 1
assert len(session["messages"]) >= 1
Loading
Loading