Repository navigation
SyncEndpointUrl accepts endpoints that later panic or fail Display round-tripping #323
Description
Activity
Assessed against
mainand theurlversion pinned inCargo.lock(2.5.8). Source review only — there's no Rust toolchain in my environment, so nothing below was executed; it's read from the repo source and theurlcrate's published source.Two of the three cases are real. The third isn't, and PR #330 has already built a fix for it. There are also two further cases of exactly the same class that the issue misses — one of which breaks the round-trip property for the majority of real configs, including the endpoint this repo ships as a default.
Case 1 — port 65535: confirmed, and the reasoning is stronger than stated
Accurate, and you quoted the right expression. Worth adding why it's a clean crash rather than something worse:
checked_add(1).expect("port overflow")is an unconditional panic, independent of build profile. This workspace's[profile.release]setslto,opt-level,codegen-units, andstripbut notoverflow-checks— so had that line been a plainhttp_port + 1, release builds would have silently wrapped to port 0 and produced a follower quietly dialling the wrong port. Whoever wrotechecked_addalready saw this; the bug is that the check fires at call time instead of parse time.Blast radius is narrow — it needs RPC-sync/follow mode plus an explicitly configured
:65535endpoint — but it's the only case here that can take a node down, which is worth separating from the rest.Case 2 — path/query lost by
Display: confirmedCorrect as written.
Displayrebuilt the HTTP side from scheme + host + port only, so/api/v1?key=valuewas genuinely discarded.Case 3 — IPv6 brackets: I believe this one is incorrect
The claim is that
host_str()returns::1andDisplaywrites it unbracketed.url2.5.8's own documentation forUrl::host_strstates the opposite:Return the string representation of the host (domain or IP address) for this URL, if any. […] IPv6 addresses are given between
[and]brackets.The same holds one level down:
Host'sDisplayimpl inhost.rsexplicitly writes"[", thenwrite_ipv6(addr), then"]". So the existing code should already emithttp://[::1]:8545and the authority should already be valid.This matters because #330 implemented a fix for it — a
host_for_displayhelper that swapsurl's WHATWG serializer for std'sIpv6AddrDisplay. Those differ on IPv4-mapped addresses (::ffff:127.0.0.1vs::ffff:7f00:1), so the remedy for a non-existent canonicalisation bug introduces a real one. I've left the detail on that PR.Quickest way to settle it definitively: add
http://[::1]:8545,ws=8546as aDisplaytest against unmodifiedmain. If it already passes, the case can be struck.Missing case A — userinfo is silently dropped
Same class as case 2, and more likely to be hit in practice than IPv6.
validate_http_schemeonly checks the scheme, so credentials parse fine, butDisplaynever emits them:let endpoint: SyncEndpointUrl = "http://user:pass@localhost:8545,ws=8546".parse()?; // Display -> "http://localhost:8545,ws=8546" (user:pass@ gone) let reparsed: SyncEndpointUrl = endpoint.to_string().parse()?; assert_eq!(endpoint, reparsed); // fails
Basic-auth RPC endpoints are a common shape for hosted providers, so this is a realistic follow endpoint. Note the flip side: for a value that gets logged, dropping credentials is arguably the desirable behaviour — which is a good argument for deciding deliberately whether
Displayis a serialisation format or a log format, rather than letting it be an accident of which components got hand-copied.Missing case B — the derived-vs-explicit distinction is erased, so round-trip fails for most configs
This is the one I'd rank highest, because it defeats the issue's own stated expectation in the common case.
Displaywrites the,<scheme>=segment unconditionally, even whenwsisNone. SincePartialEqis derived over{ http: Url, ws: Option<Url> },None != Some(_):let endpoint: SyncEndpointUrl = "http://localhost:8545".parse()?; // ws: None // Display -> "http://localhost:8545,ws=8546" let reparsed: SyncEndpointUrl = endpoint.to_string().parse()?; // ws: Some(ws://localhost:8546/) assert_eq!(endpoint, reparsed); // fails
So
parse -> Display -> parseis not identity for any endpoint without an explicit WS override. That includes both values indefault_rpc_sync_endpoint(config.rs:198) —http://localhost:8545for localdev andhttps://rpc.testnet.arc.io/for testnet. The shipped defaults fail the invariant.The reason nobody noticed: every existing round-trip assertion uses an input that already carries an explicit override (
,wss=rpc.testnet.example.com/websocket), and #330's two new ones do too (,wss=ws.example.com/websocketand,ws=8546).display_http_onlyanddisplay_https_onlycover the derived case but only assert the output string — they never reparse. The invariant is untested in exactly the place it breaks.In fairness this is semantically benign:
ws: Noneandws: Some(derived)yield identicalwebsocket()results, andDisplayis idempotent from its first output onward. But it's structurally unequal, which is what the issue asks for.On the proposed fix — your instinct was better than the implementation
The issue proposes "use URL-aware serialization," which is the right call and would have closed cases 2, 3, and A in one stroke:
self.http.as_str()already emits userinfo, path, query, fragment, and bracketed canonical IPv6, becauseUrlis its own serialiser. #330 instead hand-assembles the components, which is why it needed a bespoke IPv6 helper and still drops userinfo.A complete fix is roughly two decisions:
- HTTP side: emit
self.http.as_str()rather than rebuilding it. Real cost to weigh:as_str()omits default ports, sohttps://rpc.example.com:443/...becomeshttps://rpc.example.com/.... Both reparse equal, but several existing assertions pin the:443form and would need updating. - WS side: emit the
,<scheme>=segment only whenself.ws.is_some(). That fixes case B and makes the round-trip genuinely identity.
Both change
Display's canonical output, so they're a deliberate format decision rather than a patch — which is easier to justify given the point below.Severity calibration
Worth stating plainly, since the issue frames all three cases together:
SyncEndpointUrl::Displayappears to have no production consumer. The connection path uses the typed accessors (peers.rs:54-58callsurl.http()andurl.websocket()); the%endpointtracing field inrpc_sync/client.rs:136is a&Url, not this type (client.rs:86,:94,:204); andfollow_endpointsis#[serde(skip)], soDisplayisn't used to persist or reload config. Configuration enters exclusively throughFromStr, which was never broken.That makes case 1 a genuine crash fix and cases 2/A/B a latent-trap cleanup — worth doing, since a
Display/FromStrpair that loses data will eventually bite whoever first serialises one, but not a live production defect today.- HTTP side: emit
Thanks for the careful review. I agree with the split here.
The IPv6 case should be struck from the issue: with
url2.5.8,host_str()already preserves brackets for IPv6 hosts, so that part was my mistake.The two missing cases you pointed out are the same serialization/round-trip class and are worth covering instead:
- HTTP userinfo is accepted by
FromStrbut dropped byDisplay. - An endpoint without an explicit websocket override reparses as
ws: Some(derived), soparse -> Display -> parseis not identity for the default/common form even thoughwebsocket()resolves to the same URL.
I also checked the narrow behavior locally in an isolated repro pinned to
url = 2.5.8: IPv6 display already passes, while the userinfo and derived-websocket round-trip cases reproduce. I could not run the full workspace test here because the Windows build hitsha3-asmrequiringperl, but the repro is enough for theSyncEndpointUrlformatting behavior.So the issue should be read as: port 65535 is the runtime panic case; path/query, userinfo, and derived-vs-explicit websocket are Display/round-trip cleanup cases. The IPv6 claim can be ignored.
- HTTP userinfo is accepted by
Appreciate you re-testing rather than just taking my word for it, and the isolated repro pinned to
url = 2.5.8is the right instinct — that's the cheapest way to settle a claim about a dependency's serialisation behaviour.Four things worth adding, in rough order of usefulness.
1. Your Windows blocker — the targeted build won't dodge it either
sha3-asmis pulled in transitively byasm-keccak, enabled onalloy-primitivesat the workspace root (Cargo.toml:42). Its build script compiles CRYPTOGAMS-style assembly generated by perl scripts, hence the requirement.The obvious workaround — narrowing to
cargo test -p arc-consensus-types— does not help. That crate takesalloy-primitives = { workspace = true }, so it inherits the workspace feature set includingasm-keccak; feature unification means you still buildkeccak-asm→sha3-asm. Worth saying because that's the first thing most people try.What should work, in increasing order of effort:
- Drop the feature locally. Remove
"asm-keccak"fromCargo.toml:42. It's a pure performance feature — same keccak algorithm, same outputs, no API difference — so it cannot affect the correctness of anything you'd be testing. A one-line local edit, not something to commit. - Install Strawberry Perl and put it on
PATH, if you'd rather keep the workspace pristine. - WSL2, which also matches what CI actually runs.
On which: CI here is Ubuntu-only (
ubuntu-24.04/ubuntu-latest). Windows isn't a supported build target for this repo, so you weren't skipping a check that would otherwise have run — the isolated repro was the pragmatic call and nothing was lost by it. (I can't execute Rust in my environment either, so treat the above as reasoned from the manifests rather than verified end to end.)2. Cases 1 and B are the same bug, and one change closes both
This is the part I'd most encourage you to fold into the issue.
websocket()is:self.ws.clone().unwrap_or_else(|| { /* derive: set_scheme, checked_add(1).expect("port overflow") */ })
self.wsis read in exactly one place in the entire workspace — that line. Nothing anywhere distinguishes "user supplied a WS override" from "we derived one." TheOptioncarries no information that anyone consumes; it only defers work from parse time to call time.So: derive eagerly in
FromStrand storews: Urlinstead ofws: Option<Url>. Consequences:- The
checked_addnow runs during parsing, where it can returnErrinstead of panicking. Case 1 disappears — and without needing a separatevalidate_derived_ws_portor itshas_ws_overrideflag, because when an override is supplied no derivation happens at all.http://localhost:65535,ws=9000still parses fine, for free. wsis always populated, soDisplay's unconditional,<scheme>=segment becomes correct rather than lossy. Case B disappears.websocket()becomes an infallible field read, deleting all threeexpect()s ("port overflow","valid WebSocket scheme","valid port") from a public accessor.- No test churn. I checked the derived-form outputs:
http://localhost:8545still displays as,ws=8546andhttps://example.comstill as,wss=443, becausewebsocket()already returns exactly those values today.display_http_onlyanddisplay_https_onlykeep passing unchanged.
That last point is why I'd now prefer this over what I suggested earlier (omitting the segment when
wsisNone) — same fix for case B, but it preserves the canonical output format instead of rewriting every expectation.One honest caveat: normalising makes
http://x:8545andhttp://x:8545,ws=8546compare equal, where today they're unequal viaNone != Some(_). I'd argue that's more correct, since they're behaviourally identical — but it is a semantic change toPartialEq. No current test asserts they differ (there's noassert_ne!in the module), so nothing breaks today; it's just a decision to make deliberately rather than discover later.3. The real ceiling on "
Displayas a round-trippable format"Worth naming before anyone invests further, because it bounds how far this can go. The format is comma-delimited and
FromStrusessplit_once(','). That's only safe because the fields are private, there's no public constructor, and there's noDeserializeimpl — so the only valuesDisplayever sees came from a parser that structurally cannot produce a comma in the HTTP part.Add any one of those three — a constructor, serde support, a
with_ws()builder — and the delimiter becomes ambiguous. Userinfo makes this sharper, not weaker: onceDisplaystarts emitting credentials, a password containing a comma or=would produce a string that reparses into something different, silently.So the honest framing is that this is a log/debug format that happens to round-trip for parser-produced values, not a serialisation format. Making it genuinely round-trip-safe means percent-encoding or a different delimiter. Given that
Displaycurrently has no production consumer at all, I'd bank cases 2/A/B as cheap correctness hygiene and explicitly decide not to promote it to a real format — rather than half-promoting it and inheriting the escaping obligations.4. Coordination with #330
Flagging since it's a different author: #330 carries
Fixes #323, so merging it as-is would auto-close this issue while cases A and userinfo/derived-WS remain unaddressed — and it would land thehost_for_displayhelper built for the IPv6 case you've now struck.Might be worth either narrowing that to a partial reference, or noting in the PR that the issue's scope changed after it was opened, so the remaining round-trip cases don't get silently closed out.
- Drop the feature locally. Remove
Thanks again. I folded this into the issue body so the current description no longer leaves the wrong IPv6 claim in the main report.
The issue now treats
:65535as the runtime panic case, keeps path/query/fragment as the original serialization loss, and adds the userinfo plus derived-vs-explicit websocket round-trip cases. I also changed the proposed fix toward eager websocket derivation inFromStr, with thePartialEqcaveat called out explicitly.The rewrite reads accurately, and I checked the two claims it introduces that weren't in the earlier discussion — both hold up:
crates/malachite-app/src/config.rsis the right location:default_rpc_sync_endpointat:198, returninghttps://rpc.testnet.arc.io/(testnet) andhttp://localhost:8545(localdev). Neither carries a WS override, so both do sit in the derived-vs-explicit case.- Neither default is affected by moving the failure into
FromStr— both parse cleanly, anddefault_rpc_sync_endpointalready returnseyre::Result, so there's no new panic surface at the call site.
Three things worth adding before anyone implements this.
The
PartialEqcaveat is safer than the issue currently statesYou wrote that the distinction is unused as a belief ("no workspace consumer appears to use"). I went looking for a counterexample and there isn't one — worth upgrading to a verified claim, since it's the only real risk in the proposal:
- No
dedup,HashSet,BTreeSet,retain,position, orcontainsoverrpc_sync_endpointsorPeerRegistry::endpointsanywhere in the workspace. Thecontains(hits inmalachite-appare all&strassertions on error messages in tests. SyncEndpointUrlderivesDebug, Clone, PartialEq, Eqbut notHash, so it structurally cannot be aHashMap/HashSetkey. That closes the whole class of "two endpoints silently collapse into one" concerns.peers.rsiterates the list positionally to build its registry rather than comparing entries.
So the equality change has no production blast radius — it's visible only to tests that choose to assert on it. That's a much stronger position than "probably fine."
Round-trip identity does hold for the default-port forms
This is the case I'd expect to break under eager derivation, because
urlelides default ports — so it's worth confirming ahead of implementation rather than discovering in review. Tracinghttps://example.com(no explicit port, no override):- Derived WS is
wss://example.com/, no port set. Displaytakes the same-host/no-path branch, emitsport_or_known_default()→,wss=443. HTTP side emits:443likewise.- Reparsing
https://example.com:443,wss=443:urlelides:443forhttps, givinghttps://example.com/; the443override is applied viaset_portand elided again forwss, givingwss://example.com/.
Both fields land back on exactly the original values, so identity holds. Same for
http://localhost→,ws=80. Eager derivation doesn't disturb the default-port normalisation.One clarity risk in the current "Proposed fix" section
As written, the section leads with eager derivation and lists five benefits, then mentions URL-aware serialisation only in a conditional closing paragraph. An implementer skimming it could reasonably conclude the first change addresses everything. It doesn't — the four cases split cleanly into two independent fixes:
- Eager derivation closes the
:65535panic and the derived-vs-explicit round trip. Requires noDisplaychange and no test churn. - Emitting the HTTP side via
self.http.as_str()closes path/query/fragment and userinfo. This is the one with real cost: it drops the pinned:443/:8546forms that several existing assertions depend on, and it's the change that forces the log-format-vs-serialisation-format decision, because emitting credentials is what makes the comma/=delimiter a genuine escaping problem.
Making that split explicit would also let the two be landed independently — the first is close to free, the second needs a deliberate call on what
Displayis for.Separately, and only as a coordination note: #330 is still at
2d1885bwithFixes #323, so it currently proposesvalidate_derived_ws_port— a mechanism the issue's updated fix would make unnecessary — alongside the IPv6 helper for the struck case.I updated the issue body again with this split.
The proposed fix section now separates the two concerns:
- eager websocket derivation in
FromStr, which closes the:65535panic and the derived-vs-explicit structural round-trip case without requiringDisplaychurn; and - a separate Display decision for path/query/fragment and userinfo, since treating Display as serialization brings the delimiter/credential escaping question with it.
I also checked the
PartialEqrisk in the workspace and added the concrete result:SyncEndpointUrldoes not deriveHash, I did not find set/dedup/position/contains usage over the endpoint list, andPeerRegistrybuilds from endpoints positionally. So the equality normalization looks visible to tests/future code, not to a current production consumer.- eager websocket derivation in
Hi, I would like to work on this issue. Could you assign it to me?
Summary
SyncEndpointUrlaccepts HTTP endpoints that can later panic inwebsocket()or lose information when formatted and reparsed.Update after review: the original IPv6 case was incorrect. With
url2.5.8,Url::host_str()already returns bracketed IPv6 hosts, so that case should be ignored. The remaining issue is the runtime panic plus severalDisplay/ round-trip cases.Cases
Maximum explicit HTTP port panics when the WebSocket URL is derived
Parsing succeeds, but deriving the WebSocket URL executes:
That is an unconditional panic in every build profile. Invalid derived-port combinations should be rejected during parsing instead of failing later from a public accessor. An explicit override should still be accepted, e.g.
http://localhost:65535,ws=9000.HTTP path, query, and fragment are lost by Display
Displayreconstructs the HTTP side from only scheme, host, and port, so URL components such as/api/v1,?key=value, and fragments are discarded.HTTP userinfo is accepted by FromStr but dropped by Display
The parser accepts credentials because only the scheme is validated, but
Displaynever emits them. Dropping credentials may be desirable for logs, but thenDisplayshould be treated as a log/debug format rather than a round-trippable serialization format.Derived-vs-explicit WebSocket state breaks structural round-tripping
The two values resolve to the same
websocket()URL, but they are structurally unequal becauseSyncEndpointUrlderivesPartialEqoverws: Option<Url>. This affects the common/default form, including both default follow endpoints incrates/malachite-app/src/config.rs:https://rpc.testnet.arc.io/http://localhost:8545Expected behavior
websocket()is called.parse -> Display -> parseshould preserve parser-produced endpoint values, orDisplayshould be documented/treated as a lossy log format.Implementation notes
The cases split into two independent fixes.
1. Eagerly derive the WebSocket URL during parsing
Store
ws: Urlinstead ofws: Option<Url>, deriving the default WebSocket URL insideFromStrwhen no override is supplied.That would close:
:65535panic, becausechecked_addcan fail during parsing and returnErrUrlThis does not require a
Displaychange and should preserve the current canonical output for derived endpoints such ashttp://localhost:8545,ws=8546and default-port forms such ashttps://example.com:443,wss=443.The semantic caveat is that
http://x:8545andhttp://x:8545,ws=8546would compare equal after parsing. That appears to have no production blast radius:SyncEndpointUrlderivesDebug,Clone,PartialEq, andEq, but notHash, so it cannot be used directly as aHashMap/HashSetkey.dedup,retain,position, orcontainsuse overrpc_sync_endpointsorPeerRegistry::endpoints.PeerRegistrybuilds peers by iterating the endpoint list positionally rather than comparing entries.So the equality change is visible to tests or future code that chooses to assert on it, but there does not appear to be a current production consumer of the derived-vs-explicit distinction.
2. Decide whether Display is serialization or logging
Path/query/fragment loss and userinfo loss are Display-format problems. Emitting the HTTP side with URL-aware serialization, e.g.
self.http.as_str(), would preserve those components, but it also changes pinned output such as explicit default ports and raises the delimiter question.The current format is comma-delimited via
split_once(','). It round-trips only for values produced by the current parser shape. If future code adds a public constructor, serde support, or a builder that can produce URLs containing commas or=, Display-as-serialization would need proper escaping or a different format.So this part should be a deliberate decision:
Displayis intended as a serialization format, use URL-aware serialization and handle delimiter/credential edge cases.Displayis intended only for logs/debug output, document that it is lossy and avoid treatingparse -> Display -> parseas a contract.