fix(net)!: enforce the loop-free hop chain where chains are built - #3066
fix(net)!: enforce the loop-free hop chain where chains are built#3066kixelated wants to merge 2 commits into
Conversation
`HopPath::validate` ran only on decode, so nothing stopped us constructing and sending an outbound HOP_PATH that a conforming receiver must reject. `Advert::forward` appended our own Hop ID and wrapped the result without revalidating, and the encode path checked nothing, so a `broadcast::Route` whose `hops` already carried a duplicate went out as-is. `draft-lcurley-moq-cluster` requires the receiver to treat that as fatal, so a malformed chain we build does not degrade our own routing: it closes someone else's session. Routes reach the model from more than one place and only the IETF decode path was checked. The moq-lite ingress builds hop chains itself and `OriginList` capped length without rejecting duplicates, so a lite chain could legally carry one and be forwarded to an IETF cluster peer. `broadcast::Route` is public with a public `hops` field, so any in-process producer could attach one. Move the rule into `OriginList`, which is where the state lives: `push` and `TryFrom<Vec<Origin>>` reject a repeated non-zero id, and `Decode` goes through `push` so a chain that revisits a hop never enters the model. `replace_first` refuses a replacement already in the chain for the same reason. That leaves `HopPath::validate` with only the empty-list check, and makes `Advert::forward` fail on a chain it cannot legally extend; `Publisher::select` already advances to the next route on that error, so a route that cannot be advertised is skipped rather than emitted, and stays servable by exact path. moq-lite never stated the rule, which is what let the two dialects disagree. It says it now, in the same words as the cluster draft. BREAKING CHANGE: `OriginList::push` and `TryFrom<Vec<Origin>>` return `InvalidHop` (`TooMany` or `Duplicate`) instead of `TooManyOrigins`, which is removed. `OriginList::replace_first` returns `Result<bool, InvalidHop>`. `Route::with_hop` and `cluster::Advert::forward` carry the new error. Fixes #3049 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`checkHops` guarded only the length, and only on encode. It now applies both wire rules in both directions: a non-zero Hop ID may not appear twice, and `decodeHops` runs the check so a looped chain from a non-conforming sender is rejected rather than routed on. Repeated zeros stay legal, which is all a lite-03 chain is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f7e5e75241
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| The first entry of the reconstructed path identifies the original publisher of the broadcast; it is the fallback content identity when `Epoch` is 0 (see [Routing](#routing)). | ||
| A Hop ID value of 0 means the hop is unknown: either it was never assigned or a relay deliberately withholds it (see [Routing](#routing)). | ||
|
|
||
| A receiver MUST close the stream with a PROTOCOL_VIOLATION if a non-zero Hop ID appears twice in the reconstructed path. |
There was a problem hiding this comment.
Validate the implicit publisher hop before accepting announces
For Lite05+ announcements, a sender can include its non-zero ANNOUNCE_OK.Hop ID once in the explicit list, making it duplicate only after the implicit trailing hop is reconstructed. decodeHops accepts that list, while Rust's start_announce in rs/moq-net/src/lite/subscriber.rs:271-279 merely returns Ok(false) and the JS subscriber in js/net/src/lite/subscriber.ts:277-286 merely drops it, so neither implementation closes the stream as this new MUST requires. Check the reconstructed list after adding the responder ID and propagate a protocol violation.
AGENTS.md reference: AGENTS.md:L186-L191
Useful? React with 👍 / 👎.
| if replacement != Origin::UNKNOWN && self.0.contains(&replacement) { | ||
| return Err(InvalidHop::Duplicate); |
There was a problem hiding this comment.
Check for the target before rejecting a replacement
When target is absent but replacement already occurs in the list, this preflight returns InvalidHop::Duplicate even though the documented operation would make no change and should return Ok(false); it also rejects the valid no-op replace_first(x, x) instead of returning Ok(true). Since this is a public method, callers performing conditional rewrites now receive an error for operations that cannot create a duplicate. Locate the target first and exclude its slot from the duplicate check.
Useful? React with 👍 / 👎.
| A receiver MUST close the stream with a PROTOCOL_VIOLATION if a non-zero Hop ID appears twice in the reconstructed path. | ||
| An announcement that traversed the same relay twice looped, so neither forwarding it nor subscribing through it is safe. | ||
| Duplicate values of 0 are not a violation, since 0 identifies nothing and any number of hops may be unknown. |
There was a problem hiding this comment.
Record the hop-chain rule in the draft changelog
This adds a new fatal protocol condition to the in-progress moq-lite-06 semantics, but the moq-lite-06 section of Appendix A has no corresponding bullet. Add a concise entry so readers can discover that repeated non-zero Hop IDs now require PROTOCOL_VIOLATION.
AGENTS.md reference: drafts/AGENTS.md:L77-L81
Useful? React with 👍 / 👎.
Fixes #3049. Targets
dev: it changes the error type on publishedmoq-netAPI.Root cause
HopPath::validateran only on decode, so nothing stopped us constructing and sending an outbound HOP_PATH that a conforming receiver must reject.Advert::forwardappended our own Hop ID and wrapped the result without revalidating, and the encode path checked nothing, so abroadcast::Routewhosehopsalready carried a duplicate went out as-is.draft-lcurley-moq-clustermakes that the receiver's problem: "A receiver MUST close the session with a PROTOCOL_VIOLATION if the entries do not exactly fillLength, if the list is empty, or if a non-zero Hop ID appears twice." So a malformed chain we build does not degrade our own routing. It closes someone else's session.Routes reach the model from more than one place and only the IETF decode path was checked:
OriginListcapped length without rejecting duplicates, so a lite chain could legally carry one and, once shared through the origin, be forwarded to an IETF cluster peer.broadcast::Routeis public with a publichopsfield, so any in-process producer could attach one.The general shape is that validity was enforced where a chain is parsed rather than where one exists, so every new route source had to remember the rule again.
The fix
Move the rule into
OriginList, which is where the state lives.pushandTryFrom<Vec<Origin>>reject a repeated non-zero id;Decodegoes throughpush, so a looped chain never enters the model rather than being caught later;replace_firstrefuses a replacement already in the chain, since writing one in names it twice by another door.Two things fall out:
HopPath::validateis left with only the empty-list check. The duplicate rule now holds on the outbound path too, not just where a parameter was parsed.Advert::forwardfails on a chain it cannot legally extend, andPublisher::selectalready advances to the next route on that error. So a route that cannot be advertised is skipped rather than emitted, and stays servable by exact path — which is what the issue asked for, since a route that is illegal to advertise may still be fine to serve locally.Origin::UNKNOWNidentifies nothing, so duplicate zeros stay legal. That is the whole of a lite-03 chain, and it is what #3060 changes next.The lite draft never stated the rule
That asymmetry is what let the two dialects disagree, and it was not deliberate.
draft-lcurley-moq-litenow says it in the same words as the cluster draft, alongside the existing Hop Count rule. Validated withjust drafts check.Breaking change
OriginList::pushandTryFrom<Vec<Origin>>returnInvalidHop(TooMany/Duplicate) instead ofTooManyOrigins, which is removed.OriginList::replace_firstreturnsResult<bool, InvalidHop>.Route::with_hopandcluster::Advert::forwardcarry the new error.Outside
moq-netthese are named only by tests andmoq-relay/src/nodes.rs.Tests
origin_list_push_rejects_a_repeatand thetry_fromcase cover both construction paths, including that repeated zeros stay legal.origin_list_replace_firstcovers the rewrite refusing an id already in the chain.forward_refuses_a_chain_it_cannot_extendcovers the outbound half: a chain that already names us, and one with no room left.hop_path_rejects_repeated_hopnow forges its bytes, becauseOriginListno longer lets an invalid chain be built. That is the change working, and the test still proves decode rejects one arriving from a non-conforming sender.Cross-package sync
drafts/draft-lcurley-moq-lite.md: the new receiver MUST (wire spec change).js/net: third commit.checkHopsguarded only length and only on encode; it now applies both rules in both directions, with a test that patches a valid encoding into a looped one.just test smoke-fullpasses: all 21 publisher/subscriber pairs across rust, python, js, js-native-node, js-native-bun, c, and gst.Follow-up
#3060 extends the same rule to ban 0 from chains entirely, which is what closes #3053. This is its prerequisite: it is what makes an invalid chain unbuildable rather than something each ingress path remembers.
🤖 Generated with Claude Code
(written by Claude Opus 5)