Skip to content

Commit 8d29e8f

Browse files
MrPredicclaude
andcommitted
test: property-based / fuzz tests for the safety invariants (Hypothesis)
Coverage proves a branch ran; these prove the contract holds across inputs no one hand-wrote. Invariants pinned: - shell parser is total (never raises) and never certifies a command carrying an unmodelled metachar as its own token - sql tolerates arbitrary statements without raising - egress to any non-allow-listed host is always a value move; allow-listed never - filesystem snapshot survives arbitrary on-disk states (symlinks, FIFOs, chmod) without raising or hanging - CanonicalDelta.merge is a monoid (associative, empty is identity) CI installs hypothesis; 105 tests pass; coverage stays 100%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1f511fc commit 8d29e8f

4 files changed

Lines changed: 189 additions & 1 deletion

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@ jobs:
1616
- uses: actions/setup-python@v5
1717
with:
1818
python-version: ${{ matrix.python-version }}
19-
- run: pip install pytest pytest-cov
19+
- run: pip install pytest pytest-cov hypothesis
2020
- run: python -m pytest -q --cov --cov-fail-under=100

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ build/
88
.venv/
99
venv/
1010
.DS_Store
11+
.hypothesis/

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ Hardening release after a second, fresh-eyes critical review. No API changes.
88
CI (`--cov-fail-under=100`). This closed previously untested branches, including
99
the security-critical solana **owner-reassignment (takeover)** detection, SOL/
1010
token inflows, null-account RPC responses, and the filesystem fail-closed paths.
11+
- **Tests:** added property-based / fuzz tests (Hypothesis) pinning the safety
12+
invariants across random inputs: the shell parser is total and never certifies a
13+
command carrying an unmodelled metachar; sql tolerates arbitrary statements;
14+
egress to any non-allow-listed host is always surfaced; the filesystem snapshot
15+
survives arbitrary on-disk states (symlinks, FIFOs, chmod) without raising or
16+
hanging; and `CanonicalDelta.merge` is a monoid (associative, empty identity).
1117

1218
- **Security fix (filesystem):** snapshotting is no longer crash- or hang-prone.
1319
An action that created a dangling symlink made `simulate()` raise (instead of

tests/test_properties.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""Property-based / fuzz tests: assert the invariants that make simdiff safe.
2+
3+
Line coverage proves a branch *ran*; these prove the tool upholds its contract
4+
across inputs nobody wrote a test for. A failure here is a real bug, not a flake.
5+
"""
6+
7+
import os
8+
import shutil
9+
import sqlite3
10+
import tempfile
11+
12+
import pytest
13+
from hypothesis import HealthCheck, given, settings
14+
from hypothesis import strategies as st
15+
16+
from simdiff import simdiff
17+
from simdiff.adapters.shell import ShellAdapter
18+
from simdiff.adapters.sql import SqlAdapter
19+
from simdiff.adapters.http import HttpAdapter, HttpRequest
20+
from simdiff.delta import (
21+
AuthorityGrant,
22+
CanonicalDelta,
23+
DataAccess,
24+
ResourceUse,
25+
ValueMove,
26+
)
27+
28+
29+
# --- shell: a conservative interpreter must be total and fail-closed ----------
30+
31+
@given(st.text())
32+
@settings(max_examples=400)
33+
def test_shell_is_total_and_returns_a_delta(cmd):
34+
# INVARIANT: the parser never raises on any input — at worst it fails closed.
35+
delta = simdiff(cmd, ShellAdapter())
36+
assert isinstance(delta, CanonicalDelta)
37+
38+
39+
_UNMODELLED_SINGLES = ["|", "$", "`", "*", "?", "~", "<", "{", "}", "[", "]"]
40+
41+
42+
@given(
43+
metachar=st.sampled_from(_UNMODELLED_SINGLES),
44+
words=st.lists(st.from_regex(r"[a-z0-9_.]{1,8}", fullmatch=True), max_size=4),
45+
)
46+
@settings(max_examples=300)
47+
def test_shell_standalone_metachar_is_never_certified(metachar, words):
48+
# INVARIANT: a command carrying an unmodelled metachar as its own token must
49+
# never be reported as fully classified (it could expand to anything).
50+
cmd = " ".join(["echo", *words, metachar])
51+
delta = simdiff(cmd, ShellAdapter())
52+
assert delta.fully_classified is False
53+
54+
55+
# --- sql: arbitrary statements must never crash the simulator -----------------
56+
57+
@given(st.text())
58+
@settings(max_examples=300, suppress_health_check=[HealthCheck.function_scoped_fixture])
59+
def test_sql_is_total_and_returns_a_delta(stmt):
60+
conn = sqlite3.connect(":memory:")
61+
conn.execute("CREATE TABLE t (id INTEGER, v TEXT)")
62+
conn.commit()
63+
try:
64+
delta = simdiff(stmt, SqlAdapter(conn))
65+
assert isinstance(delta, CanonicalDelta)
66+
finally:
67+
conn.close()
68+
69+
70+
# --- http: egress to a non-allow-listed host is always surfaced ---------------
71+
72+
_HOST = st.from_regex(r"[a-z][a-z0-9-]{0,15}(\.[a-z][a-z0-9-]{0,15}){0,3}", fullmatch=True)
73+
74+
75+
@given(host=_HOST, path=st.text(alphabet="abcdef0123/", max_size=20), body=st.text(max_size=40))
76+
@settings(max_examples=300)
77+
def test_http_external_host_is_always_egress(host, path, body):
78+
# INVARIANT: data leaving to a host that is not allow-listed is always a value
79+
# move, no matter what the payload looks like (the destination cannot be hidden).
80+
adapter = HttpAdapter(allowed_hosts=set())
81+
delta = simdiff(HttpRequest("POST", f"https://{host}/{path}", body=body), adapter)
82+
assert len(delta.value_moves) == 1
83+
assert delta.value_moves[0].dst == host.lower()
84+
85+
86+
@given(host=_HOST, body=st.text(max_size=40))
87+
@settings(max_examples=200)
88+
def test_http_allowed_host_is_never_egress(host, body):
89+
# INVARIANT: the same request to an allow-listed host is never flagged.
90+
adapter = HttpAdapter(allowed_hosts={host})
91+
delta = simdiff(HttpRequest("POST", f"https://{host}/x", body=body), adapter)
92+
assert delta.value_moves == []
93+
assert delta.fully_classified is True
94+
95+
96+
# --- filesystem: snapshotting must survive any on-disk state ------------------
97+
98+
@st.composite
99+
def _fs_ops(draw):
100+
name = st.from_regex(r"[a-z0-9_]{1,8}", fullmatch=True)
101+
op = st.one_of(
102+
st.tuples(st.just("write"), name, st.binary(max_size=64)),
103+
st.tuples(st.just("mkdir"), name, st.none()),
104+
st.tuples(st.just("symlink"), name, name), # may dangle
105+
st.tuples(st.just("fifo"), name, st.none()), # would block a naive hasher
106+
st.tuples(st.just("remove"), name, st.none()),
107+
st.tuples(st.just("chmod"), name, st.integers(0, 0o777)),
108+
)
109+
return draw(st.lists(op, max_size=5))
110+
111+
112+
def _apply_fs_ops(root, ops):
113+
for kind, name, arg in ops:
114+
path = os.path.join(root, name)
115+
try:
116+
if kind == "write":
117+
# O_NONBLOCK so a name-collision with a reader-less FIFO raises
118+
# (ENXIO) instead of blocking — that would hang the *action*, which
119+
# is the caller's code, not simdiff. We only fuzz simdiff here.
120+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_NONBLOCK, 0o644)
121+
try:
122+
os.write(fd, arg)
123+
finally:
124+
os.close(fd)
125+
elif kind == "mkdir":
126+
os.mkdir(path)
127+
elif kind == "symlink":
128+
os.symlink(arg, path)
129+
elif kind == "fifo":
130+
os.mkfifo(path)
131+
elif kind == "remove":
132+
os.remove(path)
133+
elif kind == "chmod":
134+
os.chmod(path, arg)
135+
except OSError:
136+
pass # the action itself failing is fine; simdiff must still not crash
137+
138+
139+
@given(ops=_fs_ops())
140+
@settings(max_examples=120, deadline=None,
141+
suppress_health_check=[HealthCheck.function_scoped_fixture])
142+
def test_fs_simulate_is_total_under_arbitrary_ops(ops):
143+
# INVARIANT: no sequence of filesystem ops (incl. symlinks, FIFOs, chmod) makes
144+
# simulate() raise or hang; it always returns a delta (fail-closed if unsure).
145+
from simdiff.adapters.filesystem import FilesystemAdapter
146+
147+
sandbox = tempfile.mkdtemp(prefix="simdiff-prop-")
148+
try:
149+
delta = simdiff(lambda root: _apply_fs_ops(root, ops), FilesystemAdapter(sandbox))
150+
assert isinstance(delta, CanonicalDelta)
151+
finally:
152+
shutil.rmtree(sandbox, ignore_errors=True)
153+
154+
155+
# --- delta algebra: merge is a monoid (associative, with the empty identity) --
156+
157+
_value = st.builds(ValueMove, asset=st.text(max_size=4), src=st.text(max_size=4),
158+
dst=st.text(max_size=4), amount=st.floats(allow_nan=False, allow_infinity=False))
159+
_access = st.builds(DataAccess, resource=st.text(max_size=4),
160+
mode=st.sampled_from(["READ", "WRITE", "CREATE", "DELETE"]))
161+
_delta = st.builds(
162+
CanonicalDelta,
163+
value_moves=st.lists(_value, max_size=3),
164+
data_access=st.lists(_access, max_size=3),
165+
unknown=st.lists(st.text(max_size=4), max_size=3),
166+
resource_use=st.builds(ResourceUse, io_bytes=st.integers(0, 99), rows=st.integers(0, 99)),
167+
)
168+
169+
170+
@given(a=_delta, b=_delta, c=_delta)
171+
@settings(max_examples=200)
172+
def test_merge_is_associative(a, b, c):
173+
left = a.merge(b).merge(c).to_dict()
174+
right = a.merge(b.merge(c)).to_dict()
175+
assert left == right
176+
177+
178+
@given(a=_delta)
179+
@settings(max_examples=100)
180+
def test_merge_with_empty_is_identity(a):
181+
assert a.merge(CanonicalDelta()).to_dict() == a.to_dict()

0 commit comments

Comments
 (0)