From c1100273c35dd26711d20395992c91b6c3a547f7 Mon Sep 17 00:00:00 2001 From: ecgang Date: Sun, 26 Jul 2026 11:53:55 -0700 Subject: [PATCH] Courier spray: scope the overlap seam per store, and pin the receipt identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more from cross-model review (codex). The overlap seam was a mutable static. Swift Testing runs cases in parallel, so the three tests that install it could overwrite each other's hook and tally an overlap onto the wrong test — a flake that would have shown up as an unreproducible count long after this landed. It is per-instance now; each test already owns its store, so each owns its seam, and no serialization is needed. Separately, ciphertextHash had no pinned value anywhere. Every use in the test file derived it exactly as production does — CourierStore.ciphertextHash on both sides of the assertion — so a change to the derivation moved both sides and every test stayed green, while every already-shipped peer's receipts stopped matching. The bytes are written down once now. That pin uses the published courier vector's input and expected value, so it moves into the vectors file unchanged once that lands. It is here rather than there because this is where the function it checks lives. Mutation-verified: changing the production truncation from 16 bytes to 12 fails that one test and only that one. Four consecutive runs of the suite pass with the per-instance seam. Co-Authored-By: Claude Opus 5 (1M context) --- bitchat/Services/Courier/CourierStore.swift | 8 ++++-- bitchatTests/CourierStoreTests.swift | 28 ++++++++++++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index 91029e3b..7f1dbd9a 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -169,7 +169,11 @@ final class CourierStore { /// Test seam for the overlap detector, mirroring `_test_onOutboundPacket` /// in `BLEService`. Unset in normal debug runs, where an overlap trips /// `assertionFailure` instead. - static var _test_onSprayOfferOverlap: (() -> Void)? + /// + /// Per instance, not static: Swift Testing runs cases in parallel, so a + /// shared hook would let one case overwrite another's and tally overlaps + /// onto the wrong test. Each test owns its store, so each owns its seam. + var _test_onSprayOfferOverlap: (() -> Void)? #endif /// Marks the start of a spray offer's scan-to-commit span, reporting an @@ -179,7 +183,7 @@ final class CourierStore { queue.sync { defer { sprayOffersInFlight += 1 } guard sprayOffersInFlight > 0 else { return } - if let hook = Self._test_onSprayOfferOverlap { + if let hook = _test_onSprayOfferOverlap { hook() } else { assertionFailure(""" diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift index d36de0e7..0c8064d4 100644 --- a/bitchatTests/CourierStoreTests.swift +++ b/bitchatTests/CourierStoreTests.swift @@ -733,6 +733,25 @@ struct CourierStoreTests { #expect(offerAll(store, to: courierB) == 2) } + /// Pins the envelope identity a spray receipt carries. + /// + /// Every other use in this file derives the hash the same way production + /// does — `CourierStore.ciphertextHash(envelope.ciphertext)` on both sides — + /// so a change to the derivation moves both and every one of them stays + /// green. This is the one place the bytes are written down, which is the + /// only way a receipt-identity change can fail a test rather than silently + /// re-key every pending offer and break interop with an already-shipped + /// peer. + /// + /// Input and expected value are the published courier vector's, so when + /// the vectors file lands this assertion moves into it unchanged. + @Test func ciphertextHashIsPinnedSHA256TruncatedTo16() { + let ciphertext = Data("courier-vector-ciphertext-0001".utf8) + #expect(CourierStore.ciphertextHash(ciphertext).hexEncodedString() + == "bb85dcc4d8b17377c61817992df95826") + #expect(CourierStore.ciphertextHash(ciphertext).count == CourierEnvelope.tagLength) + } + #if DEBUG /// The scan-to-commit span guard. /// @@ -756,8 +775,7 @@ struct CourierStoreTests { let courierB = Data(repeating: 0xC2, count: 32) let overlaps = OverlapCounter() - CourierStore._test_onSprayOfferOverlap = { overlaps.count += 1 } - defer { CourierStore._test_onSprayOfferOverlap = nil } + store._test_onSprayOfferOverlap = { overlaps.count += 1 } _ = store.offerSprayCopies(to: courierA) { _ in // Copies for A are on the wire; B now scans before A has committed. @@ -781,8 +799,7 @@ struct CourierStoreTests { let courierB = Data(repeating: 0xC2, count: 32) let overlaps = OverlapCounter() - CourierStore._test_onSprayOfferOverlap = { overlaps.count += 1 } - defer { CourierStore._test_onSprayOfferOverlap = nil } + store._test_onSprayOfferOverlap = { overlaps.count += 1 } _ = store.offerSprayCopies(to: courierA) { _ in _ = store.takeSprayCopies(for: courierB) @@ -808,8 +825,7 @@ struct CourierStoreTests { let courierC = Data(repeating: 0xC3, count: 32) let overlaps = OverlapCounter() - CourierStore._test_onSprayOfferOverlap = { overlaps.count += 1 } - defer { CourierStore._test_onSprayOfferOverlap = nil } + store._test_onSprayOfferOverlap = { overlaps.count += 1 } _ = store.offerSprayCopies(to: courierA) { _ in // B overlaps A and finishes; A is still in flight afterwards.