Validate par and table inputs; add fuzz harnesses and SECURITY.md - #349
Validate par and table inputs; add fuzz harnesses and SECURITY.md#349zzcgumn wants to merge 11 commits into
Conversation
The par entry points derived contract levels directly from
DdTableResults::res_table and formatted them into fixed-size character
buffers without ever checking that the trick counts were legal. A table
containing large out-of-range values overflowed those buffers:
- Par() smashed the stack via strcat into `char temp[8]` (par.cpp:121),
reproducible under ASan as a stack-buffer-overflow in Par+0xccc.
- SidesPar() silently wrote a 26-character string into the 10-byte
field ParResultsDealer::contracts[0] -- an intra-object overflow ASan
does not instrument by default.
Both returned RETURN_NO_FAULT. Legal tables (0-13) were unaffected, and
CalcDDtable() only ever emits legal tables, so the normal
CalcDDtable -> Par flow could not reach this. Par, SidesPar, SidesParBin,
DealerPar and DealerParBin are all exported, however, so a caller that
hand-builds or deserialises a table could.
Add par_table_checks() and call it from the two chokepoints the par
entry points funnel through: both SidesParBin variants and DealerPar.
Introduce RETURN_PAR_TABLE_FAULT (-401) following the existing
RETURN_*/TEXT_* convention, wire it into ErrorMessage() and mirror it in
DdsStatus.java. This makes the par API consistent with SolveBoard, which
already validates its input thoroughly.
Also fix an out-of-bounds read found alongside it: DealerPar indexed
VUL_LOOKUP[4][2] with an unvalidated `vulnerable`. SidesParBin only
compares against that parameter, so DealerPar was the sole indexing site.
Regression tests in library/tests/par_validation_test.cpp cover the
original trigger through all five entry points, the 13/14 and negative
boundaries, every res_table position, a null table, the vulnerability
range, and that legal tables still produce par results. Verified by
reverting the source fix with the tests in place: the suite aborts with
the original ASan overflow, and passes with the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182npkiFY2DCbdranYEhs38
Fuzzing was the one gap in the project's memory-safety tooling: ASan, TSan
and UBSan already run in CI, but nothing generated inputs to drive them.
Add libFuzzer harnesses for the four surfaces that consume caller- or
file-supplied data: convert_from_pbn(), CalcDDtablePBN(), SolveBoard() and
the par entry points.
Each harness is exposed two ways. A cc_test replays the checked-in seed
corpus through it, which needs no libFuzzer and so runs on every platform
and under --config=asan/ubsan -- that is what keeps a fixed bug fixed, since
a reproducer added to corpus/ becomes a permanent regression seed. A
libFuzzer cc_binary, tagged manual and built only under the new
--config=fuzz, is for actual campaigns.
--config=fuzz uses the registered hermetic LLVM toolchain, which ships
libclang_rt.fuzzer. Apple's does not, so the config deliberately does not
chain --config=asan: on macOS that switches to the Xcode toolchain and the
link fails. On Linux the two combine.
The harnesses found three defects on their first runs. None is fixed here;
reproducers and analysis are in library/tests/fuzz/findings/README.md, kept
out of corpus/ so the replay tests stay green.
01 CalcDDtable()/CalcDDtablePBN() do not check that the four hands hold
equal numbers of cards, so a 51-card deal reaches the search and reads
14248 bytes past rel_rank_storage. SolveBoard() rejects the same deal
via board_value_checks(). Reachable from an ordinary truncated PBN
file containing only legal characters; convert_from_pbn() silently
skipping unrecognised characters is a second way in.
02 DealerPar() does not validate `dealer`. A negative value reaches
sacrifice_as_text(), where static_cast<unsigned>(pno) turns -1 into
4294967295 and indexes a std::string array far out of bounds. Same
class as the `vulnerable` bug fixed by hand in 2abb260, which guarded
one parameter of the pair and missed the other; the fuzzer found it
within 50000 runs.
03 DumpInput() indexes card_suit[], card_hand[] and card_rank[] with the
very values board_range_checks() is rejecting as out of range, so the
error path itself reads out of bounds. Compiled in unless
DDS_NO_DUMP_ON_ERROR is defined, which the build does not define.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182npkiFY2DCbdranYEhs38
DDS is an in-process library with no network surface, and it assumes trusted, well-formed input. That assumption has always been implicit. For a library with Python, Java, .NET and WebAssembly bindings, whose deployments the maintainers do not control, stating it explicitly is a real control rather than a substitute for one: it lets consumers judge their own exposure and design against a contract instead of a guess. Document what is actually validated and what is not -- SolveBoard() validates thoroughly, the par entry points partially, CalcDDtable() not at all for card counts, and convert_from_pbn() skips unrecognised characters -- along with guidance for anyone exposing the library to untrusted input, and the sanitizer and fuzzing tooling available. Note the WebAssembly build as the one deployment where the sandbox contains memory errors by construction, and point at library/tests/fuzz/findings/README.md for the open defects. Those are documented openly because consumers need them to assess their own risk and because all are out-of-bounds reads reachable only through the input paths described, not remote code execution in any supported deployment. Reporting goes through GitHub's private vulnerability advisories, so no new contact address is introduced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0182npkiFY2DCbdranYEhs38
All four are out-of-bounds reads reachable from the documented input paths.
Three were reported when the harnesses landed; the fourth was found while
fuzzing the fixes for the first three. Each reproducer moves from
library/tests/fuzz/findings/ into the matching corpus/ directory, so it is now
a permanent regression seed, and each gains a unit test.
01 CalcDDtable(), CalcDDtablePBN() and CalcAllTables*() did not check that
the four hands held equal numbers of cards, so a 51-card deal reached the
search and read 14248 bytes past rel_rank_storage. Reachable from an
ordinary PBN file short of a card -- the reproducer contains only legal
PBN characters. Add table_deal_checks(), enforcing the same three rules
SolveBoard() already applied via board_value_checks(), so nothing is
rejected that the solver would have accepted.
02 DealerPar() did not validate `dealer`. A negative value reached
sacrifice_as_text(), where NUMBER_TO_PLAYER[static_cast<unsigned>(pno)]
turned -1 into 4294967295. Range-check `dealer` alongside `vulnerable`,
and replace the unsigned casts with guarded contract_text()/player_text()
helpers: the cast is what turned a detectable bug into a wild read.
03 DumpInput() indexed card_suit[5], card_hand[4] and card_rank[16] with the
very values board_range_checks() was rejecting as out of range, so the
error path read out of bounds. Present in release builds, since
DumpInput() is compiled in unless DDS_NO_DUMP_ON_ERROR is defined. Add
suit_text()/hand_text()/rank_text(), which fall back to printing the raw
integer -- more useful in a diagnostic than a wrong character.
04 board_range_checks() validates currentTrickSuit[k] only when the matching
rank is non-zero, but hand_rel_first derives from the card count, not from
the trick entries. A five-card deal with all trick ranks zero gives
hand_rel_first == 3, so board_value_checks() indexed remainCards with an
unchecked suit. Validate it inside that loop, where it is used as a
subscript, so only inputs that would genuinely have been read out of
bounds are rejected.
Verified by reverting the source fixes with the tests in place: the suite
fails with the original ASan reports and passes with the fixes. Campaigns
after the fixes total roughly 1.35 million executions across the four
harnesses with no crashes.
SECURITY.md is updated -- its account of what each entry point validates was
written before these fixes and no longer described the code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182npkiFY2DCbdranYEhs38
Two CI failures, both in the new harnesses rather than in the library. Windows: all four corpus tests reported "corpus resolved to 0 files". The replay driver resolved the corpus directory relative to the working directory, which only works where Bazel builds a runfiles symlink tree. Windows disables those by default and supplies RUNFILES_MANIFEST_FILE instead, so nothing was found. Resolve through the runfiles the same way library/tests/test_dtest_nothing_makes.py already does: prefer RUNFILES_DIR or TEST_SRCDIR, fall back to the manifest, and keep the plain filesystem path last so running a harness by hand from the repository root still works. The manifest branch is verified by pointing RUNFILES_MANIFEST_FILE at a generated manifest with the runfiles tree variables unset and the working directory outside the repository -- the Windows configuration. The "resolved to 0 files" guard is what turned this into a clear CI failure rather than a test that silently checked nothing, so it stays. UBSan on Linux: calc_dd_table_pbn_fuzz.cpp called memcpy with a null source and a zero length, which libFuzzer produces and glibc declares nonnull. Guard the empty case. Two other harnesses had the same latent issue -- constructing a std::string from (nullptr, 0) in pbn_fuzz.cpp, and the reader in par_fuzz.cpp -- so guard those too. macOS does not mark these arguments nonnull, which is why the local UBSan run passed. Also merges upstream/develop, which has gained CalcDDtable support for deals with fewer than 13 cards. table_deal_checks() requires the four hands to hold *equal* numbers of cards rather than exactly 13, so the new calc_dd_table_partial_test (one card per hand) passes unchanged. Verified on the merged tree: 56/56 //library/... plain, ASan and UBSan; 19 python, 5 jni, 1 utilities; 550000 further fuzz executions with no crashes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0182npkiFY2DCbdranYEhs38
There was a problem hiding this comment.
Pull request overview
This pull request hardens DDS input validation and error reporting, adds fuzzing and regression coverage, and documents the security model.
Changes:
- Validates par, dealer, vulnerability, and DD-table inputs.
- Adds fuzz harnesses, corpus replay tests, seeds, and Bazel configuration.
- Adds
SECURITY.mdand mirrors the new status code in Java.
Reviewed changes
Copilot reviewed 49 out of 69 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Review summary |
|---|---|
SECURITY.md |
Nit (3 votes): Correct the claim that DDS has no persistent state; document process-local solver resources. |
library/tests/par_validation_test.cpp |
Adds par-validation regression coverage. |
library/tests/fuzz/solve_board_fuzz.cpp |
Adds solver fuzzing. |
library/tests/fuzz/README.md |
Documents fuzz workflows. |
library/tests/fuzz/pbn_fuzz.cpp |
Adds PBN fuzzing. |
library/tests/fuzz/par_fuzz.cpp |
Adds par API fuzzing. |
library/tests/fuzz/fuzz.bzl |
Defines reusable fuzz rules. |
library/tests/fuzz/fuzz_corpus_main.cpp |
Replays fuzz corpora. |
library/tests/fuzz/findings/README.md |
Documents fuzz findings. |
library/tests/fuzz/corpus/pbn/west_first.txt |
PBN regression seed. |
library/tests/fuzz/corpus/pbn/void_suits.txt |
PBN regression seed. |
library/tests/fuzz/corpus/pbn/truncated.txt |
Truncated-input seed. |
library/tests/fuzz/corpus/pbn/south_first.txt |
PBN regression seed. |
library/tests/fuzz/corpus/pbn/north_first.txt |
PBN regression seed. |
library/tests/fuzz/corpus/pbn/no_colon.txt |
PBN regression seed. |
library/tests/fuzz/corpus/pbn/lowercase.txt |
PBN regression seed. |
library/tests/fuzz/corpus/pbn/extra_dots.txt |
PBN regression seed. |
library/tests/fuzz/corpus/pbn/empty.txt |
Empty-input seed. |
library/tests/fuzz/corpus/pbn/east_first.txt |
PBN regression seed. |
library/tests/fuzz/corpus/pbn/bad_rank_parser_only.txt |
Parser regression seed. |
library/tests/fuzz/corpus/pbn/bad_compass.txt |
PBN regression seed. |
library/tests/fuzz/corpus/par/negative.bin |
Invalid-table seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/west_first.txt |
PBN table-calculation seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/void_suits.txt |
PBN table-calculation seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/unbalanced_51_cards.txt |
Unbalanced-deal regression seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/truncated.txt |
Truncated-deal seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/south_first.txt |
PBN table-calculation seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/north_first.txt |
PBN table-calculation seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/no_colon.txt |
PBN table-calculation seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/lowercase.txt |
PBN table-calculation seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/extra_dots.txt |
PBN table-calculation seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/empty.txt |
Empty-input seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/east_first.txt |
PBN table-calculation seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/bad_rank.txt |
PBN table-calculation seed. |
library/tests/fuzz/corpus/calc_dd_table_pbn/bad_compass.txt |
PBN table-calculation seed. |
library/tests/fuzz/calc_dd_table_pbn_fuzz.cpp |
Adds PBN table-calculation fuzzing. |
library/tests/fuzz/BUILD.bazel |
Defines fuzz and replay targets. |
library/tests/deal_input_validation_test.cpp |
Adds deal and diagnostic validation tests. |
library/tests/BUILD.bazel |
Registers regression tests. |
library/src/table_deal_validate.hpp |
Critical (3 votes): Apply validation to public C++ and shim table-calculation paths. Critical (2 votes): Reject excessive or negative batch counts before fixed-array conversion. |
library/src/solver_if.cpp |
Hardens trick-suit validation. |
library/src/par.cpp |
Moderate (3 votes): Validate vulnerable in direct Par() and SidesPar() paths. |
library/src/par_validate.hpp |
Adds par-validation helpers. |
library/src/init.cpp |
Maps the new status code. |
library/src/dump.cpp |
Hardens diagnostic formatting. |
library/src/dealer_par.cpp |
Validates dealer and vulnerability parameters. |
library/src/calc_tables.cpp |
Critical (2 votes): Protect table-count boundaries before multiplication or conversion and add boundary coverage. |
library/src/BUILD.bazel |
Exposes validation sources and dependencies. |
library/src/api/dll.h |
Nit (3 votes): Add RETURN_PAR_TABLE_FAULT to doc/dll-description.md. |
jni/java/org/dds/ffm/DdsStatus.java |
Mirrors the new status code. |
.bazelrc |
Adds fuzzing configuration. |
Suppressed comments (6)
SECURITY.md:67
- The library intentionally accepts partial deals when all four hands have equal card counts; the existing partial-deal tests exercise one-card-per-hand inputs. Requiring exactly 13 cards here would cause callers to reject valid partial deals, so qualify 13 cards as the full-deal case and otherwise require equal counts.
1. **Validate at your boundary.** Reject deals that are not 13 cards per hand
and tables whose entries fall outside 0-13, before calling DDS.
SECURITY.md:40
- Only
DealerPar()/DealerParBin()range-checkdealerandvulnerable. In the default build,Par(),SidesPar()andSidesParBin()do not range-checkvulnerable; out-of-range values are treated as non-vulnerable and can return a successful but incorrect result. Either add the shared parameter check to every affected entry point or narrow this policy statement.
- The par entry points validate the double dummy table
(`par_table_checks()`) and their `dealer` and `vulnerable` parameters. Both
checks were added after fuzzing found an out-of-range table overflowing a
fixed character buffer and a negative `dealer` indexing a string table.
jni/java/org/dds/ffm/DdsStatus.java:80
- This new status is mirrored in Java, but the public .NET wrappers
ParSide,ParDealer, andDealerParBothSidescast results toSolveBoardResult; that enum andGetRcErrorMessage()have no-401. A C# caller therefore receives the new table-fault result as an unnamed status and getsUnknown errorfrom the wrapper. Add the status to the .NET binding as well, or explicitly document that it is not exposed there.
/** Double dummy table entry outside the range 0 to 13. */
public static final int RETURN_PAR_TABLE_FAULT = -401;
library/src/calc_tables.cpp:552
- This is a separate board-expansion path and is not covered by the malformed-deal regression, which only exercises legacy
CalcAllTables(). Without anCalcAllTablesX()regression using an unbalanced deal, a future removal or relocation of this loop would let the X API reach the solver with the same invalid input unchecked; add a direct X-path test (and the corresponding PBNX case if that path is intended to be covered).
for (int m = 0; m < numDeals; m++)
{
int const check = table_deal_checks(deals[m]);
if (check != RETURN_NO_FAULT)
return check;
library/src/calc_tables.cpp:552
- This validation does not protect the subsequent batch-size arithmetic:
nboards = numDeals * includedis computed as a signedint. For positive inputs where that product wraps to a small positive value, the vector is undersized but the expansion loop still incrementsindfor the full mathematical product and writes pastboards; other large inputs invoke undefined signed overflow or unbounded allocation. Compute the product in a checkedsize_t(and use a matching index type), or reject unrepresentable requests before allocating.
for (int m = 0; m < numDeals; m++)
{
int const check = table_deal_checks(deals[m]);
if (check != RETURN_NO_FAULT)
return check;
library/tests/fuzz/solve_board_fuzz.cpp:44
- The selector mapping never reaches the upper-bound validation cases for
solutionsormode: their ranges are-1..3and-1..2, respectively, sosolutions > 3andmode > 2cannot be fuzzed through this harness. Map these fields to ranges that include 4 and 3 (or consume additional selector bytes) so both sides of each documented range are exercised.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
All six inline comments on #349. Two identified real gaps in the earlier fixes, one of them a stack-buffer-overflow *write*. - SECURITY.md: "keeps no persistent state between calls" was wrong. DDS carries process-local solver resources that outlive a call -- the transposition table and per-thread memory behind SetResources()/ FreeMemory(), the SetMaxThreads() budget, and a worker pool held in a function-local static (parallel_boards.cpp:230). Describe those, and note the two consequences for a threat model: calls are not isolated from one another, and the budgets are process-wide. - doc/dll-description.{md,html}: add the -401 RETURN_PAR_TABLE_FAULT row; the table still ended at -301 while dll.h points consumers at it. - calc_tables.cpp: bound no_of_tables before it is used. CalcAllTablesPBNN() converted that many records into a fixed MAXNOOFTABLES * DDS_STRAINS local before any validation ran -- confirmed under ASan as a stack-buffer-overflow WRITE in convert_from_pbn(), the first write among these findings rather than a read. CalcAllTablesN()'s capacity check multiplied by count first, which can overflow signed int and wrap past the check, so bound the count ahead of the multiply; CalcAllTablesX() is heap-backed and uncapped by design, so guard only the product. - par.cpp: SidesParBin() accepted out-of-range `vulnerable`. It only compares against the value, so this was memory-safe, but Par() and SidesPar() returned RETURN_NO_FAULT with a result computed as "none vulnerable" while DealerPar() rejected the same input. Both variants now validate, through a shared par_vulnerable_checks() that DealerPar() also uses. - calc_dd_table.cpp: the C++ calc_dd_table(ctx, ...) overload built Boards directly and never called table_deal_checks(), so a one-card-short deal still reached the search through it and through the dds_c_* shims. It is the chokepoint the context-free and PBN overloads delegate to, so the guard goes there; tests cover the C++ and shim paths. - table_deal_validate.hpp: the doc comment claimed CalcAllTables* coverage without noting that it validates one deal and not the batch count. Say which entry points apply it, and that bounding no_of_tables is separate. Tests: 10 new cases across deal_input_validation_test.cpp and par_validation_test.cpp, each verified by reverting its guard and confirming the failure. 56/56 //library/... plain, ASan and UBSan; 19 python, 5 jni, 1 utilities. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0182npkiFY2DCbdranYEhs38
The four existing harnesses each drive a single deal, so none of them
exercised how CalcAllTables*() handles a caller-supplied deal count. That is
exactly where CalcAllTablesPBNN() copied no_of_tables records into a
fixed-size local before validating it -- a stack-buffer-overflow write that
code review caught and fuzzing did not. This closes that gap.
The harness drives CalcAllTablesN(), CalcAllTablesPBNN() and
CalcAllTablesX(). It distinguishes the two kinds of count involved:
no_of_tables is a field inside a fixed-capacity struct, so any value is
legitimate fuzzer input and the library must bound it, and the harness passes
it through verbatim; CalcAllTablesX()'s count describes a caller-allocated
array, so the harness allocates exactly what it declares and caps it.
Two details are load-bearing, and both cost a round to get right:
- Slots the input does not perturb are pre-filled with a valid deal.
CalcAllTablesPBNN() stops at the first slot convert_from_pbn() rejects,
so with zeroed slots the loop returns RETURN_PBN_FAULT immediately and
never reaches the boundary. The first version of this harness left them
zeroed and did not catch the bug it exists for.
- The fill deal holds one card per hand rather than a full 52. This
harness targets count and batch handling, not search depth, and a full
deal in every slot drops throughput from ~75000 executions in four
minutes to ~1700.
Verified by removing each count guard in turn: without the CalcAllTablesPBNN
bound the harness reports the original stack-buffer-overflow WRITE in
convert_from_pbn(), and without the CalcAllTablesN bound it reports a
heap-buffer-overflow READ inside table_deal_checks() itself -- the read past
dealsp->deals that review predicted. Both restore to green.
17 seeds covering hostile, negative and capacity-boundary counts, legal
batches, filter edge cases and a real PBN batch. A 240s campaign completed
75487 executions with no crashes. 57/57 //library/... plain, ASan and UBSan;
25 python/jni/utilities.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182npkiFY2DCbdranYEhs38
The new calc_all_tables harness found this on its first CI run, under MemorySanitizer. Boards bo is an uninitialised stack local. The board count came from a lastIndex variable initialised to 0 and assigned only inside the board-building loop, so bo.no_of_boards = lastIndex + 1 claimed one board even when no_of_tables was 0 and the loop had written none. calc_all_boards_n() then solved bo.deals[0], bo.target[0], bo.solutions[0] and bo.mode[0], none of which had ever been written. Return RETURN_NO_FAULT early for zero deals, matching what CalcAllTablesX() already did, and take the count from ind -- the number of boards actually written -- rather than from a last-index variable that starts at a valid-looking 0. ASan and UBSan do not detect uninitialised reads and MSan is Linux x86_64 only, so this cannot be reproduced on macOS; the local sweep is 57/57 under plain, ASan and UBSan, and CI's msan job is the check that matters here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0182npkiFY2DCbdranYEhs38
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 53 out of 90 changed files in this pull request and generated 7 comments.
Suppressed comments (9)
SECURITY.md:79
- This guidance says to reject every deal that is not 13 cards per hand, but the newly documented
table_deal_checks()and the table APIs intentionally accept equal-card-count partial deals; the regression suite even uses one card per hand. Please distinguish an application's complete-deal policy from DDS's actual accepted input, or callers may incorrectly reject valid partial-deal use.
1. **Validate at your boundary.** Reject deals that are not 13 cards per hand
and tables whose entries fall outside 0-13, before calling DDS.
SECURITY.md:91
- This guidance overstates the side effect:
DumpInput()is called bySolveBoard()'s validation paths, but the newly added par/table validation paths return errors without calling it (andCalcDDtable*does not invoke it either). Say it writesdump.txtwhen aSolveBoard()rejection reachesDumpInput(), so callers do not expect an unexpected file for every validation error.
5. Note that `DumpInput()` writes a `dump.txt` file into the process working
directory whenever input is rejected. Define `DDS_NO_DUMP_ON_ERROR` to
compile it out.
library/src/api/dll.h:156
RETURN_PAR_TABLE_FAULTis now returned for invalidDdTableResults, but the Python error mapper inpython/src/bindings.cpp:33-50does not classify it as an input error. Consequentlypar(),calc_par_from_table(), anddealer_par()raiseRuntimeErrorfor bad table values even though their docstrings promiseValueError. Add this code to thepy::value_errorcases and cover it in the Python tests.
#define RETURN_PAR_TABLE_FAULT -401
library/src/api/dll.h:157
par_table_checks()deliberately returnsRETURN_PAR_TABLE_FAULTfor a null table as well as for an out-of-range entry. This text is therefore misleading for the documented null-input path; broaden the message (and its mirrored documentation) to mention a null/invalid table, or use a status whose text describes the null argument.
#define TEXT_PAR_TABLE_FAULT "Double dummy table entry outside the range 0 to 13"
library/src/calc_tables.cpp:582
CalcAllTablesX()walks and validates everydeals[m]before applying thenumDeals * includedoverflow guard below. A count above the guard therefore still causes an O(numDeals) scan (and caller-array dereferences) before the intendedRETURN_TOO_MANY_TABLES, defeating the early resource check for large or untrusted counts. Move this product check to immediately afterincludedis known and before this loop.
for (int m = 0; m < numDeals; m++)
{
int const check = table_deal_checks(deals[m]);
if (check != RETURN_NO_FAULT)
return check;
}
library/src/dump.cpp:285
suit_text()andrank_text()are used to report current-trick values that have stricter semantic domains than the backing arrays:DDS_STRAINSadmits no-trump (4), andcard_rank[0/1/15]are sentinel characters. A rejected current-trick suit 4 is therefore logged asN, and invalid ranks can be logged asx/-, rather than the offending values. Use context-specific bounds or raw-value fallbacks for current-trick diagnostics.
auto suit_text(const int suit) -> std::string
{
if (suit < 0 || suit >= DDS_STRAINS)
return "?(" + std::to_string(suit) + ")";
return std::string(1, static_cast<char>(card_suit[suit]));
library/src/dump.cpp:300
card_rank[0],[1], and[15]are sentinel entries, whileboard_range_checks()permits only a nonzero trick rank in 2..14. Therefore invalid ranks such as 1 or 15 are reported asxor-rather than the raw invalid value, despite this helper's diagnostic contract. Check the semantic 2..14 range here (or pass it explicitly).
auto rank_text(const int rank) -> std::string
{
constexpr int card_rank_size = 16;
if (rank < 0 || rank >= card_rank_size)
return "?(" + std::to_string(rank) + ")";
return std::string(1, static_cast<char>(card_rank[rank]));
library/src/par_validate.hpp:38
- This new status is an input-validation error, but
python/src/bindings.cpp::throw_on_dds_error()does not classifyRETURN_PAR_TABLE_FAULTasValueError. As a result,calc_par_from_table()now raisesRuntimeErrorfor an out-of-rangeres_table, despite its documentedValueErrorcontract for invalid table input. Add the new status to the binding's validation cases and cover it with a Python regression test.
for (int d = 0; d < DDS_STRAINS; d++)
for (int h = 0; h < DDS_HANDS; h++)
if (tablep->res_table[d][h] < 0 || tablep->res_table[d][h] > 13)
return RETURN_PAR_TABLE_FAULT;
library/tests/fuzz/solve_board_fuzz.cpp:25
- The argument to
SetMaxThreads()is ignored in the current implementation, so this comment incorrectly says the call selects one worker. The call is still useful here to initialize static solver memory, but the comment should describe that behavior rather than a thread cap.
Seven inline comments from the second review round. One is a real behaviour bug in the fuzz harnesses; the rest are accuracy fixes to documentation and a diagnostic. - dump.cpp: suit_text() was shared between dl.trump, where 4 is a legal no-trump, and currentTrickSuit, where board_range_checks() accepts only 0..3. An invalid trick suit of 4 therefore printed as "N" and hid the value that had been rejected -- the opposite of the helper's purpose. Split into trump_text() and trick_suit_text() over a shared bound-taking helper, with a regression test. - Fuzz harnesses: SetMaxThreads(1) capped nothing. It is a deprecated alias of InitializeStaticMemory() whose argument is ignored, and CalcDDtablePBN() delegates with maxThreads = 0, which selects hardware concurrency -- so every input fanned out across all cores, contrary to the harnesses' own comments. Call InitializeStaticMemory() directly and use CalcDDtablePBNN(..., 1). This makes calc_dd_table_pbn compute-bound: a full deal is 20 solves, and under coverage instrumentation on one worker the hardest corpus seed takes ~36s, which -max_total_time cannot interrupt because it is only checked between runs. That is the right trade for a fuzzer, which parallelises across processes rather than within one input, so the cap stays and the README documents the cost and recommends an explicit -timeout. - fuzz_corpus_main.cpp: include <iterator> for std::istreambuf_iterator rather than relying on it arriving transitively, which MSVC may not do. - SECURITY.md: SetMaxThreads() was described as setting a thread budget. It sets nothing. Separate the process-wide legacy memory settings from the shared worker pool and the per-call maxThreads of the *N and *X entry points, and say plainly that SetMaxThreads() is deprecated and ignored. - SECURITY.md: the batch entry points do not all bound their count. CalcAllTablesX() and CalcAllTablesPBNX() accept an arbitrary numDeals by design and guard only integer overflow, so their count is caller-bounded. Say so, and add it to the untrusted-input guidance rather than leaving readers to assume a fixed-count limit. - SECURITY.md and library/tests/fuzz/README.md: both said four harnesses; there are five since calc_all_tables landed. 57/57 //library/... plain, ASan and UBSan; 25 python/jni/utilities. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0182npkiFY2DCbdranYEhs38
|
I'll have a look once all comments are resolved and we have a clean Copilot review. I still have Copilot credits - let me know if you'd like a run. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 53 out of 90 changed files in this pull request and generated no new comments.
Suppressed comments (5)
Previously missed (1) — in code that hasn't changed since the last review.
library/src/api/dll.h:156
RETURN_PAR_TABLE_FAULTis now the native error returned for invalidpar/calc_par_from_tabletables, butthrow_on_dds_error()does not include it in its input-validation cases. As a result, the Python bindings raiseRuntimeErrorfor exactly the invalid-table inputs whose docstrings promiseValueError(andcalc_parcan likewise surface this through its par stage). Add this status to the Pythonpy::value_errormapping and cover the binding behavior with a regression test.
#define RETURN_PAR_TABLE_FAULT -401
SECURITY.md:29
- This new guidance correctly says
SetMaxThreads()is ignored, but the repository's public legacy documentation still contradicts it:doc/dll-description.mdanddocs/dotnet_interface.mddescribeSetMaxThreadsas limiting the worker count. Since this section is specifically intended to prevent unsafe resource assumptions, please update those references (and any generated variants) in the same change so users do not receive conflicting limits guidance.
- `SetMaxThreads()` sets nothing. It is a deprecated alias of
`InitializeStaticMemory()` and its argument is ignored; internal batch
threading was removed. Do not treat it as a resource limit. In the modern
C++ API the embedding application controls concurrency, typically with one
`SolverContext` per worker thread.
library/src/api/dll.h:157
par_table_checks()deliberately returns this code for a null table as well as for an out-of-range entry, butErrorMessage()then reports only the entry-range case. A caller passingnullptrtherefore receives a misleading diagnostic; make the new text cover both invalid-table cases (and keep the corresponding API error tables in sync).
#define TEXT_PAR_TABLE_FAULT "Double dummy table entry outside the range 0 to 13"
library/src/calc_tables.cpp:581
- Please move the deal×strain overflow preflight ahead of this loop. For any
numDeals > INT_MAX / included,CalcAllTablesX()is guaranteed to returnRETURN_TOO_MANY_TABLES, but it currently performs an O(numDeals) validation scan first, making a count that should be rejected a potentially very long operation and defeating the cheap resource guard. Place this check immediately after theincluded/parchecks (and share the preflight with the PBN variant).
for (int m = 0; m < numDeals; m++)
{
int const check = table_deal_checks(deals[m]);
if (check != RETURN_NO_FAULT)
return check;
library/src/dump.cpp:318
rank_text()is only used forcurrentTrickRank, whose valid range is 2..14 inboard_range_checks(). Accepting 0..15 here renders rejected ranks 1 and 15 as the sentinel charactersxand-instead of the raw?(...)fallback promised above, hiding the invalid value indump.txt; use the actual trick-rank range for this helper.
auto rank_text(const int rank) -> std::string
{
constexpr int card_rank_size = 16;
if (rank < 0 || rank >= card_rank_size)
return "?(" + std::to_string(rank) + ")";
return std::string(1, static_cast<char>(card_rank[rank]));
All five comments Copilot suppressed as low-confidence. Each was checked
against the code and each was correct.
- python/src/bindings.cpp: RETURN_PAR_TABLE_FAULT fell through
throw_on_dds_error()'s input-validation cases to the default branch, so the
par functions raised RuntimeError for exactly the invalid-table inputs whose
docstrings promise ValueError. RETURN_CARD_COUNT, RETURN_DUPLICATE_CARDS and
RETURN_SUIT_OR_RANK had the same problem and this branch is what made them
reachable from calc_dd_table, whose docstring promises ValueError for an
invalid card distribution. Map all four.
This also reclassifies them for solve_board, where they were already
reachable: a malformed deal now raises ValueError rather than RuntimeError.
That is a deliberate behaviour change, agreed with the maintainer, and
test_solve_board.py's catch is widened to match. The solve_board docstring
now names card count and duplicate cards alongside suit/rank.
- library/src/api/dll.h: par_table_checks() returns RETURN_PAR_TABLE_FAULT for
a null table as well as an out-of-range entry, but TEXT_PAR_TABLE_FAULT
described only the entry case, so a null table produced a misleading
diagnostic. Reworded, and the error tables in doc/dll-description.{md,html}
kept in sync.
- library/src/calc_tables.cpp: the deal-by-deal validation scan ran before the
board-count overflow check, so a numDeals that was already guaranteed to be
rejected still cost O(numDeals) work -- and CalcAllTablesPBNX() allocated
and converted that many records first, a memory-exhaustion path. Factor the
check into batch_count_preflight(), run it ahead of any per-deal work, and
share it with the PBN variant. The regression test drops from 10.8s to 0.9s.
- library/src/dump.cpp: rank_text() was bounded by the size of card_rank[]
rather than by the legal trick-rank range, so rejected ranks of 1 and 15
rendered as the sentinel characters 'x' and '-' instead of the raw value --
hiding in dump.txt exactly the input that had been refused. Bound by 2..14,
with a test that reads dump.txt back.
- doc/dll-description.{md,html} and docs/dotnet_interface.md still described
SetMaxThreads as limiting the worker count, and even as returning the actual
number of threads. It returns void and ignores its argument. Correcting this
matters because SECURITY.md now tells readers not to rely on it, and
conflicting guidance in the same repository defeats that.
57/57 //library/... plain, ASan and UBSan; 25 python/jni/utilities. Each fix
verified by reverting it and confirming the new tests fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182npkiFY2DCbdranYEhs38
|
@tameware , could you give this another Copilot review please? |
|
In progress.
…On Tue, Aug 25, 2026 at 2:54 PM Martin Nygren ***@***.***> wrote:
*zzcgumn* left a comment (dds-bridge/dds#349)
<#349 (comment)>
@tameware <https://github.com/tameware> , could you give this another
Copilot review please?
—
Reply to this email directly, view it on GitHub
<#349?email_source=notifications&email_token=ABC4PYAMIYUTRQAKDISBG4D5LWD73A5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKNBRGA3DMNBZGI22M4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLDGN5XXIZLSL5RWY2LDNM#issuecomment-5410664925>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ABC4PYAVLZIA7NOO5CPDZB35LWD73AVCNFSNUABEKJSXA33TNF2G64TZHMZDMOJYHAZDANR3JFZXG5LFHM2TEMZUGY3TCOBUGCQXMAQ>
.
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
Summary
Closes five memory-safety defects in the input-validation and error-reporting
paths, and adds the fuzzing that found four of them.
The starting point was an audit question about whether DDS's input handling
warranted more attention. It did:
SolveBoard()validates its inputthoroughly, but the par and
CalcDDtable*entry points did not, and two of thecrashes turned out to be inside the validation and error-reporting logic
itself. Every fix is a range check; none touches the search algorithms.
Fixes
SidesParBin()never checkedres_table, so an out-of-rangetable overflowed
char temp[8]inPar()(ASan: stack-buffer-overflow) andsilently wrote 26 characters into a
char[10]viaSidesPar()— both whilereturning
RETURN_NO_FAULT. Addspar_table_checks()andRETURN_PAR_TABLE_FAULT(-401), mirrored inDdsStatus.javaandErrorMessage().DealerPar()parameters. Neithervulnerable(indexesVUL_LOOKUP[4][2])nor
dealerwas validated; a negativedealerreachedNUMBER_TO_PLAYER[static_cast<unsigned>(pno)], where the cast turns -1 into4294967295. Both are range-checked, and the unsigned casts are replaced with
guarded helpers.
CalcDDtable*card counts. No check that the four hands held equalnumbers of cards, so a 51-card deal reached the search and read 14248 bytes
past
rel_rank_storage. Reachable from an ordinary PBN file short of a card —the reproducer contains only legal PBN characters. Adds
table_deal_checks(), enforcing exactly the rulesSolveBoard()alreadyapplied, so nothing is rejected that the solver would have accepted.
DumpInput(). Indexedcard_suit[5],card_hand[4]andcard_rank[16]with the very values
board_range_checks()was rejecting as out of range.Present in release builds, since it is compiled in unless
DDS_NO_DUMP_ON_ERRORis defined.board_value_checks(). ValidatescurrentTrickSuit[k]only when thematching rank is non-zero, but
hand_rel_firstderives from the card countrather than the trick entries, so a five-card deal with zero ranks indexed
remainCardswith an unchecked suit.Fuzzing
Sanitizers already ran in CI, but nothing generated inputs for them. Adds
libFuzzer harnesses for
convert_from_pbn(),CalcDDtablePBN(),SolveBoard()and the par entry points. Each is also an ordinarycc_testthat replays a checked-in corpus, so it needs no libFuzzer and runs under
--config=asan/ubsanin normal CI — that is what keeps these fixed. The new--config=fuzzuses the hermetic LLVM toolchain, since Apple's ships nolibFuzzer.
SECURITY.md
States the input trust model explicitly: DDS is an in-process library that
assumes trusted, well-formed input, with per-entry-point detail on what is and
is not validated, guidance for anyone exposing it to untrusted data, and
reporting via GitHub private advisories.
Notes for reviewers
RETURN_PAR_TABLE_FAULTis additive; struct layouts areuntouched.
CalcDDtable*validation is deliberately no stricter thanSolveBoard()'s. It rejects unbalanced, duplicate-card and out-of-range-bitdeals — inputs that previously produced a crash or meaningless table.
convert_from_pbn()stillsilently skips unrecognised characters, so an invalid rank now yields
RETURN_CARD_COUNTrather thanRETURN_PBN_FAULT. Tightening it risksrejecting PBN files with trailing newlines, so it is documented rather than
changed. See
library/tests/fuzz/findings/README.md.SECURITY.mdpoints reporters at GitHub private advisories because the repohad no security contact; maintainers may prefer a specific address.
Test plan
bazel test //library/...— 55/55bazel test --config=asan //library/...— 55/55bazel test --config=ubsan //library/...— 55/55bazel test //python/...(19) and//jni/...(5)reproduces the original ASan report, and passes with the fix restored
(par 500k, pbn 500k, solve_board 300k, calc_dd_table_pbn 52k/240s) with
no crashes
🤖 Generated with Claude Code
LLVM Fuzz Tooling