Skip to content

Commit f5b63ce

Browse files
committed
Tighten safejson conformance gate
1 parent 9def293 commit f5b63ce

16 files changed

Lines changed: 397 additions & 120 deletions

.github/workflows/ci.yml

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ on:
66
pull_request:
77
branches: [main]
88

9+
permissions:
10+
contents: read
11+
912
jobs:
10-
test:
13+
quality:
1114
runs-on: ubuntu-latest
15+
timeout-minutes: 10
1216
strategy:
1317
matrix:
1418
python-version: ["3.9", "3.10", "3.11", "3.12"]
@@ -20,12 +24,25 @@ jobs:
2024
python-version: ${{ matrix.python-version }}
2125
- name: Install dependencies
2226
run: |
23-
python -m pip install --upgrade pip
24-
pip install pytest pytest-cov mypy ruff
25-
pip install -e .
27+
python -m pip install --upgrade pip setuptools wheel
28+
python -m pip install -e ".[dev]"
2629
- name: Run ruff
2730
run: ruff check .
2831
- name: Run pytest with coverage
2932
run: pytest --cov=safejson --cov-branch --cov-report=term-missing --cov-fail-under=100
3033
- name: Run mypy strict
3134
run: mypy --strict src/safejson
35+
- name: Build distribution
36+
if: matrix.python-version == '3.12'
37+
run: python -m build
38+
- name: Smoke installed wheel
39+
if: matrix.python-version == '3.12'
40+
run: |
41+
python -m pip install --force-reinstall dist/safejson-*.whl
42+
python - <<'PY'
43+
import safejson
44+
45+
assert safejson.loads('{"ok": [1, true, null]}') == {
46+
"ok": [1, True, None],
47+
}
48+
PY

CONTRIBUTING.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
# Contributing
22

3-
Thanks for considering a contribution to `safejson`. Keep changes small, tested, and aligned with the existing public API.
3+
Thanks for considering a contribution to `safejson`. Keep changes
4+
small, tested, and aligned with the existing public API. This project
5+
parses hostile input, so correctness and honest documentation matter
6+
more than clever shortcuts.
47

58
## Local checks
69

710
```bash
8-
python -m pip install -e .
9-
python -m pip install pytest pytest-cov ruff
11+
python -m pip install -e ".[dev]"
1012
ruff check .
11-
pytest -q
13+
pytest --cov=safejson --cov-branch --cov-fail-under=100
14+
mypy --strict src/safejson
15+
python -m build
1216
```
1317

1418
## Contribution rules
1519

1620
- Add or update tests for behavior changes.
21+
- Keep branch coverage at 100%; new scanner branches need direct tests.
1722
- Keep README examples in sync with the implementation.
18-
- Keep runtime dependencies minimal and intentional.
23+
- Keep runtime dependencies at zero unless there is an exceptional reason.
1924
- Prefer explicit errors over surprising coercions.
25+
- Preserve the scanner/decode split: resource limits belong in the
26+
iterative scanner, while duplicate-key and type policies belong in
27+
decode hooks.

QUALITY.md

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,42 @@
11
# safejson quality bar
22

3-
This repository is part of a public portfolio, so the bar is practical correctness, clear documentation, and reproducible checks.
3+
This repository is part of a public portfolio, so the bar is practical
4+
correctness, clear documentation, and reproducible checks. For
5+
`safejson`, "quality" means the parser contract is precise enough for
6+
untrusted input, the public docs do not overclaim, and every policy is
7+
covered by executable tests.
48

59
## Required checks
610

7-
- `ruff check .` must pass with no ignored failures.
8-
- `pytest -q` must pass.
9-
- Packaging metadata must install the same source tree that tests exercise.
10-
- Public behavior changes must include happy-path, edge-case, and error tests.
11+
- `ruff check .` must pass with the full configured rule set.
12+
- `pytest --cov=safejson --cov-branch --cov-fail-under=100` must
13+
keep line and branch coverage at 100%.
14+
- `mypy --strict src/safejson` must pass with no ignores added to hide
15+
public API uncertainty.
16+
- The Python 3.9, 3.10, 3.11, and 3.12 GitHub Actions matrix must stay
17+
green.
18+
- `python -m build` must produce installable wheel and sdist artifacts.
19+
- Public behavior changes must include happy-path, edge-case, and error
20+
tests, plus docs updates when the API contract changes.
21+
22+
## Parser contract
23+
24+
- The streaming scanner must remain iterative; deeply nested hostile
25+
input should raise `LimitExceededError`, not `RecursionError`.
26+
- Syntax, `allow_nan`, and every `Limits` ceiling must be enforced before
27+
`json.loads` constructs containers.
28+
- Duplicate-key and type-whitelist behavior may rely on stdlib decode
29+
hooks, but README wording must continue to make that phase boundary
30+
explicit.
31+
- RFC 8259 conformance tests must compare ordinary valid values with
32+
Python's stdlib and pin the deliberate differences: `NaN`/`Infinity`
33+
rejection and duplicate-key rejection by default.
1134

1235
## Release checklist
1336

1437
- Run the required checks locally.
1538
- Confirm GitHub Actions is green on the default branch.
1639
- Confirm Dependabot and secret scanning have no open alerts.
1740
- Confirm the README still describes the actual API and scope.
41+
- Smoke-test the installed wheel with `safejson.loads(...)` before a
42+
release tag.

README.md

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
44
[![Python: 3.9+](https://img.shields.io/badge/Python-3.9%2B-blue.svg)](https://www.python.org/downloads/)
55
[![Typed: strict](https://img.shields.io/badge/Typed-mypy%20strict-blueviolet.svg)](https://mypy.readthedocs.io/)
6-
[![Tests: 183](https://img.shields.io/badge/Tests-183%20passing-brightgreen.svg)](#running-tests)
6+
[![Tests: 226](https://img.shields.io/badge/Tests-226%20passing-brightgreen.svg)](#running-tests)
77
[![Coverage: 100%](https://img.shields.io/badge/Coverage-100%25%20line%20%2B%20branch-brightgreen.svg)](#running-tests)
88

99
Hardened JSON parser for Python. Drop-in replacement for the
@@ -20,9 +20,9 @@ when the input might be hostile:
2020
in with `allow_nan=True`.
2121
- **Type whitelisting** — restrict the result to a known set of
2222
Python types.
23-
- **Streaming pre-scan**every limit is enforced before
24-
`json.loads` is allowed to allocate, so a malicious payload can
25-
never blow the stack or fill memory.
23+
- **Streaming pre-scan**syntax, `NaN` policy, and every resource
24+
limit are enforced before `json.loads` builds Python containers.
25+
Duplicate-key and type policies run through stdlib decode hooks.
2626
- **Zero runtime dependencies, fully typed (`py.typed`).**
2727

2828
## Why?
@@ -35,8 +35,25 @@ the input is attacker-controlled.
3535

3636
`safejson` keeps the stdlib decoder in the loop (it's the fast
3737
path), but wraps it with an iterative, allocation-free pre-scan
38-
that enforces every safety policy *before* any Python object is
39-
constructed.
38+
that rejects malformed input and resource-exhaustion shapes before
39+
the decoder constructs containers. Semantic policies that require
40+
decoded Python objects, such as duplicate-key detection and
41+
`allowed_types`, run through explicit stdlib hooks immediately after
42+
that scan.
43+
44+
## Standards Notes
45+
46+
`safejson` is intentionally stricter than Python's defaults where the
47+
JSON standard and real-world interoperability disagree. RFC 8259's
48+
number grammar does not include `NaN` or infinities, and object names
49+
are expected to be unique for interoperable behavior. Python's
50+
`json` module documents that it accepts those numeric constants and
51+
keeps the last value for repeated object names by default, while also
52+
exposing `parse_constant` and `object_pairs_hook` so callers can
53+
override that behavior.
54+
55+
References: [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259),
56+
[Python `json` documentation](https://docs.python.org/3/library/json.html).
4057

4158
## Install
4259

@@ -84,7 +101,7 @@ If you only need a yes/no answer ("would this parse safely?") and
84101
don't want to allocate the result, use `validate`:
85102

86103
```python
87-
from safejson import validate, Limits
104+
from safejson import Limits, SafeJsonError, validate
88105

89106
try:
90107
validate(payload, limits=Limits(max_depth=16))
@@ -110,8 +127,10 @@ Bytes are decoded as UTF-8; non-UTF-8 input raises
110127
### `validate(text, *, limits=None, allow_nan=False) -> None`
111128

112129
Run the safety scan without constructing the result. Useful for
113-
preflight checks or when you only need to gate an upstream
114-
decode.
130+
preflight checks or when you only need to gate an upstream decode.
131+
`validate` enforces syntax, resource limits, and `allow_nan`; it does
132+
not enforce `allow_duplicate_keys` or `allowed_types`, because those
133+
policies require decoded object pairs and Python values.
115134

116135
### `Limits(...)`
117136

@@ -120,7 +139,7 @@ Immutable, hashable dataclass:
120139
| Field | Default | Meaning |
121140
|-------|---------|---------|
122141
| `max_depth` | `64` | Maximum container nesting. |
123-
| `max_string_length` | `None` | Maximum chars per string / key. |
142+
| `max_string_length` | `None` | Maximum decoded chars per string / key; UTF-16 surrogate-pair escapes count as one. |
124143
| `max_array_length` | `None` | Maximum elements per array. |
125144
| `max_object_keys` | `None` | Maximum keys per object. |
126145
| `max_total_nodes` | `None` | Maximum scalar + container starts. |
@@ -161,10 +180,11 @@ ValueError:` catches still work.
161180
## Running Tests
162181

163182
```bash
164-
pip install pytest pytest-cov mypy
165-
pytest # 183 tests
166-
pytest --cov=safejson --cov-branch # 100% line + 100% branch
167-
mypy --strict src/safejson # strict, 0 errors
183+
python -m pip install -e ".[dev]"
184+
ruff check .
185+
pytest --cov=safejson --cov-branch --cov-fail-under=100 # 226 tests
186+
mypy --strict src/safejson # strict, 0 errors
187+
python -m build # wheel + sdist
168188
```
169189

170190
## License

SECURITY.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,17 @@ The `main` branch is the supported development line for `safejson`.
88

99
Please report dependency issues, malformed-input crashes, or other security concerns through GitHub's private vulnerability reporting when available, or by opening a minimal public issue without exploit payloads.
1010

11-
For untrusted input, callers should apply reasonable size limits before passing data into this project.
11+
Useful reports include:
12+
13+
- Inputs that trigger `RecursionError`, `MemoryError`, process crashes,
14+
or non-linear runtime before a configured limit fires.
15+
- Cases where `allow_nan=False`, duplicate-key rejection, or a `Limits`
16+
ceiling is bypassed.
17+
- Packaging or CI changes that install a different source tree than the
18+
tested package.
19+
20+
For untrusted input, callers should still apply a transport-level byte
21+
limit before passing data into this project. `safejson` enforces JSON
22+
structure and decoded string/container limits after input has already
23+
arrived in memory; it is not a streaming network reader and it is not a
24+
schema validator.

pyproject.toml

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
[build-system]
2-
requires = ["setuptools>=68", "wheel"]
2+
requires = ["setuptools>=69", "wheel"]
33
build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "safejson"
77
version = "0.1.0"
88
description = "Hardened JSON parser: configurable depth/size limits, duplicate-key detection, NaN/Infinity rejection, type whitelisting. Zero deps, fully typed."
99
readme = "README.md"
10-
license = { text = "MIT" }
10+
license = "MIT"
1111
requires-python = ">=3.9"
1212
authors = [{ name = "nripankadas07" }]
13-
keywords = ["json", "safe-json", "secure", "parser", "hardened", "limits", "depth", "duplicate-keys"]
13+
keywords = [
14+
"json",
15+
"safe-json",
16+
"secure",
17+
"parser",
18+
"hardened",
19+
"limits",
20+
"depth",
21+
"duplicate-keys",
22+
]
1423
classifiers = [
1524
"Development Status :: 4 - Beta",
1625
"Intended Audience :: Developers",
17-
"License :: OSI Approved :: MIT License",
1826
"Operating System :: OS Independent",
1927
"Programming Language :: Python :: 3",
2028
"Programming Language :: Python :: 3.9",
@@ -30,6 +38,18 @@ classifiers = [
3038
Homepage = "https://github.com/nripankadas07/safejson"
3139
Repository = "https://github.com/nripankadas07/safejson"
3240

41+
[project.optional-dependencies]
42+
dev = [
43+
"build>=1.2",
44+
"mypy>=1.10",
45+
"pytest>=8",
46+
"pytest-cov>=5",
47+
"ruff>=0.5",
48+
]
49+
50+
[tool.setuptools]
51+
package-dir = { "" = "src" }
52+
3353
[tool.setuptools.packages.find]
3454
where = ["src"]
3555

@@ -38,9 +58,39 @@ safejson = ["py.typed"]
3858

3959
[tool.pytest.ini_options]
4060
testpaths = ["tests"]
41-
addopts = "-ra --strict-markers"
61+
addopts = "-ra --strict-markers --strict-config"
4262

4363
[tool.mypy]
4464
strict = true
4565
files = ["src/safejson"]
4666
mypy_path = "src"
67+
68+
[tool.ruff]
69+
line-length = 88
70+
target-version = "py39"
71+
72+
[tool.ruff.lint]
73+
select = [
74+
"B",
75+
"C4",
76+
"E",
77+
"F",
78+
"I",
79+
"N",
80+
"PERF",
81+
"PYI",
82+
"RET",
83+
"RUF",
84+
"SIM",
85+
"UP",
86+
"W",
87+
]
88+
89+
[tool.coverage.run]
90+
branch = true
91+
source = ["safejson"]
92+
93+
[tool.coverage.report]
94+
fail_under = 100
95+
show_missing = true
96+
skip_covered = true

src/safejson/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
for parsing untrusted input.
88
99
:mod:`safejson` wraps the stdlib decoder with a streaming
10-
allocation-free pre-scan that enforces configurable safety limits
11-
before any Python objects are constructed::
10+
allocation-free pre-scan that enforces configurable resource limits
11+
before Python containers are constructed, then applies semantic hooks
12+
for duplicate-key and type policy::
1213
1314
from safejson import loads, Limits
1415
@@ -44,8 +45,8 @@
4445
"DisallowedTypeError",
4546
"DuplicateKeyError",
4647
"JsonSyntaxError",
47-
"Limits",
4848
"LimitExceededError",
49+
"Limits",
4950
"SafeJsonError",
5051
"loads",
5152
"validate",

src/safejson/_errors.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@
2323

2424
from __future__ import annotations
2525

26-
from typing import Optional
27-
2826

2927
class SafeJsonError(ValueError):
3028
"""Base class for every :mod:`safejson` failure."""
@@ -76,8 +74,7 @@ def __init__(
7674
self.limit = limit
7775
self.position = position
7876
super().__init__(
79-
f"{limit_name} exceeded at position {position}: "
80-
f"{actual} > {limit}"
77+
f"{limit_name} exceeded at position {position}: {actual} > {limit}"
8178
)
8279

8380

@@ -93,7 +90,7 @@ class DuplicateKeyError(SafeJsonError):
9390
since :mod:`json` does not surface per-pair positions).
9491
"""
9592

96-
def __init__(self, key: str, position: Optional[int] = None) -> None:
93+
def __init__(self, key: str, position: int | None = None) -> None:
9794
self.key = key
9895
self.position = position
9996
location = "" if position is None else f" at position {position}"
@@ -112,8 +109,7 @@ class DisallowedConstantError(SafeJsonError):
112109
def __init__(self, constant: str) -> None:
113110
self.constant = constant
114111
super().__init__(
115-
f"disallowed constant {constant!r} "
116-
"(pass allow_nan=True to permit)"
112+
f"disallowed constant {constant!r} (pass allow_nan=True to permit)"
117113
)
118114

119115

0 commit comments

Comments
 (0)