Skip to content

Commit 2390e86

Browse files
committed
Harden robustness boundary evidence
1 parent 1ffb39b commit 2390e86

8 files changed

Lines changed: 97 additions & 11 deletions

File tree

‎src/format_bench/robustness/cases.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ def generated_cases(seed: int, count: int) -> tuple[CaseSpec, ...]:
7474
"rows": rng.randint(0, 2049),
7575
"string_seed": rng.getrandbits(32),
7676
}
77-
parameters["cardinality"] = rng.randint(1, max(parameters["rows"], 1))
77+
rows = parameters["rows"]
78+
parameters["cardinality"] = 0 if rows == 0 else rng.randint(1, rows)
7879
encoded = json.dumps(parameters, sort_keys=True, separators=(",", ":")).encode()
7980
suffix = hashlib.sha256(encoded).hexdigest()[:10]
8081
cases.append(

‎src/format_bench/robustness/native.py‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,12 @@ def _corpus_source(run_dir: Path, run: dict, target: NativeTarget) -> Path:
210210
return _safe_run_file(run_root, run["input"]["source"], "native corpus")
211211

212212

213+
def _corpus_directory(run_dir: Path, run: dict, target: NativeTarget) -> Path:
214+
# Resolve the seed before _process changes the child's cwd. libFuzzer treats
215+
# a directory argument as a fuzzing corpus and a file argument as replay.
216+
return _corpus_source(run_dir, run, target).parent
217+
218+
213219
def _safe_run_file(run_root: Path, value: object, label: str) -> Path:
214220
if not isinstance(value, str):
215221
raise ValueError(f"{label} path must be a string")
@@ -303,7 +309,11 @@ def _case(
303309
},
304310
}
305311
try:
306-
source = _corpus_source(run_dir, run, target) if target.engine == "coverage-guided" and target.binary_name else None
312+
corpus = (
313+
_corpus_directory(run_dir, run, target)
314+
if target.engine == "coverage-guided" and target.binary_name
315+
else None
316+
)
307317
except ValueError as error:
308318
return {
309319
**base,
@@ -322,11 +332,11 @@ def _case(
322332
if target.engine == "coverage-guided" and target.binary_name is None:
323333
command = ["cargo", "fuzz", "run", target.official_target, "--", *limits]
324334
elif target.engine == "coverage-guided":
325-
assert source is not None
335+
assert corpus is not None
326336
command = [
327337
str(binary), "-print_final_stats=1",
328338
f"-timeout={_fuzz_seconds(duration_seconds)}",
329-
*limits, source.as_posix(),
339+
*limits,
330340
]
331341
else:
332342
command = [
@@ -337,6 +347,8 @@ def _case(
337347
]
338348
if target.engine == "coverage-guided" and fixture:
339349
command.append("-runs=1")
350+
if corpus is not None:
351+
command.append(corpus.as_posix())
340352
process, stdout, stderr = _process(
341353
command, work_dir, duration_seconds + 5, output_budget_bytes=_NATIVE_OUTPUT_BUDGET_BYTES
342354
)
@@ -357,8 +369,8 @@ def _case(
357369
}
358370
if binary is not None:
359371
details["binary_sha256"] = _sha256(binary)
360-
if source is not None:
361-
details["corpus"] = source.relative_to(run_root).as_posix()
372+
if corpus is not None:
373+
details["corpus"] = corpus.relative_to(run_root).as_posix()
362374
result = {
363375
**base,
364376
"observed": observed,

‎src/format_bench/robustness/runner.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,13 @@ def _worker_response(stdout: str) -> WorkerResponse:
113113

114114

115115
def _bounded_details(details: dict[str, object]) -> dict[str, object]:
116-
encoded = (json.dumps(details, indent=2, sort_keys=True) + "\n").encode()
116+
try:
117+
encoded = (json.dumps(details, indent=2, sort_keys=True) + "\n").encode()
118+
except (RecursionError, TypeError, ValueError) as error:
119+
return {
120+
"truncated": True,
121+
"error_type": type(error).__name__,
122+
}
117123
if len(encoded) <= MAX_WORKER_DETAILS_BYTES:
118124
return details
119125
return {
@@ -333,7 +339,7 @@ def _outcome(
333339
try:
334340
response = _worker_response(stdout)
335341
return ObservedOutcome(response["observed"]), response["details"]
336-
except (json.JSONDecodeError, KeyError, ValueError, TypeError):
342+
except (json.JSONDecodeError, KeyError, RecursionError, ValueError, TypeError):
337343
return ObservedOutcome.HARNESS_FAILED, {}
338344

339345

‎src/format_bench/robustness/worker.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,14 @@ def run_request(request_path: Path) -> dict:
7070
"observed": ObservedOutcome.UNSUPPORTED,
7171
"details": {"error_type": type(error).__name__},
7272
}
73-
except Exception as error:
73+
except ValueError as error:
7474
observed = ObservedOutcome.VALUE_MISMATCH
75+
details = {
76+
"error_type": type(error).__name__,
77+
"message": str(error)[-500:],
78+
}
79+
except Exception as error:
80+
observed = ObservedOutcome.HARNESS_FAILED
7581
details = {"error_type": type(error).__name__, "message": str(error)[-500:]}
7682
else:
7783
effective_manifest = json.loads(manifest.read_text(encoding="utf-8"))

‎tests/test_robustness_cases.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ def test_generated_cases_preserve_requested_rows() -> None:
5757
assert table.num_rows == case.options["rows"]
5858
if case.options["rows"]:
5959
assert case.options["cardinality"] <= case.options["rows"]
60+
else:
61+
assert case.options["cardinality"] == 0
6062

6163

6264
def test_malformed_case_has_no_valid_arrow_materialization() -> None:

‎tests/test_robustness_native.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,41 @@ def test_native_suite_records_arrow_target_and_process_evidence(
8787
assert not Path(case["stdout"]).is_absolute()
8888

8989

90+
def test_native_suite_passes_absolute_directory_corpus_for_relative_run_dir(
91+
tmp_path: Path, monkeypatch
92+
) -> None:
93+
root = Path(__file__).parents[1]
94+
monkeypatch.chdir(root)
95+
run_dir = Path(os.path.relpath(tmp_path / "run", root))
96+
build_dir = tmp_path / "build"
97+
_verified_fixture(root, run_dir)
98+
_fake_target(build_dir, "arrow-csv-fuzz", "print('native smoke')")
99+
commands: list[list[str]] = []
100+
101+
def fake_process(command, cwd, timeout, output_budget_bytes):
102+
commands.append(list(command))
103+
return {"timed_out": False, "signal": None, "exit_code": 0}, "", ""
104+
105+
monkeypatch.setattr(native, "_process", fake_process)
106+
result_path = run_native(
107+
root,
108+
run_dir,
109+
duration_seconds=1,
110+
artifact_budget_mib=4,
111+
targets=(replace(ARROW_NATIVE_TARGETS[0], source_commit=None),),
112+
build_dir=build_dir,
113+
)
114+
115+
case = json.loads(result_path.read_text())["results"]["robustness_v1"]["cases"][0]
116+
corpus = Path(commands[0][-1])
117+
assert corpus.is_absolute()
118+
assert corpus.is_dir()
119+
assert (corpus / "source.csv").is_file()
120+
assert case["details"]["corpus"] == "input"
121+
assert case["observed"] == ObservedOutcome.ACCEPTED.value
122+
assert case["verdict"] == RobustnessVerdict.PASS.value
123+
124+
90125
def test_native_suite_keeps_missing_target_as_unsupported(tmp_path: Path) -> None:
91126
root = Path(__file__).parents[1]
92127
run_dir = tmp_path / "run"

‎tests/test_robustness_runner.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ def test_runner_classifies_signal_and_timeout(tmp_path: Path) -> None:
5656
assert timed_out["process"]["signal"] is None
5757

5858

59+
def test_runner_classifies_timeout_after_bytes_output(tmp_path: Path) -> None:
60+
code = "import os,time; os.write(1,b'worker-bytes\\n'); time.sleep(5)"
61+
timed_out = _run(tmp_path, code)
62+
assert timed_out["observed"] is ObservedOutcome.TIMED_OUT
63+
assert timed_out["process"]["signal"] is None
64+
assert (tmp_path / timed_out["stdout"]).read_bytes() == b"worker-bytes\n"
65+
66+
5967
def test_process_reaps_descendant_after_leader_exit(tmp_path: Path) -> None:
6068
pid_path = tmp_path / "descendant.pid"
6169
ready_path = tmp_path / "descendant.ready"
@@ -152,6 +160,22 @@ def test_runner_classifies_invalid_output_and_valid_roundtrip_failure(tmp_path:
152160
assert valid["verdict"] is RobustnessVerdict.FAIL
153161

154162

163+
def test_runner_bounds_deep_worker_details_without_escaping(tmp_path: Path) -> None:
164+
code = (
165+
"depth=1000; "
166+
"payload='{" + '\"observed\":\"REJECTED\",\"details\":' + "' "
167+
"+ '{\"nested\":' * depth + '0' + '}' * depth + '}'"
168+
"; print(payload)"
169+
)
170+
result = _run(tmp_path, code)
171+
assert result["observed"] is ObservedOutcome.REJECTED
172+
assert result["verdict"] is RobustnessVerdict.PASS
173+
assert result["details"] == {
174+
"truncated": True,
175+
"error_type": "RecursionError",
176+
}
177+
178+
155179
def test_runner_reuses_unused_stream_budget(tmp_path: Path) -> None:
156180
code = "import json; print(json.dumps({'observed':'REJECTED','details':{'message':'x'*200}}))"
157181
result = _run(tmp_path, code, output_budget_bytes=512)

‎tests/test_robustness_targets.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ def test_worker_reports_raised_builtin_verification_as_value_mismatch(
152152
assert result["details"]["error_type"] == "ValueError"
153153

154154

155-
def test_worker_reports_non_value_verification_errors_as_value_mismatch(
155+
def test_worker_reports_non_value_verification_errors_as_harness_failure(
156156
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
157157
) -> None:
158158
class BrokenVerifier:
@@ -176,5 +176,5 @@ def verify_roundtrip(self, path, manifest):
176176

177177
result = run_request(Path("request.json"))
178178

179-
assert result["observed"] is ObservedOutcome.VALUE_MISMATCH
179+
assert result["observed"] is ObservedOutcome.HARNESS_FAILED
180180
assert result["details"]["error_type"] == "TypeError"

0 commit comments

Comments
 (0)