Skip to content

test(peer): lock inbound dial+accept counted once - #591

Open
MichaelTaylor3d wants to merge 4 commits into
developfrom
loop/3124-dial-accept-no-double-count
Open

test(peer): lock inbound dial+accept counted once#591
MichaelTaylor3d wants to merge 4 commits into
developfrom
loop/3124-dial-accept-no-double-count

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Outcome: GREEN-LOCKS-PROPERTY

What

Compiles and hardens the regression test salvaged from a killed lane: a_peer_that_is_both_dialled_and_accepted_is_counted_once
in crates/dig-node-core/tests/inbound_pool_membership.rs. This is a characterization test that LOCKS a never-proven
property
— it is not a TDD red/green from a live bug, and it is not being reported as a caught defect. The property it
locks: a peer identity that holds BOTH a dialled (outbound) pool slot and simultaneously makes an accepted (inbound) mTLS
connection to the same node is counted exactly ONCE, and the surviving slot is the pre-existing DIALLED one, per
adopt_direct_inbound_handle's documented refusal rule (the accepted connection never supersedes a slot this node can
dial — the #870 rule).

Refs #3124

Evidence

cd /d/worktrees/dig-node-3124
./target/debug/deps/inbound_pool_membership-1654aab9aafefc4c.exe --nocapture --test-threads=1

running 4 tests
test a_peer_that_is_both_dialled_and_accepted_is_counted_once ... ok
test a_reconnect_does_not_let_the_stale_session_evict_the_live_one ... ok
test an_accepted_inbound_peer_is_counted_served_and_released ... ok
test the_accepted_direct_cap_still_binds_after_a_supersede_and_stale_release ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 18.32s

Teeth check — the first run FAILED, and that failure found a real defect in the test

A teeth check (deliberate mutation of adopt_inbound_peer_in_pool in crates/dig-node-core/src/peer.rs, confirm RED,
revert, confirm GREEN) was run twice.

First run, before this PR's latest commit: the mutation made the Err arm of adopt_inbound_peer_in_pool return
Some(InboundPoolSlot { peer_id: pool_id, superseded }) instead of None — i.e. a REFUSED adoption reports itself as
adopted, which should make the inbound session's teardown incorrectly release the outbound slot it never owned. Built
clean, ran the test. It stayed GREEN. Root cause: Step 5's assertion read peer_count() in a single instant right
after drop(inbound_conn), with no barrier between the drop and the read — dropping the client side does not
synchronously run the server's teardown, so the read fired before the server had acted, regardless of whether the
eventual release was correct. The assertion could not fail. Full write-up:
#591 (comment)

Fix: Step 5 now polls peer_count() across a 2-second bounded window instead of reading it once. A comment above
the check explains why an instant read cannot prove a non-event and a window is required. Also removed a vacuous
same-identity assertion in Step 2 (see Trust section) and renumbered the steps (2b -> 3, 3 -> 4) now that there is no
numbering gap.

Second run, against the fixed test:

# mutation applied to peer.rs Err arm
cargo build --tests -p dig-node-core
BIN=$(command ls -t target/debug/deps/inbound_pool_membership-*.exe | head -1)
"$BIN" a_peer_that_is_both_dialled_and_accepted_is_counted_once --nocapture --test-threads=1

test a_peer_that_is_both_dialled_and_accepted_is_counted_once ...
thread '...' panicked at crates\dig-node-core\tests\inbound_pool_membership.rs:691:9:
assertion `left == right` failed: the inbound session ending released the outbound slot it never
owned -- a session-scoped release defect: the refused inbound leg tore down B's dialled slot when
its own transport closed
  left: 0
 right: 1
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 3 filtered out; finished in 1.19s

RED, for the right reason. Mutation then fully reverted (git diff --stat crates/dig-node-core/src/peer.rs empty,
never committed), rebuilt, reran the full file:

test a_peer_that_is_both_dialled_and_accepted_is_counted_once ... ok
test a_reconnect_does_not_let_the_stale_session_evict_the_live_one ... ok
test an_accepted_inbound_peer_is_counted_served_and_released ... ok
test the_accepted_direct_cap_still_binds_after_a_supersede_and_stale_release ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 18.32s

GREEN again, confirmed against a binary whose mtime postdates the revert. Step 5 now has teeth.

Fixes applied to the salvaged test

  1. Compiled it — Defect 1 verified clean. The salvaged commit had never been built. Verified the assumed
    dig-gossip/dig-nat API surface against the pinned revs (dig-gossip 0.32.0, git rev
    1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee; dig-nat 0.21.2 and dig-tls 0.4.1 from the registry):
    dig_gossip::NatPeerConnection::new, dig_nat::PeerConnection's field list, PeerId::from_bytes (re-exported
    from dig-tls), PeerSession::client/server, GossipHandle::disconnect, and is_outbound on the detailed
    pool-peer struct all matched exactly as written — no API repair was needed.
  2. Replaced the sleep-race with a real ordering barrier. The test previously slept 300ms and then asserted
    peer_count() == 1 — a count that reads as "correctly de-duplicated" and "the inbound accept simply hasn't run yet"
    identically. adopt_inbound_peer_in_pool is called at peer.rs:3602, strictly BEFORE the serve loop starts
    answering RPC (peer.rs:3614), so a successful dig.getNetworkInfo round-trip over the inbound session is proof the
    adoption attempt already ran and returned. The RPC step was moved ahead of the count/row assertions.
  3. Verified, not assumed, the surviving-slot behaviour — Defect 3 verified clean. Read adopt_direct_inbound_handle's admission code
    (dig-gossip service/gossip_handle.rs): it explicitly REFUSES — never supersedes — whenever the held slot's
    dial_addr() is Some. This is a refusal, not a supersede-by-newer-connection; the test's assertion that the
    outbound slot survives is correct as originally written, and the comment now cites the actual admission code path
    rather than only the doc comment.
  4. Step 5's non-event assertion given a real window (this PR's latest commit — see teeth-check section above for
    the defect it fixes).
  5. Removed a vacuous same-identity assertion (see Trust section, fifth bullet's neighbour) that compared
    b_identity.peer_id().as_bytes() to b_bytes, a value derived from b_identity two lines earlier — true by
    construction, and it never touched the server side.

Blast radius

Test-only change to one integration-test file (crates/dig-node-core/tests/inbound_pool_membership.rs). No production
code was committed; peer.rs was mutated and reverted only as part of the teeth check above (never staged, never
pushed).

What an inbound peer can now reach that it could not before

No capability and no service — but yes to visibility.

  • Adoption sets no trust or permission bit. adopt_direct_inbound_handle/adopt_relayed_inbound_handle take only the
    pool id (the 32 bytes the mTLS handshake already proved), the remote socket address, the traversal tier, an
    ObservedSession, and broadcast_sink = None (peer.rs:3602-3609, adopt_inbound_peer_in_pool).
  • An adopted inbound peer is NON-dialable: dig-gossip's PeerSlot::dial_addr() returns None whenever
    !is_outbound (service/state.rs), so dial_addr == None, it is absent from dialable_pool_peers(), and it takes
    no outbound diversity budget. A direct inbound peer's remote is its ephemeral SOURCE port, never a stable dial
    target — confirmed by the existing an_accepted_inbound_peer_is_counted_served_and_released test in this file.
  • broadcast_sink = None means dig-gossip reports the peer unreachable for broadcast; no gossip fan-out.
    peer.rs:3691-3711 documents the three measured reasons no sink exists today (no wire frame for a broadcast, no
    matching classify_request route, no ingest path into dig-gossip's crate-private inbound_tx).
  • Service was ALREADY unconditional before adoption: adopt_inbound_peer_in_pool's own doc states a refused
    adoption is logged and the peer is served uncounted — "the behaviour that shipped before this call existed"
    (peer.rs, doc above the function). Adoption grants no service the peer did not already have.
  • Visibility, not capability: once adopted, an inbound peer's (peer_id, remote_addr) enters
    connected_pool_peers(), which is read by known_peers() (seams/dig_peer/ping.rs:704-710, which discards the
    _outbound flag at line 708) and by PEX's spawn_pool_feeder (seams/dig_peer/pex.rs:460-468, whose own comment
    says it advertises "the FULL pool (both directions)"). Neither reader distinguishes dialable from non-dialable
    entries, so adoption is what causes this node to advertise that peer's ephemeral source port to the mesh as a
    known-peer hint. This is pre-existing shipped behaviour from an earlier PR in the epic — not introduced by this
    diff — and is bounded: PEX treats every entry as an unverified hint, not a trust grant. Not a blocker, but the
    unqualified word "Nothing" that used to sit here was wrong for this axis and is corrected.

Net: adoption grants VISIBILITY (a count, a row in connected_pool_peers_detailed, and — per the fifth bullet — a
PEX/known_peers() hint) and costs the peer a capped slot. No capability, no service change.

This PR's test is the direct behavioural evidence for the "service was already unconditional" bullet: it drives an
inbound peer the pool REFUSES to adopt (because the same identity already holds a dialled slot) and shows it is still
served, by bytes — see the dig.getNetworkInfo round-trip assertion in the test, moved to run before the count/row
assertions specifically so it also serves as the ordering barrier described above.

Not attempted / out of scope

  • The epic's third leg (dig-node#581, REACHED-by-broadcast) is intentionally not touched here — still open and
    blocked, per the test file's own module doc.
  • No production code was changed; this PR is test-only.

Epic dig_ecosystem#3124 fixed an UNDER-count: `connected_peers` omitted
every inbound peer. Two legs shipped -- direct inbound (dig-node#523 /
PR #402) and relayed inbound (dig-node#580 / PR #579) -- each with a
behavioural test asserting COUNTED + still SERVED + RELEASED over a real
mTLS connection.

Neither leg, and no other test in this repo, covers the cross-class case:
ONE identity holding both a dialled outbound slot and an accepted inbound
slot. `connected_peers_json` (crates/dig-node-core/src/peer.rs:370) maps
one JSON row per `connected_pool_peers()` entry with no grouping by
`peer_id`, so if the pool ever held that identity twice, `peer_count()`
would read 2 for one peer and the RPC would emit two rows with the same
`peer_id` and opposite `direction` -- the same defect class the epic
exists for, inverted, and just as invisible to a consumer.

`adopt_inbound_peer_in_pool`'s own doc (peer.rs:3658-3662) lists "a peer
already holding a dialable slot" among the refusals dig-gossip can
return, so the de-duplication is believed to live in dig-gossip. It has
never been exercised from dig-node. This test exercises it.

The test drives real connections throughout: the outbound slot is created
through `adopt_nat_connection` (the single outbound adoption path,
called in production from seams/dig_peer/bootstrap.rs:218 and
seams/dig_peer/pex.rs:652), and the inbound slot by a real mTLS
`dig_nat::connect` against `serve_peer_rpc_listener_with`. Nothing
constructs a pool entry by hand.

NOT YET COMPILED. This commit is a salvage checkpoint written after the
authoring lane was killed mid-flight by a session rate limit, pushed so
the work is durable rather than lost. The API surface it assumes
(`dig_gossip::NatPeerConnection::new`, `dig_nat::PeerConnection`'s field
list, `PeerSession::client`/`server`, `GossipHandle::disconnect`) is
unverified against the pinned dig-gossip v0.32.0, and the
sleep-then-assert ordering in steps 2-3 still needs to be replaced by a
positive signal that the inbound accept path actually ran. A follow-up
commit on this branch compiles it, fixes both, and records the result.

Refs #3124
The first cut of this test slept 300ms and then asserted
`peer_count() == 1`. That assertion passes just as happily when the
inbound accept path has NOT RUN YET as when the inbound adoption was
correctly refused -- it measured a race, not the property. On a loaded
box a real double-count defect would have read green.

Replace the sleep with a happens-before proof. `adopt_inbound_peer_in_pool`
is called at peer.rs:3602, strictly before the accepted session starts
answering RPC at peer.rs:3614, so a successful `dig.getNetworkInfo`
round-trip over the inbound connection proves the server has already
reached and returned from the adoption attempt. Moving that round-trip
ahead of the count and row assertions makes it do double duty: it is
still the "refusing adoption must not refuse service" proof, and it is
now also the ordering barrier the count read needs to be meaningful.

The second sleep, before the post-drop count read, is removed for a
different reason: the inbound leg was refused adoption, so
`release_inbound_pool_slot` ran with `adopted = None` and is a no-op.
There is no pending async decrement for the drop to race against.

Also corrects the surviving-slot comment against the real dig-gossip
behaviour rather than inferring it from dig-node's doc.
`adopt_direct_inbound_handle` REFUSES outright -- it does not supersede
-- whenever the held slot's `dial_addr()` is `Some`: an accepted
connection never supersedes a slot this node can dial. The assertion was
already right; the reasoning behind it is now sourced.

Refs #3124

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

loop-reviewer verdict: PASS

Head SHA reviewed: 08d696c67fe05f2cda176b42f84f086cdb8903a7

Independent verification performed (no code edited, no cargo run beyond the prebuilt test binary):

Scope/blast radius — confirmed test-only: git show --stat on both commits shows exactly one file, crates/dig-node-core/tests/inbound_pool_membership.rs. No production code touched.

Happens-before claim (item 1) — HOLDS. Read peer.rs's accept loop directly: adopt_inbound_peer_in_pool(...).await is called and its result bound to adopted in a match that is awaited to completion, and only THEN does serve_peer_session_from_with(caller, &mut session, responder, pex).await run, sequentially, same task, no spawn in between. So a successful dig.getNetworkInfo round-trip over the accepted session is real proof the adoption attempt already ran and returned before the RPC could have been answered. The barrier is real, not a race in different clothes.

release_inbound_pool_slot no-op claim (item 2) — HOLDS. Read the function: if let (Some(gossip), Some(slot)) = (gossip, adopted) { ... } — when adopted is None (the refused-adoption case this test drives), the body never runs; nothing async is queued. Removing the second sleep is justified.

dig-gossip refuse-not-supersede rule (item 7) — REAL, verbatim. Checked against the pinned rev in Cargo.lock (dig-gossip rev=1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee, matching the checkout the PR cites) — adopt_direct_inbound_handle returns Err(GossipError::ConnectionFiltered(...)) early, with the comment "An accepted connection NEVER supersedes a slot this node can dial ... the #870 rule," whenever held.dial_addr().is_some(). This is a hard refusal (early return before the insert), not a supersede-by-newer-connection. The test's surviving-slot assertion and its sourcing comment are both correct.

Real connections, not hand-built pool entries (item 3) — confirmed: outbound slot goes through gossip_a.adopt_nat_connection(outbound_conn), the same production entry point used by bootstrap.rs/pex.rs; inbound slot goes through a real dig_nat::connect(...) against the listener spawned via serve_peer_rpc_listener_with. loopback_nat_conn only substitutes a tokio::io::duplex for a TCP socket underneath a real dig_nat::PeerConnection/PeerSession — the same pattern already used by this crate's own unit tests, not something newly invented to dodge the accept path. Acceptable.

Row counting (item 6) — confirmed matching_rows filters connected_pool_peers() on *peer_id == pool_id_b and counts, not len() of the whole vec.

Assertion content, not shape (item 5) — confirmed the "still served" assertion checks resp["result"]["served_method"] == "dig.getNetworkInfo", a content check, not a field-presence or length check.

API surface — spot-checked against the pinned dig-gossip source: connected_pool_peers() -> Vec<(PeerId, SocketAddr, bool)>, dial_addr() returns None whenever !is_outbound for a NAT slot, connected_pool_peers_detailed()'s is_outbound field — all match how the test and the PR body use them.

PR body's "nothing new is reachable" claims — each bullet checked against cited lines in peer.rs and dig-gossip; all four are accurate: adoption call passes only pool id/remote/tier/ObservedSession/broadcast_sink=None; dial_addr()==None for a non-outbound NAT slot (state.rs:460-468) so it is absent from dialable_pool_peers(); the broadcast_sink=None reasoning (peer.rs ~3690-3711) is the three-measured-facts comment, present and unchanged; the "service was already unconditional" claim matches adopt_inbound_peer_in_pool's own doc ("a refusal is logged and the connection is served uncounted — the behaviour that shipped before this call existed").

Test execution — ran the prebuilt binary directly (did not invoke cargo; another lane owns the build lock): binary mtime (00:24) is newer than the source file's last edit (23:37), confirming it is built from this head. 4 passed; 0 failed in 13.62s, matching the PR's own evidence block independently.

Teeth check (item 8) — the PR body honestly marks the mutation-revert teeth check as pending ("filled in before this PR leaves draft"), not claimed as done. Per brief, this is acceptable and non-blocking for a DRAFT PR — flagging as a non-blocking note rather than a defect.

Non-blocking findings (posted as review comments below, to be resolved — do NOT block merge)

  1. crates/dig-node-core/tests/inbound_pool_membership.rs — vacuous self-check. The assertion assert_eq!(*b_identity.peer_id().as_bytes(), b_bytes, "the inbound dial must authenticate as the SAME identity as the outbound slot") is checking b_identity's own derived bytes against b_bytes, which was itself derived from b_identity two lines earlier (let b_peer_id = b_identity.peer_id(); let b_bytes = *b_peer_id.as_bytes();). It is true by construction regardless of whether the SERVER's mTLS-derived identity for the inbound connection actually matches B — it does not touch the server side at all. The real proof that the same identity landed in both slots is the later peer_count() == 1 invariant (if the inbound leg authenticated as a different identity, the accepted-direct admission would succeed as a NEW slot and the count would go to 2, which the test does check) — so the property IS still proven, just not by this specific line. Recommend either deleting the misleading self-check or replacing it with something that reads the identity dig-node derived server-side (e.g., via a debug hook / log assertion) if one is cheaply available; otherwise reword the assertion message so it does not overclaim what it tests.
  2. Step numbering gap — comments jump Step 2b -> Step 3 -> Step 5 (no Step 4). Cosmetic only.

Teeth check status

PENDING, per PR body, honestly disclosed as pending rather than falsely claimed done. Not required for this DRAFT PR per brief; should land before the PR leaves draft.

Not reviewed

  • pairing.rs/server.rs (owned by #3191, out of scope, confirmed not in diff).
  • dig-node#581 (REACHED-by-broadcast leg) — correctly out of scope per module doc.
  • Did not run cargo test/cargo build (build lock owned by another lane); ran the prebuilt binary directly instead and confirmed its mtime postdates the source.

Comment thread crates/dig-node-core/tests/inbound_pool_membership.rs Outdated
Comment thread crates/dig-node-core/tests/inbound_pool_membership.rs
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security verdict: PASS

Head SHA audited: 08d696c67fe05f2cda176b42f84f086cdb8903a7

Scope

gh pr diff 591 confirms the diff is exactly one file: crates/dig-node-core/tests/inbound_pool_membership.rs (+197 lines, new test + fixture helper). No production code in the diff. Cross-checked against git show HEAD:crates/dig-node-core/src/peer.rs (the pinned production state this test exercises) and dig-gossip vendored source at the pinned rev 1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee (~/.cargo/git/checkouts/dig-gossip-f31621c2079eb047/1a33916).

Environment note (not a PR finding)

The worktree at D:/worktrees/dig-node-3124 is not clean: crates/dig-node-core/src/peer.rs carries an uncommitted, explicitly-labelled "TEETH-CHECK MUTATION (temporary, MUST be reverted before commit)" that flips the Err arm of adopt_inbound_peer_in_pool to always adopt. Confirmed via git diff HEAD and cross-checked against gh pr diff 591, which shows only the test file changed — this mutation is not part of the PR, it is local scratch matching the PR body's own "teeth-check ... pending" note. Not gating on it, but flagging so nobody mistakes a test run in this dirty tree for evidence about HEAD, and so the author reverts it before requesting review out of draft.

Claim-by-claim audit ("what can an inbound peer now reach that it could not before")

  1. No trust/permission bit set. Confirmed. adopt_inbound_peer_in_pool (peer.rs:3665-3737 at HEAD) passes only pool_id, remote, method, an ObservedSession, broadcast_sink=None into adopt_relayed_inbound_handle/adopt_direct_inbound_handle. Read both in dig-gossip service/gossip_handle.rs:1920-2167: admission is pure accounting (caps, bans, self-connection, dialable-slot refusal) — no capability, credential or authorization flag is stored or read anywhere downstream of adoption.

  2. Non-dialable, no diversity budget. Confirmed. adopt_direct_inbound_handle stores is_outbound: false (gossip_handle.rs:2131), and PeerSlot::dial_addr() (service/state.rs:460) returns None whenever !is_outbound. dialable_pool_peers() filters on this. No outbound-diversity-budget path reads a non-dialable slot as occupying it (gossip_handle.rs:1990-1993, explicit doc). Verified no dig-node caller treats an inbound peer's remote as a dial target: bootstrap.rs/pex.rs's dial paths (should_adopt_dialed_peer, candidate dialing) only ever act on PEX-supplied candidate addresses or explicit bootstrap targets, never on connected_pool_peers()'s reported address as something to dial.

  3. broadcast_sink = None -> no gossip fan-out. Confirmed by reading the three cited reasons (peer.rs:3690-3709) and cross-checking dig-gossip's inbound_tx really is crate-private and set_nat_broadcast_sink really is send-direction-only — matches.

  4. Service was already unconditional; a refusal is served uncounted. Confirmed — the strongest bullet. At peer.rs:3600-3619, adopt_inbound_peer_in_pool's result (Some/None) is captured but serve_peer_session_from_with (line 3614) runs unconditionally regardless of the Err arm. A refused adoption (cap reached, banned, already-dialable-slot held) still gets full L7 RPC service. This means a Sybil flood that exhausts the accepted-inbound caps denies attackers-and-honest-peers-alike only a counted slot and PEX visibility, never RPC service — so pool exhaustion is a visibility/observability degradation, not a service-denial vector.

The identity key

peer_id_from_tls (peer.rs:3826-3846) derives the pool key as SHA-256(SPKI DER) from the leaf certificate rustls verified during the mTLS handshake (same derivation caller_from_tls uses). This is proof-of-possession, not self-reported: an attacker can only ever get the peer_id corresponding to a private key they hold. Squatting another identity's slot would require a SHA-256 preimage attack or actual possession of the target's private key — out of scope, not a defect here.

Refuse-not-supersede rule (#870) is real: adopt_direct_inbound_handle (gossip_handle.rs:2037-2041) refuses outright — no supersede — whenever the held slot's dial_addr() is Some.

Converse checked and is real but not exploitable cross-identity: an accepted connection for peer_id P can supersede an existing non-dialable (inbound) slot for the same peer_id P (gossip_handle.rs:2122-2142, "newest-wins... free"). Since peer_id is handshake-proven (see above), this only lets a peer supersede its own prior inbound session (self-churn, free, no net slot growth) — it cannot be used to evict a different identity's inbound slot without that identity's private key.

One finding worth surfacing — PR-body claim is slightly overbroad, not a live vulnerability

"Nothing" is correct for capability/service (bullets 1-4 above all hold), but is incomplete for visibility: once adopted, an inbound peer's (peer_id, remote_addr) is included in connected_pool_peers() — used by BOTH known_peers() (peer.rs / seams/dig_peer/ping.rs:704-710, the dig.getPeers observability surface) and spawn_pool_feeder (seams/dig_peer/pex.rs:460-468, explicitly "fed from the FULL pool (both directions)... so what we advertise is complete"). Neither call site distinguishes dialable from non-dialable entries (the _outbound flag is discarded at ping.rs:708). So an accepted inbound peer's ephemeral-source-port address becomes a PEX-advertised "first-hand known peer" hint to the rest of the mesh — something it could not do before pool adoption existed. This is bounded (PEX treats every entry as a hint "verified only by a completed mTLS handshake," never a trusted fact, per pex.rs's own module doc) and is pre-existing shipped behaviour from an earlier PR in the #3124 epic, not introduced by this test-only diff — so it does not block this PR.

Recommend: (a) tighten the PR-body sentence to "no service capability" rather than unqualified "Nothing," since a reader citing "nothing is reachable" for the visibility/PEX-amplification question would be relying on a claim this diff does not establish; (b) ticket whether known_peers()/PEX-feed should filter to dial_addr().is_some() only, since advertising a dead ephemeral port as a mesh-wide dial candidate is a small but real cost-asymmetry (one attacker-initiated inbound connect leads to N mesh peers each attempting one wasted dial). Classified defense-in-depth / doc-accuracy, not LIVE — no test-only diff can fix it, and it does not compromise custody, authz, or service.

Test-fixture reachability

loopback_nat_conn's direct construction of dig_nat::PeerConnection { peer_id, .. } with a caller-chosen peer_id is confined to the test file and to peer.rs's own #[cfg(test)] module (peer.rs:3854 boundary; grep confirms the only non-test construction site, peer.rs:2534, is a destructure of a connection already produced by a real handshake, not a construction). Not reachable from production code. Confirmed clean.

Verdict

PASS at 08d696c67fe05f2cda176b42f84f086cdb8903a7. The diff is test-only and correctly locks a real, previously-unmeasured property (#870 refusal rule + counted-once + still-served). Every "nothing new is reachable" bullet checked against dig-gossip source holds for capability/service. One non-blocking finding: the PEX/known_peers visibility surface is a partial exception to "nothing," pre-existing and bounded — recommend a PR-body wording fix and a follow-up ticket, not a merge blocker.

Not covered

  • Did not run cargo test/cargo build (starved build lock, per brief).
  • Did not audit dig-node-service/src/pairing.rs/server.rs (lane #3191, out of scope).
  • Did not chase the PEX/known_peers finding into a live PoC (would require a running two-node mesh); flagged as a ticket candidate instead.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Teeth check ran, and it FAILED — the test survives a mutation it should have caught

Run by the orchestrator after the authoring lane was capped mid-check. Reporting the honest result
rather than the one the PR body predicted.

What was mutated

adopt_inbound_peer_in_pool (crates/dig-node-core/src/peer.rs:3734), production code only, the Err
arm flipped so a refused adoption reports itself as adopted:

Err(e) => {
    tracing::debug!(peer_id = %pool_id, error = %e, "inbound peer not adopted into the pool; serving it uncounted");
    Some(InboundPoolSlot { peer_id: pool_id, superseded })   // was: None
}

Built clean (cargo build --tests -p dig-node-core, 8m10s, exit 0). The mutation was expected to turn
Step 5 red: with adopted = Some(slot) the inbound session now believes it owns a pool slot keyed on
B, so when that session ends release_inbound_pool_slot releases the OUTBOUND slot the inbound leg
never owned — precisely the session-scoped-release defect Step 5 exists to catch.

Result

test a_peer_that_is_both_dialled_and_accepted_is_counted_once ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.39s

Green. The test did not notice.

Why — and it is the same defect class this PR already fixed once, in a place we did not look

Step 5 reads:

drop(inbound_conn);
assert_eq!(gossip_a.peer_count().await, 1, "the inbound session ending must not release the outbound slot it never owned");

There is no barrier between the drop and the read. Dropping the CLIENT side of the connection does
not synchronously run the SERVER's teardown — the server's serve loop has to observe the closed
transport and run its release path, and the assertion fires long before that. So the read returns 1
because the server has not acted yet, not because it correctly declined to release.

That is exactly the failure this PR's commit 08d696c6 removed from Steps 2 and 3: an assertion whose
green depends on the code under test not having run. We fixed it where a sleep made it visible and
left it where the absence of a sleep made it invisible. Commit 08d696c6 even justified removing
Step 5's sleep on the grounds that "the inbound leg was refused adoption, so release_inbound_pool_slot
ran with adopted = None and is a no-op — there is no pending async decrement to race against." That
reasoning is circular: it assumes the very property Step 5 is meant to prove. Under the mutation the
premise is false, the decrement IS pending, and the assertion cannot see it.

What this does and does not invalidate

Steps 2-4 stand. Their barrier is real and independently verified by the review gate: adoption is
awaited to completion before the accepted session answers RPC (same task, no spawn), so the
dig.getNetworkInfo round-trip is genuine proof the adoption attempt already returned. The count
property — a peer both dialled and accepted is counted once — and the still-served property are
soundly proven, and the #870 refuse-not-supersede rule they rest on was confirmed verbatim in
dig-gossip at the pinned rev.

Step 5 does not stand. Its assertion is currently unfalsifiable and should be treated as absent.

What Step 5 needs

A "the count STAYS 1" check with a positive signal that the server processed the disconnect, not an
immediate read. Asserting a non-event needs a bounded window: poll peer_count() for a couple of
seconds and fail if it ever leaves 1. Under the mutation that window catches the erroneous release;
under correct code it stays flat. A positive liveness signal that the server is still cycling its
accept loop after the drop would make it stronger still.

Until that lands, this PR should either fix Step 5 or delete it. Keeping an assertion that cannot fail
is worse than not having one — it reads as coverage.

State

  • Mutation fully reverted; git status --short is empty and the branch is unchanged at
    08d696c67fe05f2cda176b42f84f086cdb8903a7.
  • The mutation patch is kept out of the repo.
  • The test binary currently in target/debug/deps/ was built from the MUTATED source. It is being
    rebuilt clean; do not treat a run against that artifact as evidence about this head.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

The security gate's non-blocking visibility finding is now its own ticket: DIG-Network/dig_ecosystem#3241 — PEX advertises an accepted inbound peer's ephemeral source port as a known-peer hint. It is pre-existing shipped behaviour from an earlier PR in the #3124 epic, not introduced by this diff, so this PR does not carry it. What this PR does carry is the corrected wording: an inbound peer gains no capability and no service, but it does gain visibility.

Step 5 asserted the count stays 1 with a single instant read right after
dropping the inbound session. A teeth check proved this cannot fail: the
server's teardown does not run synchronously with the client-side drop,
so the read fires before the server has acted, regardless of whether the
eventual release is correct. Replace it with a poll across a 2s window
that would catch an erroneous drop to 0 if it ever occurred, and explain
in the comment why a window is required instead of an instant read.

Also drop the same-identity assertion in Step 2 (b_bytes was derived from
b_identity two lines above, so it was true by construction and checked
nothing server-side); the real property is carried by peer_count() == 1.
Renumber the remaining steps (2b -> 3, 3 -> 4) now that there is no gap.

Refs #3124

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

loop-reviewer verdict: PASS

Head SHA reviewed: 850a077fc5af925a7a7cd965418c01abb03e96a0

Re-gate scope: the previous PASS was on 08d696c6; this reviews only the new commit 850a077f
("bound Step 5's non-event check to a real window") plus a re-check that nothing else moved.

1. Does the new Step 5 have teeth?

Yes, independently reasoned through, not taken on the PR body's word. The loop at
inbound_pool_membership.rs:690-700 runs assert_eq!(peer_count(), 1, ...) INSIDE the while body,
once per 50ms tick for up to 2s -- it panics on the FIRST tick where the count is wrong, it does not
sample once at the end and then declare victory. That is the structural difference from the original
defect (a single instant read racing the server's async teardown): here every one of ~40 samples across
the window is a live chance to catch an erroneous drop to 0, so as long as the server's release path
(if it were buggy) runs and settles within 2s of drop(inbound_conn), the loop observes it. 2s is
generous for a local in-process loopback teardown (accept-task wakeup + one release call, no network
RTT) -- the confirmed second teeth-check run (mutation applied, RED at line 691, left: 0, right: 1)
is direct evidence the window is wide enough on this box. A window that only sampled at the end would
be the same defect in a longer coat; this one does not do that.

2. Flakiness in the other direction?

Checked the fixture (inbound_pool_membership.rs:540-705): only two identities are live in this test
(3124-dualslot-server, the RPC responder; 3124-dualslot-peer-b, both the dialled and accepted leg).
No third peer, no background reconnect/ping-interval churn is driven inside this test function, and the
window sits strictly between drop(inbound_conn) and the explicit disconnect(&pool_id_b) call that
follows it -- nothing else in this fixture touches gossip_a's pool during that span. Low flake risk.

3. Did removing the vacuous assertion lose anything?

No. The removed assertion compared b_identity.peer_id().as_bytes() against b_bytes, and b_bytes
is *b_peer_id.as_bytes() where b_peer_id = b_identity.peer_id() two lines above (line 557-558) --
true by construction regardless of server behavior. The replacement comment's claim -- that a real
identity mismatch would admit a SECOND slot and peer_count() would read 2 -- holds: Step 3's
peer_count() == 1 assertion (line 626) is server-side and would fail exactly under that mismatch
scenario. No coverage lost.

4. Renumbering

Grepped the whole file: Step 1 (561), Step 2 (592), Step 3 (607, was "2b"), Step 4 (636, was "3"), Step
5 (670, unchanged) -- sequential, no gaps, no stray old-number references left anywhere (including the
one internal cross-reference at line 686, which correctly says "Step 3").

5. Steps 2-4 unchanged otherwise?

Diffed: only the same-identity assertion removed from Step 2 and the two renumbering comment edits.
The previously-verified happens-before barrier (RPC round-trip before the count read, peer.rs:3602
vs peer.rs:3614) and the #870 refuse-not-supersede check are untouched.

6. PR body honesty

Confirmed via gh api repos/DIG-Network/dig-node/pulls/591 --jq .body: the teeth-check section states
plainly that the first run FAILED and names the root cause (instant read racing async teardown),
then the fix, then a second run that goes RED-then-GREEN with the actual panic line and byte
values. Not sanded into a clean pass. Trust section reads "No capability and no service — but yes to
visibility," carries the visibility bullet citing seams/dig_peer/ping.rs:704-710 and
seams/dig_peer/pex.rs:460-468, and points at DIG-Network/dig_ecosystem#3241 as pre-existing,
out-of-scope behavior -- matches the brief's requirement exactly.

Diff scope

Confirmed test-only: git show 850a077f --stat touches only
crates/dig-node-core/tests/inbound_pool_membership.rs (34 insertions, 16 deletions). No production
code in this commit.

Threads

Both pre-existing inline threads (the vacuous-assertion note and the step-numbering-gap note) are
already isResolved: true and are directly addressed by this commit. No new blocking findings from
this re-gate -- zero threads opened, zero left open.

Not run

Did not run cargo (per brief -- machine-wide build lock, 15 min cost) and did not re-execute the
prebuilt binary; the orchestrator's own build/run at this exact clean head (4 passed, binary mtime
postdates both peer.rs and the test file) is accepted as sufficient build/green evidence and was not
re-derived.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — RE-CONFIRM at new head

Verdict: PASS
Head SHA audited: 850a077fc5af925a7a7cd965418c01abb03e96a0

This is a scoped delta re-confirm over the previous PASS (08d696c6), per that audit's settled findings on dig-gossip adoption semantics (no trust/permission bit, non-dialable/non-diversity, unconditional service, handshake-proven identity, refuse-not-supersede is same-identity-only). Not re-derived here.

1. Diff is test-only — confirmed

gh pr diff 591 --name-only returns exactly one file: crates/dig-node-core/tests/inbound_pool_membership.rs. git diff origin/main...HEAD -- crates/dig-node-core/src/peer.rs is empty (0 lines) — production code is byte-identical to main. Working tree is clean (git status --short empty). The described teeth-check mutation is not present.

The test diff itself: Step 5's non-event check (the inbound session ending must not release the outbound slot it never owned) changed from a single instant assert_eq! right after drop(inbound_conn) to a 2-second polling loop that asserts peer_count() == 1 on every iteration — sound, because it fails immediately (panics) on any transient dip to 0 during the window rather than only at one fixed instant that races the server's own teardown task. A separate tautological assertion in Step 2 (comparing b_bytes to the value it was derived from) was correctly removed and replaced with a comment explaining why peer_count() == 1 is the assertion doing the real work for that property. Ran the compiled binary directly (no cargo): 4/4 green in 15.75s, binary mtime (04:54) postdates both peer.rs (04:40) and the test file (04:27) — consistent with the claimed clean rebuild.

2. Can a production caller release a slot its session did not adopt? — No, and the guard is explicit

release_inbound_pool_slot (peer.rs:3791) only acts if let (Some(gossip), Some(slot)) = (gossip, adopted) — a session whose adopt_inbound_peer_in_pool returned Err (i.e. was refused) gets adopted = None and its release_inbound_pool_slot call is a structural no-op; there is no code path by which that session's drop reaches gossip.disconnect(&slot.peer_id). For a session that did adopt, the superseded flag (set only by the ObservedSession supersede notice) short-circuits release when a newer connection for the same identity has already taken the slot, preventing a stale first session from evicting the live second one.

The None short-circuit is therefore the whole guard, and it holds today for every dig-node-core call path — confirmed by the passing a_peer_that_is_both_dialled_and_accepted_is_counted_once and a_reconnect_does_not_let_the_stale_session_evict_the_live_one tests, which exercise exactly this. Two narrower residual races are documented in the code itself as out of scope of this PR (dig-gossip's own retire_slot-bypassing paths — the ban path, currently unreachable from dig-node per the comment's own call-graph argument, and the departed-peer reaper's narrow reap-then-reconnect window) and tracked upstream at dig-gossip#86. These are pre-existing, not introduced by this diff, and not gated here.

3. PR body accuracy — accurate

The Trust section ("What an inbound peer can now reach that it could not before") now opens with "No capability and no service — but yes to visibility", keeps the four previously-verified bullets, and adds a fifth citing seams/dig_peer/ping.rs:704-710 and seams/dig_peer/pex.rs:460-468, correctly scoped as pre-existing/not-introduced-by-this-diff. DIG-Network/dig_ecosystem#3241 is cross-linked on the PR (as a comment, not inline in the body text) and confirmed to exist and match the description. Minor: the brief asked for the body's fifth bullet itself to carry the #3241 pointer inline; it's cross-linked via comment instead. Not a security finding — the substantive trust claim is correct and the ticket is discoverable from the PR — noting for hygiene only, not blocking.

Scope audited

crates/dig-node-core/tests/inbound_pool_membership.rs (full diff 08d696c6..850a077f), confirmed-empty diff on crates/dig-node-core/src/peer.rs, PR body/comments. Did not re-audit dig-gossip admission internals, PEX/ping.rs visibility behavior (settled, tracked as #3241), or dig-node#581 (deliberately out of scope, broadcast_sink = None).

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Gate verdicts carried forward from 850a077f to 4ae4ad97 — the delta is proven token-identical

Both gates returned PASS naming head 850a077fc5af925a7a7cd965418c01abb03e96a0:

Head then moved to 4ae4ad97edce5fea880178e2500bfba4e6f02166 for style(peer): rustfmt the inbound pool membership test — the one red check on an otherwise green PR.

A verdict names a SHA, so carrying one forward needs evidence, not an assurance. git diff -w is not
that evidence here: the reformat ADDED line breaks (a single method chain split across five lines), and
-w ignores whitespace within a line, not newlines between them — it still reports a diff. So the
stronger check:

$ git show 850a077f:crates/dig-node-core/tests/inbound_pool_membership.rs | tr -d '[:space:]' | sha256sum
3d325d46c4716f8b...
$ git show 4ae4ad97:crates/dig-node-core/tests/inbound_pool_membership.rs | tr -d '[:space:]' | sha256sum
3d325d46c4716f8b...

$ git diff --name-only 850a077f 4ae4ad97
crates/dig-node-core/tests/inbound_pool_membership.rs

With every whitespace character removed the two revisions hash identically, and exactly one file — a
test file — differs at all. No token, string, identifier or assertion changed. Neither the correctness
review (which turns on the happens-before barrier, the bounded-window Step 5, and the row-counting
logic) nor the security audit (which turns on production peer.rs and dig-gossip, untouched by this
branch at any commit) can be affected by a change that alters no token.

Both PASS verdicts therefore hold at 4ae4ad97edce5fea880178e2500bfba4e6f02166. Recording the
reasoning rather than leaving it implicit, so a later reader is not left comparing a verdict against a
SHA it does not name.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 8, 2026 12:30
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.

1 participant