From a9a5d9c0d404cc3b97c32ba6e21df420b5e708b8 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 18 Aug 2026 22:27:02 +0200 Subject: [PATCH 01/10] Reject extra compass letters on later hands in PBN deals. Only the first hand may have N/E/S/W; extra seat prefixes were previously ignored and could assign cards to the wrong seats. Co-authored-by: Cursor --- doc/dll-description.md | 2 +- docs/python_interface.md | 15 +++--- library/src/api/PBN.h | 1 + library/src/api/dll.h | 2 +- library/src/pbn.cpp | 27 ++++++++-- library/src/pbn.hpp | 1 + library/tests/BUILD.bazel | 15 ++++++ library/tests/README.md | 2 +- library/tests/pbn_test.cpp | 71 +++++++++++++++++++++++++++ python/src/bindings.cpp | 6 ++- python/tests/README.md | 1 + python/tests/test_type_conversions.py | 15 ++++++ 12 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 library/tests/pbn_test.cpp diff --git a/doc/dll-description.md b/doc/dll-description.md index 8e09f1ea1..bce60b33e 100644 --- a/doc/dll-description.md +++ b/doc/dll-description.md @@ -407,7 +407,7 @@ Common encodings are as follows HoldingA value of 16388 = 16384 + 4 is the encoding for the holding “A2” (ace and deuce).
The two lowest bits are always zero.   -PBNExample:
W:T5.K4.652.A98542 K6.QJT976.QT7.Q6 432.A.AKJ93.JT73 AQJ987.8532.84.K +PBNExample:
W:T5.K4.652.A98542 K6.QJT976.QT7.Q6 432.A.AKJ93.JT73 AQJ987.8532.84.K
Only the first hand has a compass letter (N/E/S/W); the other three hands follow clockwise and must not include additional directions. diff --git a/docs/python_interface.md b/docs/python_interface.md index cc2401b2c..f7041486f 100644 --- a/docs/python_interface.md +++ b/docs/python_interface.md @@ -101,7 +101,7 @@ print(f"Tricks available: {result['score']}") Solves a single bridge deal using PBN (Portable Bridge Notation). **Parameters:** -- `remain_cards` (str): PBN string (e.g., "N:AK.234.456.789TJQ W:QJ.AKQJ.789.234 E:T9.T9.TJ.AK S:8765.8765.AKQJ32.6") +- `remain_cards` (str): PBN string (e.g., `"N:AK.234.456.789TJ T9432.T9.TJ2.AKQ 8765.8765.AKQ3.6 QJ.AKQJ.789.2345"`). Only the first hand has a compass letter; the other three follow clockwise with no seat prefixes. - `trump` (int, default=4): Trump suit (0-4) - `first` (int, default=0): Player to lead - `current_trick_suit` (tuple, default=(0,0,0)): Current trick suits @@ -179,7 +179,7 @@ from dds3 import calc_all_tables_pbn deals = [ "N:QJ6.K652.J85.T98 873.J97.AT764.Q4 K5.T83.KQ9.A7652 AT942.AQ4.32.KJ3", - "N:AK.234.456.789TJQ W:QJ.AKQJ.789.234 E:T9.T9.TJ.AK S:8765.8765.AKQJ32.6", + "N:AK.234.456.789TJ T9432.T9.TJ2.AKQ 8765.8765.AKQ3.6 QJ.AKQJ.789.2345", ] result = calc_all_tables_pbn(deals, mode=0) @@ -312,13 +312,16 @@ remain_cards = [ ``` ### PBN Format -Portable Bridge Notation format: `"N:AK.234.456.789TJQ W:QJ.AKQJ.789.234 E:T9.T9.TJ.AK S:8765.8765.AKQJ32.6"` +Portable Bridge Notation deal string. Only the first hand has a compass letter (`N`/`E`/`S`/`W`); the other three hands follow clockwise and must not include additional directions. -Format: `[Seat]:[Spades].[Hearts].[Diamonds].[Clubs]` -- Seats: N (North), E (East), S (South), W (West) +Example: `"N:AK.234.456.789TJ T9432.T9.TJ2.AKQ 8765.8765.AKQ3.6 QJ.AKQJ.789.2345"` + +Format: `[Seat]:[Spades].[Hearts].[Diamonds].[Clubs] [next hand clockwise] ...` +- First-hand seat: N (North), E (East), S (South), or W (West) - Cards: 2-9, T (10), J, Q, K, A (highest) - Dots separate suits - Omitted cards belong to other players +- Extra compass letters on later hands are rejected ## Validation and Error Handling @@ -328,7 +331,7 @@ The Python interface validates all inputs: - Rank values: 0 or 2-14 for trick cards (`0` means unset) - Card bitmasks: 0..0x7FFC - Array dimensions: 4x4 for card arrays, 5x4 for results -- PBN format: Must be valid PBN notation +- PBN format: Must be valid PBN notation (first-hand compass letter only) ### Exception Handling - `ValueError`: Invalid input parameters (bounds, format) diff --git a/library/src/api/PBN.h b/library/src/api/PBN.h index ad07351a5..994c84a4e 100644 --- a/library/src/api/PBN.h +++ b/library/src/api/PBN.h @@ -16,6 +16,7 @@ * @brief Convert a PBN (Portable Bridge Notation) Deal string to DDS card array. * * Parses a PBN-format Deal string and fills the DDS card array with the remaining cards for each hand and suit. + * Only the first hand may have a compass letter (N/E/S/W); the other three hands follow clockwise and must not include additional directions. * * @param dealBuff PBN-format Deal string. * @param remainCards Output array for remaining cards per hand and suit. diff --git a/library/src/api/dll.h b/library/src/api/dll.h index f6073a359..02cef5f8e 100644 --- a/library/src/api/dll.h +++ b/library/src/api/dll.h @@ -196,7 +196,7 @@ struct Deal * @param first The hand to play first * @param currentTrickSuit Suits of cards played in the current trick * @param currentTrickRank Ranks of cards played in the current trick - * @param remainCards PBN string describing remaining cards + * @param remainCards PBN string describing remaining cards. Only the first hand may have a compass letter (N/E/S/W); later hands follow clockwise with no extra directions. */ struct DealPBN { diff --git a/library/src/pbn.cpp b/library/src/pbn.cpp index eb3e40a4a..5381184a3 100644 --- a/library/src/pbn.cpp +++ b/library/src/pbn.cpp @@ -11,6 +11,7 @@ #include auto is_card(const char cardChar) -> int; +auto is_compass_letter(const char c) -> bool; auto convert_from_pbn( @@ -22,10 +23,7 @@ auto convert_from_pbn( remainCards[h][s] = 0; int bp = 0; - while (((dealBuff[bp] != 'W') && (dealBuff[bp] != 'N') && - (dealBuff[bp] != 'E') && (dealBuff[bp] != 'S') && - (dealBuff[bp] != 'w') && (dealBuff[bp] != 'n') && - (dealBuff[bp] != 'e') && (dealBuff[bp] != 's')) && (bp < 3)) + while ((bp < 3) && !is_compass_letter(dealBuff[bp])) bp++; if (bp >= 3) @@ -92,12 +90,33 @@ auto convert_from_pbn( hand_rel_first++; suitInHand = 0; } + else if (is_compass_letter(dealBuff[bp])) + return 0; bp++; } return RETURN_NO_FAULT; } +auto is_compass_letter(const char c) -> bool +{ + switch (c) + { + case 'N': + case 'n': + case 'E': + case 'e': + case 'S': + case 's': + case 'W': + case 'w': + return true; + default: + return false; + } +} + + auto is_card(const char cardChar) -> int { switch (cardChar) diff --git a/library/src/pbn.hpp b/library/src/pbn.hpp index ad07351a5..994c84a4e 100644 --- a/library/src/pbn.hpp +++ b/library/src/pbn.hpp @@ -16,6 +16,7 @@ * @brief Convert a PBN (Portable Bridge Notation) Deal string to DDS card array. * * Parses a PBN-format Deal string and fills the DDS card array with the remaining cards for each hand and suit. + * Only the first hand may have a compass letter (N/E/S/W); the other three hands follow clockwise and must not include additional directions. * * @param dealBuff PBN-format Deal string. * @param remainCards Output array for remaining cards per hand and suit. diff --git a/library/tests/BUILD.bazel b/library/tests/BUILD.bazel index cf8c37293..ff30e5092 100644 --- a/library/tests/BUILD.bazel +++ b/library/tests/BUILD.bazel @@ -17,6 +17,7 @@ filegroup( "parse_par_test.cpp", # Uses GoogleTest, compiled separately "loop_par_test.cpp", # Uses GoogleTest, compiled separately "dds_c_api_test.cpp", # Uses GoogleTest, compiled separately + "pbn_test.cpp", # Uses GoogleTest, compiled separately ], ), ) @@ -151,6 +152,20 @@ cc_test( ], ) +cc_test( + name = "pbn_test", + srcs = ["pbn_test.cpp"], + size = "small", + copts = DDS_CPPOPTS, + linkopts = DDS_LINKOPTS, + local_defines = DDS_LOCAL_DEFINES, + deps = [ + "//library/src:testable_dds", + "//library/src/api:api_definitions", + "@googletest//:gtest_main", + ], +) + cc_test( name = "parse_par_test", srcs = [ diff --git a/library/tests/README.md b/library/tests/README.md index 7ec764fb4..98e4b4605 100644 --- a/library/tests/README.md +++ b/library/tests/README.md @@ -10,7 +10,7 @@ The test cases are located in the `hands` directory. Each `.txt` file in this di The input files use a specific format with keywords to define the test parameters for each deal. The first line is `NUMBER N`, where `N` is the count of deals in the file. Each deal is then a block of lines in this order: -- **`PBN`**: Deal header with four integer fields — dealer, vulnerability, trump, and leader — followed by a quoted PBN `remainCards` string listing the cards held by each player (North, East, South, West). +- **`PBN`**: Deal header with four integer fields — dealer, vulnerability, trump, and leader — followed by a quoted PBN `remainCards` string. That string starts with one compass letter (`N`/`E`/`S`/`W`) for the first hand; the other three hands follow clockwise with no further seat letters. - **`FUT`**: Expected `SolveBoard` future-tricks result for the deal (card count plus suit/rank/equals/score arrays). - **`TABLE`**: Expected double-dummy table: 20 integers, `res_table[strain][hand]` for 5 strains (♠♥♦♣NT) × 4 seats (N/E/S/W). - **`PAR`**: Expected par scores and contract strings for NS and EW views. diff --git a/library/tests/pbn_test.cpp b/library/tests/pbn_test.cpp new file mode 100644 index 000000000..f6cb8a060 --- /dev/null +++ b/library/tests/pbn_test.cpp @@ -0,0 +1,71 @@ +/// @file pbn_test.cpp +/// @brief Unit tests for convert_from_pbn deal-string parsing. + +#include + +#include +#include + +namespace +{ + +constexpr char kNorthFirst[] = + "N:QJ6.K652.J85.T98 873.J97.AT764.Q4 K5.T83.KQ9.A7652 AT942.AQ4.32.KJ3"; + +constexpr char kEastFirst[] = + "E:QJT5432.T.6.QJ82 .J97543.K7532.94 87.A62.QJT4.AT75 AK96.KQ8.A98.K63"; + +auto convert(const char* pbn) -> int +{ + unsigned int remain[DDS_HANDS][DDS_SUITS]{}; + return convert_from_pbn(pbn, remain); +} + +} // namespace + +TEST(ConvertFromPbn, AcceptsNorthFirstWithoutLaterDirections) +{ + unsigned int remain[DDS_HANDS][DDS_SUITS]{}; + EXPECT_EQ(convert_from_pbn(kNorthFirst, remain), RETURN_NO_FAULT); + EXPECT_NE(remain[0][0], 0u); +} + +TEST(ConvertFromPbn, AcceptsEastFirstWithoutLaterDirections) +{ + EXPECT_EQ(convert(kEastFirst), RETURN_NO_FAULT); +} + +TEST(ConvertFromPbn, AcceptsLowercaseFirstHandDirection) +{ + EXPECT_EQ( + convert( + "n:QJ6.K652.J85.T98 873.J97.AT764.Q4 K5.T83.KQ9.A7652 AT942.AQ4.32.KJ3"), + RETURN_NO_FAULT); +} + +TEST(ConvertFromPbn, RejectsClockwiseSeatLettersOnLaterHands) +{ + EXPECT_EQ( + convert( + "N:QJ6.K652.J85.T98 E:873.J97.AT764.Q4 S:K5.T83.KQ9.A7652 " + "W:AT942.AQ4.32.KJ3"), + 0); +} + +TEST(ConvertFromPbn, RejectsASingleExtraSeatLetter) +{ + EXPECT_EQ( + convert( + "N:QJ6.K652.J85.T98 W:873.J97.AT764.Q4 K5.T83.KQ9.A7652 " + "AT942.AQ4.32.KJ3"), + 0); +} + +TEST(ConvertFromPbn, RejectsLowercaseExtraSeatLetter) +{ + EXPECT_EQ( + convert( + "N:QJ6.K652.J85.T98 e:873.J97.AT764.Q4 K5.T83.KQ9.A7652 " + "AT942.AQ4.32.KJ3"), + 0); +} diff --git a/python/src/bindings.cpp b/python/src/bindings.cpp index 0d103285c..12892a55b 100644 --- a/python/src/bindings.cpp +++ b/python/src/bindings.cpp @@ -195,7 +195,8 @@ auto register_solve_bindings(py::module_& module) -> void py::arg("context") = py::none(), "Solve a single bridge deal from PBN (Portable Bridge Notation) format.\n\n" "Args:\n" - " remain_cards (str): Remaining cards in PBN format (e.g., 'N:AK.234.456.789T...').\n" + " remain_cards (str): Remaining cards in PBN format (e.g., 'N:AK.234.456.789T...'). " + "Only the first hand may have a compass letter; later hands follow clockwise.\n" " trump (int, optional): Trump suit (0=♠, 1=♥, 2=♦, 3=♣, 4=NT). Default: 4\n" " first (int, optional): Seat that plays first (0=N, 1=E, 2=S, 3=W). Default: 0\n" " current_trick_suit (tuple, optional): Suits in current trick (3-tuple of ints, 0-3). Default: (0, 0, 0)\n" @@ -264,7 +265,8 @@ auto register_solve_bindings(py::module_& module) -> void "Solve multiple bridge deals in PBN format.\n\n" "Args:\n" " boards (list): List of board dicts, each with:\n" - " remain_cards (str): Remaining cards in PBN format (e.g., 'N:AK.234.456.789T...').\n" + " remain_cards (str): Remaining cards in PBN format (e.g., 'N:AK.234.456.789T...'). " + "Only the first hand may have a compass letter; later hands follow clockwise.\n" " trump (int, optional): Trump suit (0=♠, 1=♥, 2=♦, 3=♣, 4=NT). Default: 4\n" " first (int, optional): Seat that plays first (0=N, 1=E, 2=S, 3=W). Default: 0\n" " current_trick_suit (tuple, optional): Suits in current trick. Default: (0, 0, 0)\n" diff --git a/python/tests/README.md b/python/tests/README.md index aace6eb3f..c44573a67 100644 --- a/python/tests/README.md +++ b/python/tests/README.md @@ -76,6 +76,7 @@ pytest python/tests/test_solve_board.py::TestSolveBoard::test_solve_board_basic - **TestPBNConversions**: PBN string parsing validation - Valid PBN format acceptance - Invalid seat designations + - Extra compass letters on later hands rejected - Truncated/empty string handling - **TestTrumpFilterValidation**: Trump filter parameter bounds diff --git a/python/tests/test_type_conversions.py b/python/tests/test_type_conversions.py index 8d13b64fe..95ccb5c19 100644 --- a/python/tests/test_type_conversions.py +++ b/python/tests/test_type_conversions.py @@ -198,6 +198,21 @@ def test_pbn_truncated(self) -> None: pbn = "N:AK.234.456.789TJQ W:QJ.AKQJ" # Incomplete assert_raises((ValueError, RuntimeError), solve_board_pbn, pbn) + def test_pbn_extra_seat_letters_rejected(self) -> None: + """Later hands may not include compass letters; only the first hand may.""" + pbn = ( + "N:QJ6.K652.J85.T98 E:873.J97.AT764.Q4 " + "S:K5.T83.KQ9.A7652 W:AT942.AQ4.32.KJ3" + ) + assert_raises((ValueError, RuntimeError), solve_board_pbn, pbn) + + def test_pbn_single_extra_seat_letter_rejected(self) -> None: + pbn = ( + "N:QJ6.K652.J85.T98 W:873.J97.AT764.Q4 " + "K5.T83.KQ9.A7652 AT942.AQ4.32.KJ3" + ) + assert_raises((ValueError, RuntimeError), solve_board_pbn, pbn) + class TestTrumpFilterValidation(unittest.TestCase): """Tests for trump_filter validation in calc_all_tables_pbn.""" From 76695d464b4d2dc25f586268aedaf68fa525831a Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 19 Aug 2026 08:31:40 +0200 Subject: [PATCH 02/10] Guard convert_from_pbn against null and short PBN strings. Stop scanning past the terminator and require a Seat: prefix so empty or truncated inputs fail safely instead of reading out of bounds. Co-authored-by: Cursor --- library/src/pbn.cpp | 7 +++++-- library/tests/pbn_test.cpp | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/library/src/pbn.cpp b/library/src/pbn.cpp index 5381184a3..aecebddac 100644 --- a/library/src/pbn.cpp +++ b/library/src/pbn.cpp @@ -18,15 +18,18 @@ auto convert_from_pbn( char const * dealBuff, unsigned int remainCards[DDS_HANDS][DDS_SUITS]) -> int { + if (dealBuff == nullptr) + return 0; + for (int h = 0; h < DDS_HANDS; h++) for (int s = 0; s < DDS_SUITS; s++) remainCards[h][s] = 0; int bp = 0; - while ((bp < 3) && !is_compass_letter(dealBuff[bp])) + while ((bp < 3) && (dealBuff[bp] != '\0') && !is_compass_letter(dealBuff[bp])) bp++; - if (bp >= 3) + if ((bp >= 3) || (dealBuff[bp] == '\0') || (dealBuff[bp + 1] != ':')) return 0; int first; diff --git a/library/tests/pbn_test.cpp b/library/tests/pbn_test.cpp index f6cb8a060..8f2450226 100644 --- a/library/tests/pbn_test.cpp +++ b/library/tests/pbn_test.cpp @@ -69,3 +69,16 @@ TEST(ConvertFromPbn, RejectsLowercaseExtraSeatLetter) "AT942.AQ4.32.KJ3"), 0); } + +TEST(ConvertFromPbn, RejectsNullPointerDealBuffer) +{ + unsigned int remain[DDS_HANDS][DDS_SUITS]{}; + EXPECT_EQ(convert_from_pbn(nullptr, remain), 0); +} + +TEST(ConvertFromPbn, RejectsEmptyAndMissingSeatPrefixInputs) +{ + EXPECT_EQ(convert(""), 0); + EXPECT_EQ(convert("N"), 0); + EXPECT_EQ(convert("xx"), 0); +} From f7952794ae479a0d3d7b23a00436dc5721606207 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 19 Aug 2026 08:45:57 +0200 Subject: [PATCH 03/10] Always clear remainCards before convert_from_pbn error returns. Zero the output buffer before rejecting null or invalid deal strings so callers that ignore the return code do not see stale card data. Co-authored-by: Cursor --- library/src/pbn.cpp | 5 ++++- library/tests/pbn_test.cpp | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/library/src/pbn.cpp b/library/src/pbn.cpp index aecebddac..e7ada5b38 100644 --- a/library/src/pbn.cpp +++ b/library/src/pbn.cpp @@ -18,13 +18,16 @@ auto convert_from_pbn( char const * dealBuff, unsigned int remainCards[DDS_HANDS][DDS_SUITS]) -> int { - if (dealBuff == nullptr) + if (remainCards == nullptr) return 0; for (int h = 0; h < DDS_HANDS; h++) for (int s = 0; s < DDS_SUITS; s++) remainCards[h][s] = 0; + if (dealBuff == nullptr) + return 0; + int bp = 0; while ((bp < 3) && (dealBuff[bp] != '\0') && !is_compass_letter(dealBuff[bp])) bp++; diff --git a/library/tests/pbn_test.cpp b/library/tests/pbn_test.cpp index 8f2450226..4b2993739 100644 --- a/library/tests/pbn_test.cpp +++ b/library/tests/pbn_test.cpp @@ -76,6 +76,27 @@ TEST(ConvertFromPbn, RejectsNullPointerDealBuffer) EXPECT_EQ(convert_from_pbn(nullptr, remain), 0); } +TEST(ConvertFromPbn, ClearsOutputOnNullDealBuffer) +{ + unsigned int remain[DDS_HANDS][DDS_SUITS]{}; + remain[0][0] = 0xFFFF; + EXPECT_EQ(convert_from_pbn(nullptr, remain), 0); + EXPECT_EQ(remain[0][0], 0u); +} + +TEST(ConvertFromPbn, ClearsOutputOnInvalidDeal) +{ + unsigned int remain[DDS_HANDS][DDS_SUITS]{}; + remain[0][0] = 0xFFFF; + EXPECT_EQ(convert_from_pbn("xx", remain), 0); + EXPECT_EQ(remain[0][0], 0u); +} + +TEST(ConvertFromPbn, RejectsNullOutputBuffer) +{ + EXPECT_EQ(convert_from_pbn(kNorthFirst, nullptr), 0); +} + TEST(ConvertFromPbn, RejectsEmptyAndMissingSeatPrefixInputs) { EXPECT_EQ(convert(""), 0); From 37fe782727449551b9b417c66d78bbae08633a86 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 19 Aug 2026 08:52:39 +0200 Subject: [PATCH 04/10] Bounds-check hand and suit indices in convert_from_pbn. Reject malformed PBN with too many suit separators or hand spaces before writing into remainCards. Co-authored-by: Cursor --- library/src/pbn.cpp | 12 ++++++++++++ library/tests/pbn_test.cpp | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/library/src/pbn.cpp b/library/src/pbn.cpp index e7ada5b38..0fc6f9f42 100644 --- a/library/src/pbn.cpp +++ b/library/src/pbn.cpp @@ -57,6 +57,9 @@ auto convert_from_pbn( card = is_card(dealBuff[bp]); if (card) { + if (hand_rel_first >= DDS_HANDS || suitInHand >= DDS_SUITS) + return 0; + switch (first) { case 0: @@ -85,14 +88,23 @@ auto convert_from_pbn( hand = hand_rel_first - 1; } + if (hand < 0 || hand >= DDS_HANDS) + return 0; + remainCards[hand][suitInHand] |= static_cast((bit_map_rank[card] << 2)); } else if (dealBuff[bp] == '.') + { + if (suitInHand >= DDS_SUITS - 1) + return 0; suitInHand++; + } else if (dealBuff[bp] == ' ') { + if (hand_rel_first >= DDS_HANDS - 1) + return 0; hand_rel_first++; suitInHand = 0; } diff --git a/library/tests/pbn_test.cpp b/library/tests/pbn_test.cpp index 4b2993739..1b9423f06 100644 --- a/library/tests/pbn_test.cpp +++ b/library/tests/pbn_test.cpp @@ -103,3 +103,14 @@ TEST(ConvertFromPbn, RejectsEmptyAndMissingSeatPrefixInputs) EXPECT_EQ(convert("N"), 0); EXPECT_EQ(convert("xx"), 0); } + +TEST(ConvertFromPbn, RejectsTooManySuitsInHand) +{ + EXPECT_EQ(convert("N:AK.K.K.K.A"), 0); +} + +TEST(ConvertFromPbn, RejectsTooManyHands) +{ + unsigned int remain[DDS_HANDS][DDS_SUITS]{}; + EXPECT_EQ(convert_from_pbn("N:AK.K.K.K A", remain), 0); +} From 82f03ebef18b100e47850ec5e9ea55865ca7a5cb Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 19 Aug 2026 09:04:56 +0200 Subject: [PATCH 05/10] Fail convert_from_pbn when PBN input exceeds the 80-byte buffer. Reject deals that do not terminate within DealPBN::remainCards so truncated or overlong strings cannot bypass later-hand validation. Co-authored-by: Cursor --- library/src/pbn.cpp | 4 ++++ library/tests/pbn_test.cpp | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/library/src/pbn.cpp b/library/src/pbn.cpp index 0fc6f9f42..c3e2a607f 100644 --- a/library/src/pbn.cpp +++ b/library/src/pbn.cpp @@ -112,6 +112,10 @@ auto convert_from_pbn( return 0; bp++; } + + if (bp >= 80) + return 0; + return RETURN_NO_FAULT; } diff --git a/library/tests/pbn_test.cpp b/library/tests/pbn_test.cpp index 1b9423f06..1f26e0028 100644 --- a/library/tests/pbn_test.cpp +++ b/library/tests/pbn_test.cpp @@ -3,6 +3,8 @@ #include +#include + #include #include @@ -114,3 +116,17 @@ TEST(ConvertFromPbn, RejectsTooManyHands) unsigned int remain[DDS_HANDS][DDS_SUITS]{}; EXPECT_EQ(convert_from_pbn("N:AK.K.K.K A", remain), 0); } + +TEST(ConvertFromPbn, RejectsInputLongerThanRemainCardsBuffer) +{ + const std::string pbn = + std::string(kNorthFirst) + " E:873.J97.AT764.Q4"; + ASSERT_GT(pbn.size(), 80u); + EXPECT_EQ(convert(pbn.c_str()), 0); +} + +TEST(ConvertFromPbn, AcceptsInputThatFitsRemainCardsBuffer) +{ + ASSERT_LT(std::char_traits::length(kNorthFirst), 80u); + EXPECT_EQ(convert(kNorthFirst), RETURN_NO_FAULT); +} From 991015899c1425fdb027f2a02270ca0bfb94719c Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 19 Aug 2026 09:10:42 +0200 Subject: [PATCH 06/10] Test PBN rejection at exactly 80 characters. Pin the remainCards buffer boundary so inputs that fill all 80 bytes without an early NUL stay rejected. Co-authored-by: Cursor --- library/tests/pbn_test.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/library/tests/pbn_test.cpp b/library/tests/pbn_test.cpp index 1f26e0028..4701b5b86 100644 --- a/library/tests/pbn_test.cpp +++ b/library/tests/pbn_test.cpp @@ -125,6 +125,14 @@ TEST(ConvertFromPbn, RejectsInputLongerThanRemainCardsBuffer) EXPECT_EQ(convert(pbn.c_str()), 0); } +TEST(ConvertFromPbn, RejectsInputExactlyAtRemainCardsBufferLimit) +{ + std::string pbn = "N:"; + pbn.append(78, 'A'); + ASSERT_EQ(pbn.size(), 80u); + EXPECT_EQ(convert(pbn.c_str()), 0); +} + TEST(ConvertFromPbn, AcceptsInputThatFitsRemainCardsBuffer) { ASSERT_LT(std::char_traits::length(kNorthFirst), 80u); From 5329d3a2652d629b2fe51c47471872f0e0da6cd7 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 19 Aug 2026 11:15:09 +0200 Subject: [PATCH 07/10] Pad overlong PBN test with card chars so only the length check triggers. The previous test appended a hand separator and compass letter that could trip the extra-seat rejection before hitting the 80-byte limit. Co-authored-by: Cursor --- library/tests/pbn_test.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/tests/pbn_test.cpp b/library/tests/pbn_test.cpp index 4701b5b86..17e9b1ec9 100644 --- a/library/tests/pbn_test.cpp +++ b/library/tests/pbn_test.cpp @@ -119,8 +119,8 @@ TEST(ConvertFromPbn, RejectsTooManyHands) TEST(ConvertFromPbn, RejectsInputLongerThanRemainCardsBuffer) { - const std::string pbn = - std::string(kNorthFirst) + " E:873.J97.AT764.Q4"; + std::string pbn = "N:"; + pbn.append(80, 'A'); ASSERT_GT(pbn.size(), 80u); EXPECT_EQ(convert(pbn.c_str()), 0); } From 8140ca5d6312cb73b327f7679396880fb581dc2a Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 19 Aug 2026 11:24:55 +0200 Subject: [PATCH 08/10] Derive PBN buffer limit from DealPBN::remainCards instead of hard-coding 80. Both the parser and boundary tests now track the ABI-defined buffer size. Co-authored-by: Cursor --- library/src/pbn.cpp | 7 +++++-- library/tests/pbn_test.cpp | 13 ++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/library/src/pbn.cpp b/library/src/pbn.cpp index c3e2a607f..ded5448a0 100644 --- a/library/src/pbn.cpp +++ b/library/src/pbn.cpp @@ -9,6 +9,9 @@ #include "pbn.hpp" #include +#include + +constexpr int PbnBufferSize = static_cast(sizeof(DealPBN::remainCards)); auto is_card(const char cardChar) -> int; auto is_compass_letter(const char c) -> bool; @@ -52,7 +55,7 @@ auto convert_from_pbn( int suitInHand = 0; int card, hand; - while ((bp < 80) && (dealBuff[bp] != '\0')) + while ((bp < PbnBufferSize) && (dealBuff[bp] != '\0')) { card = is_card(dealBuff[bp]); if (card) @@ -113,7 +116,7 @@ auto convert_from_pbn( bp++; } - if (bp >= 80) + if (bp >= PbnBufferSize) return 0; return RETURN_NO_FAULT; diff --git a/library/tests/pbn_test.cpp b/library/tests/pbn_test.cpp index 17e9b1ec9..9a4801007 100644 --- a/library/tests/pbn_test.cpp +++ b/library/tests/pbn_test.cpp @@ -119,22 +119,25 @@ TEST(ConvertFromPbn, RejectsTooManyHands) TEST(ConvertFromPbn, RejectsInputLongerThanRemainCardsBuffer) { + constexpr auto kBufSize = sizeof(DealPBN::remainCards); std::string pbn = "N:"; - pbn.append(80, 'A'); - ASSERT_GT(pbn.size(), 80u); + pbn.append(kBufSize, 'A'); + ASSERT_GT(pbn.size(), kBufSize); EXPECT_EQ(convert(pbn.c_str()), 0); } TEST(ConvertFromPbn, RejectsInputExactlyAtRemainCardsBufferLimit) { + constexpr auto kBufSize = sizeof(DealPBN::remainCards); std::string pbn = "N:"; - pbn.append(78, 'A'); - ASSERT_EQ(pbn.size(), 80u); + pbn.append(kBufSize - 2, 'A'); + ASSERT_EQ(pbn.size(), kBufSize); EXPECT_EQ(convert(pbn.c_str()), 0); } TEST(ConvertFromPbn, AcceptsInputThatFitsRemainCardsBuffer) { - ASSERT_LT(std::char_traits::length(kNorthFirst), 80u); + constexpr auto kBufSize = sizeof(DealPBN::remainCards); + ASSERT_LT(std::char_traits::length(kNorthFirst), kBufSize); EXPECT_EQ(convert(kNorthFirst), RETURN_NO_FAULT); } From f1782bcb7e76fd62e84f84dc850122e9c7d865b1 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 19 Aug 2026 11:35:16 +0200 Subject: [PATCH 09/10] Reject truncated PBN deals with fewer than 4 hands Adds a post-parse check that all 4 hands were present and a test covering 1-, 2-, and 3-hand inputs. Co-authored-by: Cursor --- library/src/pbn.cpp | 3 +++ library/tests/pbn_test.cpp | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/library/src/pbn.cpp b/library/src/pbn.cpp index ded5448a0..4ec56fec7 100644 --- a/library/src/pbn.cpp +++ b/library/src/pbn.cpp @@ -119,6 +119,9 @@ auto convert_from_pbn( if (bp >= PbnBufferSize) return 0; + if (hand_rel_first != DDS_HANDS - 1) + return 0; + return RETURN_NO_FAULT; } diff --git a/library/tests/pbn_test.cpp b/library/tests/pbn_test.cpp index 9a4801007..afe843701 100644 --- a/library/tests/pbn_test.cpp +++ b/library/tests/pbn_test.cpp @@ -117,6 +117,13 @@ TEST(ConvertFromPbn, RejectsTooManyHands) EXPECT_EQ(convert_from_pbn("N:AK.K.K.K A", remain), 0); } +TEST(ConvertFromPbn, RejectsTruncatedDealWithFewerThanFourHands) +{ + EXPECT_EQ(convert("N:AK.QJ.T9.876"), 0); // 1 hand + EXPECT_EQ(convert("N:AK.QJ.T9.876 AK.QJ.T9.876"), 0); // 2 hands + EXPECT_EQ(convert("N:AK.QJ.T9.876 AK.QJ.T9.876 AK.QJ.T9.876"), 0); // 3 hands +} + TEST(ConvertFromPbn, RejectsInputLongerThanRemainCardsBuffer) { constexpr auto kBufSize = sizeof(DealPBN::remainCards); From 6d8e42bd03cd4136ad7080938d7b88c2c0c88a2b Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 20 Aug 2026 21:30:27 +0200 Subject: [PATCH 10/10] Clarify that extra PBN compass letters fail validation. Co-authored-by: Cursor --- python/tests/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/python/tests/README.md b/python/tests/README.md index c44573a67..41f71436e 100644 --- a/python/tests/README.md +++ b/python/tests/README.md @@ -77,6 +77,7 @@ pytest python/tests/test_solve_board.py::TestSolveBoard::test_solve_board_basic - Valid PBN format acceptance - Invalid seat designations - Extra compass letters on later hands rejected + - Raises an error (validation fails; does not continue) - Truncated/empty string handling - **TestTrumpFilterValidation**: Trump filter parameter bounds