Skip to content

Commit f68353c

Browse files
thepastaclawclaude
andcommitted
fix(fuzz): address latest review feedback
- Workflow corpus replay now classifies timeout/kill exits (124/137/143) distinctly from generic crashes in both log and FAILED_TARGETS, and captures the exit code in the non-empty-corpus branch like the empty branch does. - Defer src/version.h lookup in seed_corpus_from_chain.py until a stream version prefix is actually needed; --help no longer requires an in-tree checkout. Add --stream-version CLI flag and DASH_FUZZ_STREAM_VERSION env override. - Correct synthetic LLMQ seeds to match C++ serialization: CRecoveredSig now includes msgHash; CSigSesAnn uses VARINT(sessionId) + llmqType + quorumHash + id + msgHash; CSigShare adds quorumMember and 96-byte sigShare; CDKGComplaint includes a full 96-byte BLS signature after both DYNBITSETs. Self-checks assert the corrected byte sizes. - Replace stale TODO above the MNAUTH skip in process_message.cpp with a pointer to process_message_dash, which already exercises the Dash-aware MNAUTH setup. - Add src/test/fuzz/util_dash.h to non-backported.txt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ecb73ac commit f68353c

5 files changed

Lines changed: 124 additions & 20 deletions

File tree

.github/workflows/test-fuzz.yml

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,17 @@ jobs:
157157
corpus_dir="/tmp/fuzz_corpus/${target}"
158158
artifact_prefix="${ARTIFACT_DIR}/${target}-"
159159
160+
# Classify a non-zero exit code from `timeout`/libFuzzer. timeout(1) reports
161+
# 124 when the time budget elapsed, and 128+SIGNAL when the child was killed
162+
# (137 = SIGKILL, 143 = SIGTERM). Treat those as "timeout/kill" and any other
163+
# non-zero status as a generic crash. Both still fail the job.
164+
classify_exit() {
165+
case "$1" in
166+
124|137|143) echo "timeout" ;;
167+
*) echo "crash" ;;
168+
esac
169+
}
170+
160171
if [ ! -d "$corpus_dir" ] || [ -z "$(ls -A "$corpus_dir" 2>/dev/null)" ]; then
161172
# No corpus for this target — run with empty input for 10s
162173
# This catches basic initialization crashes
@@ -174,9 +185,10 @@ jobs:
174185
PASSED=$((PASSED + 1))
175186
else
176187
EXIT_CODE=$?
177-
echo "::error::FAIL: $target exited with code $EXIT_CODE"
188+
KIND=$(classify_exit "$EXIT_CODE")
189+
echo "::error::FAIL: $target exited with code $EXIT_CODE (${KIND})"
178190
FAILED=$((FAILED + 1))
179-
FAILED_TARGETS="${FAILED_TARGETS} - ${target} (exit code ${EXIT_CODE})\n"
191+
FAILED_TARGETS="${FAILED_TARGETS} - ${target} (${KIND}, exit code ${EXIT_CODE})\n"
180192
fi
181193
echo "::endgroup::"
182194
continue
@@ -192,9 +204,11 @@ jobs:
192204
echo "PASS: $target"
193205
PASSED=$((PASSED + 1))
194206
else
195-
echo "::error::FAIL: $target"
207+
EXIT_CODE=$?
208+
KIND=$(classify_exit "$EXIT_CODE")
209+
echo "::error::FAIL: $target exited with code $EXIT_CODE (${KIND})"
196210
FAILED=$((FAILED + 1))
197-
FAILED_TARGETS="${FAILED_TARGETS} - ${target}\n"
211+
FAILED_TARGETS="${FAILED_TARGETS} - ${target} (${KIND}, exit code ${EXIT_CODE})\n"
198212
fi
199213
echo "::endgroup::"
200214
done <<< "$TARGETS"

contrib/fuzz/continuous_fuzz_daemon.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ if ! [[ "$TIME_PER_TARGET" =~ ^[0-9]+$ ]] || (( TIME_PER_TARGET < 1 )); then
8585
echo "ERROR: --time-per-target must be a positive integer, got '$TIME_PER_TARGET'" >&2
8686
exit 1
8787
fi
88-
if ! [[ "$RSS_LIMIT_MB" =~ ^[0-9]+$ ]]; then
88+
if ! [[ "$RSS_LIMIT_MB" =~ ^[0-9]+$ ]] || (( RSS_LIMIT_MB < 1 )); then
8989
echo "ERROR: --rss-limit must be a positive integer, got '$RSS_LIMIT_MB'" >&2
9090
exit 1
9191
fi

contrib/fuzz/seed_corpus_from_chain.py

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import argparse
2020
import hashlib
2121
import json
22+
import os
2223
import re
2324
import subprocess
2425
import sys
@@ -52,9 +53,9 @@ def _read_protocol_version():
5253
return int(match.group(1))
5354

5455

55-
# Must match src/version.h PROTOCOL_VERSION. Several fuzz harnesses read a
56-
# 4-byte little-endian int from the start of the buffer and use it as the
57-
# stream version before deserializing the object:
56+
# The stream version is needed by several fuzz harnesses that read a 4-byte
57+
# little-endian int from the start of the buffer and use it as the stream
58+
# version before deserializing the object:
5859
# * The Dash-specific helpers DashDeserializeFromFuzzingInput /
5960
# DashRoundtripFromFuzzingInput (src/test/fuzz/deserialize_dash.cpp,
6061
# src/test/fuzz/roundtrip_dash.cpp), used by dash_*_deserialize and
@@ -65,10 +66,37 @@ def _read_protocol_version():
6566
# via DeserializeFromFuzzingInput when no explicit protocol_version is
6667
# passed.
6768
# Chain data we extract is serialized at PROTOCOL_VERSION, so we prepend
68-
# that value to seeds for those targets.
69-
PROTOCOL_VERSION = _read_protocol_version()
70-
STREAM_VERSION = PROTOCOL_VERSION
71-
STREAM_VERSION_PREFIX = STREAM_VERSION.to_bytes(4, byteorder="little", signed=False)
69+
# that value to seeds for those targets. The lookup is deferred to first use
70+
# so `--help` and callers that supply --stream-version / DASH_FUZZ_STREAM_VERSION
71+
# don't require an in-tree src/version.h.
72+
_STREAM_VERSION_OVERRIDE = None
73+
_STREAM_VERSION_CACHE = None
74+
75+
76+
def _resolve_stream_version():
77+
"""Return the stream version, preferring an explicit override and falling back
78+
to parsing src/version.h. Cached after first successful resolution."""
79+
global _STREAM_VERSION_CACHE
80+
if _STREAM_VERSION_CACHE is not None:
81+
return _STREAM_VERSION_CACHE
82+
if _STREAM_VERSION_OVERRIDE is not None:
83+
_STREAM_VERSION_CACHE = _STREAM_VERSION_OVERRIDE
84+
return _STREAM_VERSION_CACHE
85+
env_override = os.environ.get("DASH_FUZZ_STREAM_VERSION")
86+
if env_override:
87+
try:
88+
_STREAM_VERSION_CACHE = int(env_override)
89+
except ValueError as e:
90+
raise RuntimeError(
91+
f"DASH_FUZZ_STREAM_VERSION must be an integer, got {env_override!r}"
92+
) from e
93+
return _STREAM_VERSION_CACHE
94+
_STREAM_VERSION_CACHE = _read_protocol_version()
95+
return _STREAM_VERSION_CACHE
96+
97+
98+
def _stream_version_prefix():
99+
return _resolve_stream_version().to_bytes(4, byteorder="little", signed=False)
72100

73101
# Non-Dash targets (outside the dash_* naming convention) whose harnesses
74102
# also consume the 4-byte stream version prefix described above.
@@ -113,7 +141,7 @@ def save_corpus_input(output_dir, target_name, data_hex):
113141
return False
114142

115143
if _needs_stream_version_prefix(target_name):
116-
raw_bytes = STREAM_VERSION_PREFIX + raw_bytes
144+
raw_bytes = _stream_version_prefix() + raw_bytes
117145

118146
filename = hashlib.sha256(raw_bytes).hexdigest()[:16]
119147
filepath = target_dir / filename
@@ -1169,21 +1197,27 @@ def create_synthetic_seeds(output_dir):
11691197
((1).to_bytes(2, "little") + _serialize_uint32(0) + minimal_final_commitment).hex(),
11701198
],
11711199
"dash_recovered_sig_deserialize": [
1172-
"64" + "00" * 32 + "00" * 32 + "00" * 96, # llmqType + quorumHash + id + sig
1200+
# CRecoveredSig: llmqType (uint8) + quorumHash (32) + id (32) + msgHash (32) + sig (96)
1201+
"64" + "00" * 32 + "00" * 32 + "00" * 32 + "00" * 96,
11731202
],
11741203
"dash_sig_ses_ann_deserialize": [
1175-
"64" + "00" * 32 + "00000000" + "00" * 32, # llmqType + quorumHash + nSessionId + id
1204+
# CSigSesAnn: VARINT(sessionId=0) + llmqType (uint8) + quorumHash (32) + id (32) + msgHash (32)
1205+
"00" + "64" + "00" * 32 + "00" * 32 + "00" * 32,
11761206
],
11771207
"dash_sig_share_deserialize": [
1178-
"64" + "00" * 32 + "00000000" + "00" * 32 + "0000" + "00" * 96,
1208+
# CSigShare: llmqType (uint8) + quorumHash (32) + quorumMember (uint16 LE)
1209+
# + id (32) + msgHash (32) + sigShare (96, BLS lazy)
1210+
"64" + "00" * 32 + "0000" + "00" * 32 + "00" * 32 + "00" * 96,
11791211
],
11801212
# MNAuth
11811213
"dash_mnauth_deserialize": [
11821214
"00" * 32 + "00" * 32 + "00" * 96, # proRegTxHash + signChallenge + sig
11831215
],
11841216
# DKG messages
11851217
"dash_dkg_complaint_deserialize": [
1186-
"64" + "00" * 32 + "00" * 32 + "0000" + "00", # minimal
1218+
# CDKGComplaint: llmqType + quorumHash + proTxHash + DYNBITSET(badMembers)
1219+
# + DYNBITSET(complainForMembers) + sig (96 bytes)
1220+
"64" + "00" * 32 + "00" * 32 + "00" + "00" + "00" * 96,
11871221
],
11881222
"dash_dkg_justification_deserialize": [
11891223
# llmqType (uint8) + quorumHash (32) + proTxHash (32) +
@@ -1283,17 +1317,57 @@ def _run_helper_self_checks():
12831317
"dash_bls_ies_multi_recipient_blobs_roundtrip",
12841318
"dash_coinjoin_entry_deserialize",
12851319
"dash_coinjoin_entry_roundtrip",
1320+
"dash_dkg_complaint_deserialize",
1321+
"dash_dkg_complaint_roundtrip",
12861322
"dash_dkg_justification_deserialize",
12871323
"dash_dkg_justification_roundtrip",
12881324
"dash_sig_shares_inv_deserialize",
12891325
"dash_sig_shares_inv_roundtrip",
12901326
"dash_batched_sig_shares_deserialize",
12911327
"dash_batched_sig_shares_roundtrip",
1328+
"dash_recovered_sig_deserialize",
1329+
"dash_recovered_sig_roundtrip",
1330+
"dash_sig_ses_ann_deserialize",
1331+
"dash_sig_ses_ann_roundtrip",
1332+
"dash_sig_share_deserialize",
1333+
"dash_sig_share_roundtrip",
1334+
"dash_mnauth_deserialize",
1335+
"dash_mnauth_roundtrip",
12921336
"dash_mnhf_tx_deserialize",
12931337
]
12941338
missing = [target for target in required_targets if not any((tmp_path / target).iterdir())]
12951339
assert not missing, f"missing synthetic seeds for: {', '.join(missing)}"
12961340

1341+
# Assert the LLMQ seed sizes match the C++ serialization layouts so the
1342+
# synthetic seeds aren't silently truncated again (regression guard).
1343+
def _seed_bytes(target):
1344+
files = list((tmp_path / target).iterdir())
1345+
assert len(files) == 1, f"{target}: expected one synthetic seed, got {len(files)}"
1346+
return files[0].read_bytes()
1347+
1348+
# The Dash deserialize/roundtrip wrappers prepend a 4-byte stream version.
1349+
prefix = len(_stream_version_prefix())
1350+
# CRecoveredSig: 1 + 32 + 32 + 32 + 96 = 193
1351+
assert len(_seed_bytes("dash_recovered_sig_deserialize")) == prefix + 193
1352+
# CSigSesAnn (VARINT(0)=1 byte): 1 + 1 + 32 + 32 + 32 = 98
1353+
assert len(_seed_bytes("dash_sig_ses_ann_deserialize")) == prefix + 98
1354+
# CSigShare: 1 + 32 + 2 + 32 + 32 + 96 = 195
1355+
assert len(_seed_bytes("dash_sig_share_deserialize")) == prefix + 195
1356+
# CDKGComplaint with empty bitsets: 1 + 32 + 32 + 1 + 1 + 96 = 163
1357+
assert len(_seed_bytes("dash_dkg_complaint_deserialize")) == prefix + 163
1358+
1359+
# --stream-version override path: setting the override must not require
1360+
# src/version.h, and _stream_version_prefix must round-trip the value.
1361+
global _STREAM_VERSION_OVERRIDE, _STREAM_VERSION_CACHE
1362+
saved_override, saved_cache = _STREAM_VERSION_OVERRIDE, _STREAM_VERSION_CACHE
1363+
try:
1364+
_STREAM_VERSION_OVERRIDE = 0x12345678
1365+
_STREAM_VERSION_CACHE = None
1366+
assert _stream_version_prefix() == b"\x78\x56\x34\x12"
1367+
finally:
1368+
_STREAM_VERSION_OVERRIDE = saved_override
1369+
_STREAM_VERSION_CACHE = saved_cache
1370+
12971371

12981372
def main():
12991373
parser = argparse.ArgumentParser(
@@ -1317,8 +1391,22 @@ def main():
13171391
action="store_true",
13181392
help="Only generate synthetic seeds (no RPC required)"
13191393
)
1394+
parser.add_argument(
1395+
"--stream-version",
1396+
type=int,
1397+
default=None,
1398+
help=(
1399+
"Stream version (4-byte LE prefix) to use for harnesses that consume one. "
1400+
"Overrides DASH_FUZZ_STREAM_VERSION and the src/version.h fallback. "
1401+
"Useful when running outside an in-tree source checkout."
1402+
),
1403+
)
13201404
args = parser.parse_args()
13211405

1406+
if args.stream_version is not None:
1407+
global _STREAM_VERSION_OVERRIDE
1408+
_STREAM_VERSION_OVERRIDE = args.stream_version
1409+
13221410
output_dir = Path(args.output_dir)
13231411
output_dir.mkdir(parents=True, exist_ok=True)
13241412

src/test/fuzz/process_message.cpp

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,10 @@ FUZZ_TARGET(process_message, .init = initialize_process_message)
6363
if (!LIMIT_TO_MESSAGE_TYPE.empty() && random_message_type != LIMIT_TO_MESSAGE_TYPE) {
6464
return;
6565
}
66-
// Skip Dash message types that require subsystem initialization not present in the fuzz harness.
67-
// TODO: initialize the masternode/LLMQ contexts here (see process_message_dash.cpp) and remove
68-
// this list so these handlers get real coverage.
66+
// Skip Dash message types that require subsystem initialization not present in
67+
// this upstream-style harness. MNAUTH coverage is provided by the Dash-aware
68+
// `process_message_dash` harness (src/test/fuzz/process_message_dash.cpp), which
69+
// sets up the masternode/LLMQ contexts the handler depends on.
6970
static constexpr std::array<std::string_view, 1> skip_message_types{"mnauth"};
7071
if (std::find(skip_message_types.begin(), skip_message_types.end(), random_message_type) != skip_message_types.end()) {
7172
return;

test/util/data/non-backported.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ src/test/fuzz/process_message_dash.cpp
7070
src/test/fuzz/roundtrip_dash.cpp
7171
src/test/fuzz/simplified_mn_list_diff.cpp
7272
src/test/fuzz/special_tx_validation.cpp
73+
src/test/fuzz/util_dash.h
7374
src/test/llmq*.cpp
7475
src/test/util/llmq_tests.h
7576
src/test/governance*.cpp

0 commit comments

Comments
 (0)