Conversation
`4bae6edd` (the hotfix posted in n0-computer#738) and n0-computer#783 both patch individual `==`/`!=` comparison sites in `Connection::early_discard_packet` to canonicalize IPv4-mapped-IPv6 addresses (`::ffff:a.b.c.d`) before comparing, since dual-stack sockets can report the same peer in either form depending on the code path. This closes the same class of bug at every site where `FourTuple`'s `PartialEq`/`Hash` or a raw `remote` comparison is used, not just the two `early_discard_packet` sites. An earlier version of this branch canonicalized `remote`/`local_ip` inside `FourTuple::new()`, mutating the stored/emitted address so every downstream comparison would see one canonical form automatically. That had two real problems, both found by running the existing test suite: - It changes the address family of what gets handed to the OS for sending (`Transmit::destination` becomes plain IPv4 where it used to be IPv4-mapped IPv6 on dual-stack sockets). This crate's CI is Linux-only, so whether that's safe on Windows/macOS was unverified. It also broke the `noq` crate's `normalize_network_path` IPv6 autodetection, silently narrowing genuine mixed v4/v6 multipath. - It broke the test harness's own simulated network routing (21 proptest regressions), because `tests/util.rs` compares raw `SocketAddr`s captured before canonicalization against ones captured after. This version instead canonicalizes only for comparison/hashing, never for storage: `FourTuple::new()` is unchanged from `main`, and `PartialEq`/`Hash` are hand-written to canonicalize via a `remote_key()` helper — `(ip.to_canonical(), port, scope_id)`, keeping `scope_id` for addresses that stay IPv6 (a mapped address canonicalizes to plain IPv4 and has no scope). Dropping `scope_id` unconditionally was tried first and collapsed two genuinely different link-local interfaces into one path for equality/hashing — the same class of bug this fix is meant to prevent, just for a different field. Covered by a new regression test. `is_probably_same_path` and the other raw `remote == remote` comparisons that don't go through `FourTuple`'s whole-struct equality (`early_discard_packet`, PATH_CHALLENGE on-path detection, OBSERVED_ADDRESS matching, the peer-migration trigger, and `PathResponses::push`'s dedup) now all route through a shared `same_remote()` helper instead of open-coding the comparison. Includes and builds on the regression test from the `noq-738` branch (`noq-proto/src/tests/multipath.rs::open_path_with_explicit_local_ip`), adapted to build its `ManyToManyRouting` via `add_client_route`/ `add_server_route` instead of `from_routes` (which now rejects the duplicate `server_addr` this test intentionally uses, an invariant added by n0-computer#721 after the test was originally written), and to construct its `FourTuple` via `FourTuple::new()` rather than a struct literal. Testing: - Two new unit tests in `noq-proto/src/lib.rs` (`four_tuple_tests`): `four_tuple_eq_ignores_mapped_v4_representation` (the n0-computer#738 case) and `four_tuple_eq_preserves_link_local_scope_id` (regression test for the scope_id issue found during review). - `cargo test -p noq-proto`: 390 passed, 0 failed. - `cargo test -p noq --lib`: 32 passed, 3 ignored, 0 failed, including `echo_dualstack` (which the construction-time version of this fix broke). - Verified on a real Android device (WiFi + cellular): `Secondary` established, then `PhysicalWifi`/`PhysicalCellular` both validate on the first attempt instead of retrying 3x and getting abandoned. Co-authored-by: Philipp Krüger <philipp.krueger1@gmail.com>
c011f8f to
a75a9be
Compare
remote comparisons were already unified behind same_remote() in the previous commit, but local_ip comparisons were still open-coded three different ways (early_discard_packet, the passive-migration log check, and FourTuple's own PartialEq/is_probably_same_path). Same rationale as same_remote(): a single choke point means a future change can't fix some of these and miss the rest. No behavior change -- cargo test -p noq-proto: 390 passed, 0 failed. cargo test -p noq --lib: 32 passed, 3 ignored, 0 failed.
Adds direct tests for is_probably_same_path (previously only exercised transitively via FourTuple's PartialEq, but it's a separate implementation with its own asymmetric local_ip rule, so it can't just delegate to ==) plus two gaps in the scope_id matrix that the earlier scope_id fix didn't lock in: - four_tuple_eq_zeroes_scope_id_for_global_v6: the inverse of the link-local test -- a *global* IPv6 address with a bogus/differing scope_id must still compare equal, since FourTuple::new() zeroes scope_id for anything that isn't link-local/multicast. Guards against a future "fix" that makes remote_key() preserve scope_id unconditionally. - four_tuple_eq_preserves_multicast_scope_id: mirrors the link-local test for the other half of FourTuple::new()'s requires_scope_id condition. cargo test -p noq-proto: 394 passed, 0 failed. cargo test -p noq --lib: 32 passed, 3 ignored, 0 failed.
… comment It claimed to guard against a future remote_key() change preserving scope_id unconditionally, but remote_key() never sees a nonzero scope_id for a global address in the first place -- FourTuple::new() already zeroes it before storage. Verified by mutation (opus review): removing remote_key()'s is_ipv6() guard still passes all 6 four_tuple_tests. The test itself is still worth keeping (it locks in new()'s zeroing), just the stated rationale was wrong. Also tightened the two is_probably_same_path tests' doc comments: they test the remote-canonicalization half only (both sides have local_ip: None), not the asymmetric local_ip rule the previous wording implied.
divagant-martian
left a comment
There was a problem hiding this comment.
I'm glad 4bae6ed served as a smell test in the right direction. While I prefer the intent of this PR, the code is a bit all over the place.
Please add FourTuple::same_remote(other: &FourTuple) and same for same_local_ip, and try to reduce he use of free standing functions
| /// noq#738: canonicalizes an [`IpAddr`] for comparison purposes (see [`FourTuple`]'s | ||
| /// docs) without changing any stored/emitted address. | ||
| fn canonical_ip(ip: IpAddr) -> IpAddr { | ||
| ip.to_canonical() | ||
| } |
There was a problem hiding this comment.
what's the point of adding a function like this? Please you -the human- self-review this
| /// multicast remotes (see the comment there) — two different link-local interfaces | ||
| /// must not compare equal just because their canonicalized IP matches. A mapped | ||
| /// address canonicalizes to V4, which has no scope, so it gets 0. | ||
| pub(crate) fn remote_key(addr: SocketAddr) -> (IpAddr, u16, u32) { |
There was a problem hiding this comment.
omething like canonicalized_parts would make more sense. This is not intrinsic to being "remote" or "local", also, do not add pub(crate) unless necessary
| /// mapped-IPv4-vs-plain-IPv4 representation difference that motivated this fix. Used | ||
| /// everywhere a raw `remote == remote` comparison would otherwise bypass | ||
| /// [`FourTuple`]'s canonicalizing `PartialEq`. | ||
| pub(crate) fn same_remote(a: SocketAddr, b: SocketAddr) -> bool { |
There was a problem hiding this comment.
make this a fn of FourTuple
| /// noq#738: same rationale as [`same_remote`], for `local_ip: Option<IpAddr>` | ||
| /// comparisons. Used everywhere a raw `local_ip == local_ip` comparison would | ||
| /// otherwise bypass [`FourTuple`]'s canonicalizing `PartialEq`. | ||
| pub(crate) fn same_local_ip(a: Option<IpAddr>, b: Option<IpAddr>) -> bool { |
There was a problem hiding this comment.
same, make part of FourTuple
…hods Per review feedback on n0-computer#784: replace the free-standing same_remote(a, b)/ same_local_ip(a, b) helpers (which took raw SocketAddr/Option<IpAddr> and required callers to unpack FourTuple fields by hand) with FourTuple::same_remote(&self, other: &Self) and FourTuple::same_local_ip(&self, other: &Self). All call sites already had two FourTuples in scope, so this is a straightforward substitution; PartialEq/is_probably_same_path now delegate to the same methods instead of duplicating the comparison logic.
Matches this crate's existing naming convention for two-instance boolean predicates (FourTuple::is_probably_same_path, Connection::is_same_connection in noq/src/connection.rs), rather than the bare same_x form.
- Fold canonical_ip/remote_key free functions into private FourTuple methods (remote_key/local_ip_key), consistent with turning same_remote/same_local_ip into methods in the previous commit — they're as much an implementation detail of FourTuple's fields as the comparison predicates that use them. - Move the hand-written PartialEq/Eq/Hash impls to after the inherent impl FourTuple block (matching this file's existing struct -> inherent impl -> trait impl ordering for Side/Dir), so is_same_remote/ is_same_local_ip are defined before they're referenced. - Drop the repeated "noq#738: same rationale as FourTuple's PartialEq" comments at each call site now that the method names themselves carry that context; kept the one comment that explains a non-obvious invariant (network_path.local_ip being guaranteed Some(new_local_ip)).
_key already means something else in this crate (HmacKey/HandshakeTokenKey in config/mod.rs, cid_generator::from_key) -- reusing it here for "value used for comparison purposes" invited confusion with those literal cryptographic keys. canonical_remote/canonical_local_ip instead matches this crate's own existing vocabulary for this exact kind of operation (n0_nat_traversal::CanonicalIpPort::as_canonical_addr, IpAddr::to_canonical which these methods wrap).
Independent review flagged two gaps in the existing regression coverage: - All existing four_tuple_tests used FourTuple::from_remote (local_ip: None), so canonical_local_ip/is_same_local_ip's Some(..) path -- the half of the original bug report that open_path()'s local_ip argument actually depends on -- was never exercised. Added four_tuple_eq_ignores_mapped_v4_representation_for_local_ip and is_probably_same_path_ignores_mapped_v4_representation_for_local_ip to close that. - None of the 5 call-site fixes in connection/mod.rs and connection/paths.rs were pinned by any test (verified: reverting all 5 to their pre-fix raw comparisons still passed the full suite). PathResponses::push is the one call site cheaply testable in isolation (pub(crate), no live Connection/handshake needed) -- added push_coalesces_mapped_v4_representation. The other 4 call sites are on private Connection methods only reachable through full E2E simulation; covering those would need new routing-test infrastructure to simulate a representation mismatch, which is out of scope here (already verified manually on real hardware per the PR description). All three new tests verified to fail without the corresponding fix.
The four_tuple_tests/PathResponses test comments had grown to 4-9 lines each, several just restating what the test's own name already says. Existing regression tests elsewhere in this crate (e.g. regression_path_validation_stale_local_after_passive_migration in tests/mod.rs) keep this to 2-3 lines -- summary + the one non-obvious reason (history, cross-reference, or asymmetry) it exists. Trimmed to match; no content with actual information value was removed.
|
Pushed a series of follow-ups addressing the review above and a subsequent independent pass. Summary (superseding my earlier per-commit comments, which I've consolidated into this one): API shape — per @divagant-martian's request, the comparison logic is now impl FourTuple {
pub(crate) fn is_same_remote(&self, other: &Self) -> bool { ... }
pub(crate) fn is_same_local_ip(&self, other: &Self) -> bool { ... }
}Named The underlying canonicalization (previously free functions Structure — the hand-written Test coverage — an independent review pass found two gaps, now closed:
Comments — trimmed the per-call-site "canonicalize both sides" comments and some over-long test doc comments down to match this file's usual brevity (2-3 lines, only the non-obvious why), now that the method names themselves carry most of that context. Current: |
There was a problem hiding this comment.
This looks close to done imo. Please reflow comments to 100 width and cleanup the PR description. The description should use the pull request template and please make it short and concise. Your agent can simplify this for you.
I'd like @matheus23 to give this a look as well
| // `network_path.local_ip` is `Some(new_local_ip)` per the `let Some` guard | ||
| // above, so comparing against `network_path` itself covers the | ||
| // `new_local_ip` side. |
matheus23
left a comment
There was a problem hiding this comment.
AFAIU, the issue #738 shows we might have an issue with our address family normalization in noq-proto.
The invariant we're trying to uphold inside of noq-proto is that IP addrs are normalized to the socket family of the socket we're bound to. You can see how we use noq_proto::Connection::is_ipv6 in a bunch of places to ensure we're normalizing IP addresses we get from "the outside" (of noq-proto), e.g. via the wire or from API calls and ensure that we normalize our IP addresses to the appropriate socket family.
Admittedly, the way we do this at the moment is a bit hacky. We've thought about cleaning it up by moving everything to ipv6 (and ipv6-mapped ipv4) in the past: #342, but this PR would only add to the complexity by adding yet another hack papering over places where we've messed up the canonicalization in a way that is different and not applied uniquely across noq-proto yet again.
Additionally, changing Eq for FourTuple has potentially far-reaching effects not quite evident from just the diff, and especially because it's exposed, it might even break downstream code that uses FourTuple (e.g. iroh). We shouldn't change FourTuple from structural equality to another type without very good reasons for doing so.
I propose instead we should:
- clearly identify where we've messed up the addr family normalization
- fix the place where non-normalized addrs have leaked from the outside to the inside.
| /// Regression test for issue #738. | ||
| /// | ||
| /// When a client opens a new path with an explicit `local_ip` set in the | ||
| /// [`FourTuple`], the path should validate successfully. On real devices the | ||
| /// `PATH_RESPONSE` was never matched to the outstanding `PATH_CHALLENGE` on | ||
| /// such paths, causing them to be abandoned with | ||
| /// [`PathAbandonReason::ValidationFailed`]. | ||
| /// | ||
| /// This test sets up a routing table where the client has a second interface | ||
| /// that can reach the *same* server address as the initial connection, and | ||
| /// opens a path on that second interface with an explicit `local_ip`. | ||
| /// | ||
| /// See <https://github.com/n0-computer/noq/issues/738> | ||
| #[test] | ||
| fn open_path_with_explicit_local_ip() -> TestResult { |
There was a problem hiding this comment.
Running this test on main doesn't actually produce a failure - thus this is not a valid regression test.
I know this is my own code copied into this PR (from 643a473), but it turns out that code doesn't seem to fail, unlike what I initially thought.
Please provide a proper regression test exercises the situation that you're seeing on Android in practice - that would be immensely helpful.
There was a problem hiding this comment.
@matheus23 is 💯 right here. This test should use two client addresses, one mapped, one canonicalized, and routes such that client can talk to the server using the mapped address, but the server can only talk to the canonicalized one back.
Also, we've mentioned this a couple times: Please provide full logs of the failures.
It's a shame I missed this big hole in the PR. Without proper proof of the bug we will not be able to accept it
Meant to be only a comment, not approval
@matheus23 pointed out that open_path_with_explicit_local_ip (added in an earlier revision of this branch) uses two structurally distinct IPv6 addresses for its two client interfaces, so it never exercises the actual mapped-vs-plain-IPv4 representation mismatch n0-computer#738 is about, and does not fail on unpatched main. Removing it rather than leaving a test that doesn't test what it claims to.
@matheus23 flagged that changing FourTuple's own structural equality has far-reaching effects not evident from the diff alone -- it's public API, used directly as a HashMap/HashSet key in a few other places (e.g. the noq crate's Endpoint routing table), and this crate's downstream consumers (e.g. iroh) may rely on it meaning byte-for-byte equality. Keep #[derive(Hash, Eq, PartialEq, Copy, Clone)] as on main, and keep using is_same_remote()/is_same_local_ip() only at the specific comparison call sites (early_discard_packet, PATH_CHALLENGE-on-active-path detection, OBSERVED_ADDR matching, local_ip/peer migration detection, PathResponses::push) that actually need to ignore the mapped-vs-plain-IPv4 representation difference -- unchanged from the previous revision of this branch and already verified against the reported failure on real Android hardware. Rewrite the unit tests accordingly: they now assert on is_same_remote()/is_same_local_ip() directly instead of on FourTuple's own == and HashSet dedup behavior.
@matheus23 asked us to identify where non-normalized addresses leak from the outside (caller/wire) to the inside instead of patching comparisons. normalize_network_path() is exactly that boundary for open_path()/ open_path_ensure(): it already canonicalizes remote via ensure_ipv6() when the connection is dual-stack, but passed local_ip through unchanged, so an application-supplied plain IPv4 local_ip (e.g. read from the OS's network interface list) could end up stored in a different representation than remote on the same FourTuple. This closes that specific inconsistency. Unlike the noq-proto-level comparison fixes (verified against the real reported failure on Android hardware with physical Wi-Fi/cellular interfaces), I could not reproduce noq#738's actual symptom with this alone in a loopback-only environment to confirm it's part of the root cause on its own -- see the new test's doc comment for what was and wasn't reproducible here. Included regardless because it fixes a real, independently-motivated inconsistency with how remote is already handled in this same function.
|
Thanks both for the detailed reviews. Pushed a rework addressing both: @matheus23's structural-equality concern: dropped the @matheus23's regression-test concern: agreed, Following through on your "identify where non-normalized addrs leak from outside to inside" suggestion: found one concrete instance — Flagging honestly: I could not reproduce #738's actual reported symptom (path abandoned with
|
Following up on Option 1: instead of the previous per-comparison-site is_same_local_ip() hack, normalize local_ip eagerly at every point a FourTuple enters noq-proto::Connection's owned state from outside -- Connection::new (the initial path), open_path/open_path_ensure (the public proto-level APIs, so embedders bypassing the noq wrapper crate get the invariant too), incoming datagram handling, and the server's first-packet handling. This means every local_ip comparison inside Connection can now be a plain == -- delete is_same_local_ip() and update the two call sites (early_discard_packet's migration guard and the local_ip-migration check) accordingly. remote is deliberately left untouched (is_same_remote() stays): unlike local_ip, PathData.network_path.remote also drives Transmit::destination for actual OS sends, so normalizing it here risks the same problem the very first (abandoned) attempt at this fix hit. That needs a separate, larger PathData network_path/transmit_path split, which will be proposed as an independent follow-up PR for maintainers to weigh in on rather than folded into this one. Added noq-proto/src/tests/multipath.rs coverage that opens a path via Connection::open_path/open_path_ensure directly (bypassing the noq wrapper) with a mapped-vs-plain-IPv4 local_ip and confirms it's recognized as the same path either way -- this is the part that was previously only exercised through the noq wrapper crate's own normalize_network_path, not at the noq-proto level itself.
…scan is_ipv6() previously re-derived the connection's address family on every call from whatever paths currently happen to exist (paths.values().any(...)). Since local_ip normalization now depends on this predicate, a connection whose only IPv6-family path gets abandoned could have is_ipv6() flip back to false mid-connection, causing already-normalized (mapped) local_ips to stop matching freshly-observed ones -- a variant of the exact bug n0-computer#738 is about, self-inflicted. Fix it at Connection::new() from the initial path's remote family and never recompute it afterward. This also collapses two independently derived notions of the connection's address family that existed in this codebase (noq-proto's own .any()-based one, and the noq wrapper crate's separate .next()-based one in normalize_network_path) into a single source of truth: the noq wrapper now just calls conn.is_ipv6() directly. Known limitation, documented on the new field: this does not adapt to Endpoint::rebind() changing the underlying socket's family mid-connection -- noq-proto::Connection currently has no signal for that at all (its own ConnectionEventInner has no rebind concept, and the noq wrapper's ConnectionEvent::Rebind never reaches here). Wiring that up is a real gap but is out of scope for this fix; flagging it for maintainers rather than silently leaving it unmentioned. Also correct the doc comment on local_ip normalization: it previously claimed this doesn't change what's handed to the OS, which is wrong -- local_ip becomes Transmit::src_ip, and noq-udp's unix backend sends a different control message (IP_PKTINFO vs IPV6_PKTINFO) depending on whether it's IpAddr::V4 or IpAddr::V6. Reframed as: this is the correct behavior for a dual-stack socket, and the cmsg-family mismatch this fixes is a plausible (unverified without real multi-interface hardware) explanation for n0-computer#738's actual root cause, not just a comparison-time cosmetic issue. Add the regression test divagant-martian asked for: a ManyToManyRouting setup where the client's local interface is only routable in its IPv4-mapped-IPv6 form, opening a path with the plain-IPv4 representation of that same address. Verified this fails on the pre-fix code (the test harness's own routing simulation drops every packet with 'no route from client to server', since the source address representation doesn't match any route) and passes with the fix.
|
Pushed two more rounds since my last comment: local_ip normalization moved to actual Fixed Also corrected a doc comment that wrongly claimed normalizing Added the
|
|
Opened #787 — the alternative approach mentioned above (normalizing |
…s dev-flow I had only run a narrower cargo test/clippy subset before, not the project's actual Makefile.toml dev-flow (format-check, check, clippy, doc, test, proptests-extralight, all workspace-wide with --all-features). Running the real thing surfaced two genuine issues the narrower checks missed: - FourTuple's public type docs linked to Self::is_same_remote, which is private -- this resolves under --document-private-items (what I'd tested with) but is a broken intra-doc link in a normal doc build (e.g. docs.rs). Replaced the doc link with plain text, and while here, updated the stale reference to a hypothetical 'PathData network_path/transmit_path split' to instead point at noq#787, which is the actual follow-up that now exists. - normalize_network_path()'s doc comment linked to IpAddr::to_ipv6_mapped, which doesn't exist -- that method is on Ipv4Addr, not IpAddr. Fixed the link target. Also re-ran cargo fmt with this project's actual rustfmt config (comment_width=100, wrap_comments=true, from Makefile.toml) instead of plain defaults, which reflowed one over-width comment line.
Extends open_path_normalizes_ipv4_mapped_addrs_to_connection_family to cover the exact shape divagant-martian asked for on the sibling PR (n0-computer#784): a client address reachable in one representation on the way out but not the other on the way back. Investigated first (see PR comment for the full writeup) whether there's a noq-proto code path where the server independently re-derives a different reply destination than the literal remote it just received -- found none; every send path (build_transmit, PathResponses, migration) uses the stored/observed FourTuple as-is. The actual bug is upstream of that: without this branch's fix, calling open_path/open_path_ensure with a plain-IPv4 FourTuple on an already-IPv6-family connection stores that mismatched representation in PathData.network_path itself, and every later send for that path inherits it. The route table now permits the outbound (client-to-server) leg via the plain-IPv4 representation but only the mapped representation on the return leg. Verified by temporarily reverting the fix: the test fails with 'no route from server to client for packet packet.destination=1.1.1.99:4433' (server tries to reply to the unnormalized plain-IPv4 remote it stored); restoring the fix, the stored path is normalized to the mapped form up front and the reply routes successfully. Raw logs from both runs are in the PR comment.
|
Following up on the asymmetric-routing/full-logs ask: investigated whether there's a Since this PR deliberately leaves |
The explanatory comment about why comparing against network_path itself covers the new_local_ip side was flagged 'fine to remove' in review; it never got dropped in the subsequent rewrites of this function.
Compared against how the pre-existing codebase actually references issues in comments (e.g. 'PATH_ABANDON on the abandoned path itself when no other path exists (n0-computer#509).', 'Recover storage from these by compacting (n0-computer#700)') -- the convention is a bare '(#NNN)' at the end of the relevant sentence, not a 'noqNNN:' prefix at the start. Reworded every doc/comment this PR chain added that used the latter style to match. Also fixed two doc comments that still referenced a hypothetical 'PathData network_path/transmit_path split' follow-up instead of the actual n0-computer#787 that now exists, and a stray duplicated blank doc line.
into this PR Adds the remaining piece from the sibling exploration in n0-computer#787: caller- supplied `remote` addresses passed to `open_path`/`open_path_ensure` are now normalized to the connection's established socket family too, the same way `local_ip` already was. This is scoped narrowly, exactly as n0-computer#787 worked out: `Connection::new` (path 0, which establishes the family in the first place), `handle_event`'s incoming datagram arm, and `handle_first_packet` are all left untouched for `remote` -- their remote addresses come from the OS's own recvfrom-equivalent, which for a single bound socket already reports peer addresses in one consistent representation. With every FourTuple that ever enters Connection-owned state now consistently normalized -- both remote (this commit) and local_ip (already normalized at all five entry points) -- structural equality just works everywhere. Delete FourTuple::is_same_remote()/ canonical_remote() entirely and revert every comparison site (early_discard_packet, PATH_CHALLENGE-on-active-path detection, OBSERVED_ADDR matching, the peer-migration trigger, PathResponses::push, is_probably_same_path) to plain ==/!=. There is no comparison-time canonicalization hack left anywhere in noq-proto. Added regression coverage exercised directly through Connection::open_path/open_path_ensure (bypassing the noq wrapper): normalization for both a dual-stack and an IPv4-only connection, and an asymmetric-routing test (ManyToManyRouting with the outbound leg reachable via plain IPv4 but the return leg only via mapped IPv4-in-IPv6) proving this actually closes a real gap -- verified by temporarily reverting the remote normalization and confirming failure first (a path that times out and never validates), then confirming it passes restored. This is the last piece n0-computer#787 explored separately; consolidating it here so n0-computer#784 is the complete fix and n0-computer#787 can close as superseded.
I'm having a hard time reading what you're writing here. Are you saying you cannot reproduec #738 anymore "using this fix", so using this PR? I suspect what you're trying to say is that you can't come up with a regression test that matches what you're seeing in practice. Reading a bunch of LLM output is very tiring. I'll be ignoring this PR for a bit to focus on other work. |
Description
Closes #738.
On a dual-stack socket,
FourTuple { remote, local_ip }can represent the same real peer/interface as either plain IPv4 or IPv4-mapped-IPv6, and comparing these representations wrongly reports them as different, breaking multipath path validation (confirmed on real Android hardware with physical Wi-Fi/cellular interfaces --PATH_RESPONSEnever matched to itsPATH_CHALLENGE, path abandoned withValidationFailed).Per @matheus23's review: rather than patching comparison call sites, this normalizes both
remoteandlocal_ipto the connection's established socket family at every point aFourTupleentersConnection-owned state from outside:local_ipis normalized atConnection::new(path 0),open_path/open_path_ensure, incoming datagram handling, and serverhandle_first_packet-- five entry points.remoteis normalized atopen_path/open_path_ensureonly.Connection::new, incoming datagram handling, andhandle_first_packetare deliberately left untouched forremote:Connection::new's path 0 is what establishes the connection's family in the first place, and incoming datagrams'remotecomes from the OS's own receive path, which for a single bound socket already reports peer addresses in one consistent representation. Only caller-suppliedremoteviaopen_path/open_path_ensurecan arrive in either representation.With every
FourTuplethat ever entersConnection-owned state now consistently normalized,FourTuplekeeps#[derive(Hash, Eq, PartialEq, Copy, Clone)]unchanged (plain structural equality, no risk to downstream consumers like iroh that use it as aHashMap/HashSetkey), and there is no comparison-time canonicalization hack anywhere innoq-proto-- every comparison is a plain==/!=.is_ipv6()is fixed once atConnection::new()from the initial path's remote family, instead of being re-derived live from current path membership (which could otherwise drift mid-connection). Known gap, documented rather than silently left unhandled:Endpoint::rebind()changing the underlying socket's address family mid-connection isn't signaled tonoq-proto::Connectiontoday.Breaking Changes
None.
Connection::is_ipv6()is nowpubinstead ofpub(crate)(needed so thenoqwrapper can delegate to it as the single source of truth for this predicate), which is additive.Notes & open questions
Endpoint::rebind()not being signaled intonoq-proto::Connection(see above) is a real gap, but wiring up a new cross-crate signal for it felt like a separate, bigger change than this fix warrants -- open to doing that here if you'd rather not leave it as a follow-up.Change checklist
cargo make(format-check, check, clippy, doc, test, proptests-extralight, workspace-wide with--all-features): clean.cargo test -p noq-proto(399 passed),cargo test -p noq(33 passed).