Skip to content

Validate par and table inputs; add fuzz harnesses and SECURITY.md - #349

Open
zzcgumn wants to merge 11 commits into
developfrom
fix/res_table_validation
Open

Validate par and table inputs; add fuzz harnesses and SECURITY.md#349
zzcgumn wants to merge 11 commits into
developfrom
fix/res_table_validation

Conversation

@zzcgumn

@zzcgumn zzcgumn commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

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 input
thoroughly, but the par and CalcDDtable* entry points did not, and two of the
crashes turned out to be inside the validation and error-reporting logic
itself. Every fix is a range check; none touches the search algorithms.

Fixes

  • Par table. SidesParBin() never checked res_table, so an out-of-range
    table overflowed char temp[8] in Par() (ASan: stack-buffer-overflow) and
    silently wrote 26 characters into a char[10] via SidesPar() — both while
    returning RETURN_NO_FAULT. Adds par_table_checks() and
    RETURN_PAR_TABLE_FAULT (-401), mirrored in DdsStatus.java and
    ErrorMessage().
  • DealerPar() parameters. Neither vulnerable (indexes VUL_LOOKUP[4][2])
    nor dealer was validated; a negative dealer reached
    NUMBER_TO_PLAYER[static_cast<unsigned>(pno)], where the cast turns -1 into
    4294967295. Both are range-checked, and the unsigned casts are replaced with
    guarded helpers.
  • CalcDDtable* card counts. No 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. Adds
    table_deal_checks(), enforcing exactly the rules SolveBoard() already
    applied, so nothing is rejected that the solver would have accepted.
  • 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.
    Present in release builds, since it is compiled in unless
    DDS_NO_DUMP_ON_ERROR is defined.
  • board_value_checks(). Validates currentTrickSuit[k] only when the
    matching rank is non-zero, but hand_rel_first derives from the card count
    rather than the trick entries, so a five-card deal with zero ranks indexed
    remainCards with 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 ordinary cc_test
that replays a checked-in corpus, so it needs no libFuzzer and runs under
--config=asan/ubsan in normal CI — that is what keeps these fixed. The new
--config=fuzz uses the hermetic LLVM toolchain, since Apple's ships no
libFuzzer.

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

  • No ABI change. RETURN_PAR_TABLE_FAULT is additive; struct layouts are
    untouched.
  • The CalcDDtable* validation is deliberately no stricter than
    SolveBoard()'s. It rejects unbalanced, duplicate-card and out-of-range-bit
    deals — inputs that previously produced a crash or meaningless table.
  • One open hardening item is left deliberately: convert_from_pbn() still
    silently skips unrecognised characters, so an invalid rank now yields
    RETURN_CARD_COUNT rather than RETURN_PBN_FAULT. Tightening it risks
    rejecting PBN files with trailing newlines, so it is documented rather than
    changed. See library/tests/fuzz/findings/README.md.
  • SECURITY.md points reporters at GitHub private advisories because the repo
    had no security contact; maintainers may prefer a specific address.

Test plan

  • bazel test //library/... — 55/55
  • bazel test --config=asan //library/... — 55/55
  • bazel test --config=ubsan //library/... — 55/55
  • bazel test //python/... (19) and //jni/... (5)
  • Each fix verified by reverting it with the tests in place: the suite
    reproduces the original ASan report, and passes with the fix restored
  • Post-fix campaigns: ~1.35M executions across the four harnesses
    (par 500k, pbn 500k, solve_board 300k, calc_dd_table_pbn 52k/240s) with
    no crashes
  • CI on Linux and Windows/MSVC (only macOS was available locally)

🤖 Generated with Claude Code

LLVM Fuzz Tooling

zzcgumn and others added 6 commits August 24, 2026 11:09
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
@zzcgumn
zzcgumn requested a review from tameware August 24, 2026 13:22
@zzcgumn zzcgumn self-assigned this Aug 24, 2026
@zzcgumn
zzcgumn requested a lite review from Copilot August 24, 2026 13:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md and 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-check dealer and vulnerable. In the default build, Par(), SidesPar() and SidesParBin() do not range-check vulnerable; 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, and DealerParBothSides cast results to SolveBoardResult; that enum and GetRcErrorMessage() have no -401. A C# caller therefore receives the new table-fault result as an unnamed status and gets Unknown error from 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 an CalcAllTablesX() 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 * included is computed as a signed int. For positive inputs where that product wraps to a small positive value, the vector is undersized but the expansion loop still increments ind for the full mathematical product and writes past boards; other large inputs invoke undefined signed overflow or unbounded allocation. Compute the product in a checked size_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 solutions or mode: their ranges are -1..3 and -1..2, respectively, so solutions > 3 and mode > 2 cannot 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.

Comment thread SECURITY.md Outdated
Comment thread library/src/api/dll.h Outdated
Comment thread library/src/calc_tables.cpp
Comment thread library/src/par.cpp
Comment thread library/src/table_deal_validate.hpp
Comment thread library/src/table_deal_validate.hpp Outdated
zzcgumn and others added 3 commits August 24, 2026 16:50
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 by SolveBoard()'s validation paths, but the newly added par/table validation paths return errors without calling it (and CalcDDtable* does not invoke it either). Say it writes dump.txt when a SolveBoard() rejection reaches DumpInput(), 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_FAULT is now returned for invalid DdTableResults, but the Python error mapper in python/src/bindings.cpp:33-50 does not classify it as an input error. Consequently par(), calc_par_from_table(), and dealer_par() raise RuntimeError for bad table values even though their docstrings promise ValueError. Add this code to the py::value_error cases and cover it in the Python tests.
#define RETURN_PAR_TABLE_FAULT -401

library/src/api/dll.h:157

  • par_table_checks() deliberately returns RETURN_PAR_TABLE_FAULT for 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 every deals[m] before applying the numDeals * included overflow guard below. A count above the guard therefore still causes an O(numDeals) scan (and caller-array dereferences) before the intended RETURN_TOO_MANY_TABLES, defeating the early resource check for large or untrusted counts. Move this product check to immediately after included is 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() and rank_text() are used to report current-trick values that have stricter semantic domains than the backing arrays: DDS_STRAINS admits no-trump (4), and card_rank[0/1/15] are sentinel characters. A rejected current-trick suit 4 is therefore logged as N, and invalid ranks can be logged as x/-, 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, while board_range_checks() permits only a nonzero trick rank in 2..14. Therefore invalid ranks such as 1 or 15 are reported as x or - 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 classify RETURN_PAR_TABLE_FAULT as ValueError. As a result, calc_par_from_table() now raises RuntimeError for an out-of-range res_table, despite its documented ValueError contract 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.

Comment thread SECURITY.md Outdated
Comment thread SECURITY.md Outdated
Comment thread SECURITY.md Outdated
Comment thread library/src/dump.cpp Outdated
Comment thread library/tests/fuzz/README.md Outdated
Comment thread library/tests/fuzz/calc_dd_table_pbn_fuzz.cpp Outdated
Comment thread library/tests/fuzz/fuzz_corpus_main.cpp
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
@tameware

Copy link
Copy Markdown
Collaborator

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_FAULT is now the native error returned for invalid par/calc_par_from_table tables, but throw_on_dds_error() does not include it in its input-validation cases. As a result, the Python bindings raise RuntimeError for exactly the invalid-table inputs whose docstrings promise ValueError (and calc_par can likewise surface this through its par stage). Add this status to the Python py::value_error mapping 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.md and docs/dotnet_interface.md describe SetMaxThreads as 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, but ErrorMessage() then reports only the entry-range case. A caller passing nullptr therefore 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 return RETURN_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 the included/par checks (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 for currentTrickRank, whose valid range is 2..14 in board_range_checks(). Accepting 0..15 here renders rejected ranks 1 and 15 as the sentinel characters x and - instead of the raw ?(...) fallback promised above, hiding the invalid value in dump.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
@zzcgumn
zzcgumn requested a lite review from Copilot August 25, 2026 12:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@zzcgumn

zzcgumn commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

@tameware , could you give this another Copilot review please?

@tameware

tameware commented Aug 25, 2026 via email

Copy link
Copy Markdown
Collaborator

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants