-
Notifications
You must be signed in to change notification settings - Fork 155
fix(arc-consensus-types): validate derived WebSocket port and preserve URL components in Display #330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix(arc-consensus-types): validate derived WebSocket port and preserve URL components in Display #330
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -105,6 +105,21 @@ fn validate_ws_scheme(scheme: &str) -> Result<(), eyre::Report> { | |
| Ok(()) | ||
| } | ||
|
|
||
| fn validate_derived_ws_port(http: &Url, has_ws_override: bool) -> Result<(), eyre::Report> { | ||
| if has_ws_override { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| if matches!(http.port(), Some(u16::MAX)) { | ||
| return Err(eyre::eyre!( | ||
| "Invalid HTTP URL port '{}': derived WebSocket port would overflow.", | ||
| u16::MAX | ||
| )); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| /// Parses a WebSocket override in the format `<scheme>=<value>`. | ||
| /// | ||
| /// The value after `=` can be: | ||
|
|
@@ -142,6 +157,7 @@ impl FromStr for SyncEndpointUrl { | |
| Url::parse(http_part).map_err(|e| eyre::eyre!("Failed to parse HTTP URL: {e}"))?; | ||
|
|
||
| validate_http_scheme(http.scheme())?; | ||
| validate_derived_ws_port(&http, ws_part.is_some())?; | ||
|
|
||
| let ws = ws_part | ||
| .map(|part| parse_ws_override(part, &http)) | ||
|
|
@@ -158,19 +174,26 @@ impl fmt::Display for SyncEndpointUrl { | |
| let ws_url = self.websocket(); | ||
| let ws_host = ws_url.host_str().expect("validated host"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed this is the correct replacement for
Worth noting that |
||
|
|
||
| write!( | ||
| f, | ||
| "{}://{host}:{http_port},{}=", | ||
| self.http.scheme(), | ||
| ws_url.scheme() | ||
| )?; | ||
| write!(f, "{}://{host}:{http_port}", self.http.scheme())?; | ||
| let http_path = self.http.path(); | ||
| if http_path != "/" { | ||
| write!(f, "{http_path}")?; | ||
| } | ||
| if let Some(query) = self.http.query() { | ||
| write!(f, "?{query}")?; | ||
| } | ||
| if let Some(fragment) = self.http.fragment() { | ||
| write!(f, "#{fragment}")?; | ||
| } | ||
| write!(f, ",{}=", ws_url.scheme())?; | ||
|
|
||
| let ws_path = ws_url.path(); | ||
| let has_path = ws_path != "/"; | ||
| let has_suffix = has_path || ws_url.query().is_some() || ws_url.fragment().is_some(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This line is the part of the change I want to single out, because it wasn't in my report and it prevents a bug that emitting query/fragment alone would have created. Had the condition stayed Widening to |
||
|
|
||
| if ws_host != host || has_path { | ||
| // Include the host when it differs or when a path is present | ||
| // (a bare port + path like `443/websocket` mis-parses as a hostname) | ||
| if ws_host != host || has_suffix { | ||
| // Include the host when it differs or when extra URL components are | ||
| // present (a bare port plus path/query/fragment mis-parses as a host). | ||
| write!(f, "{ws_host}")?; | ||
| if let Some(ws_port) = ws_url.port() { | ||
| write!(f, ":{ws_port}")?; | ||
|
|
@@ -184,6 +207,12 @@ impl fmt::Display for SyncEndpointUrl { | |
| if has_path { | ||
| write!(f, "{ws_path}")?; | ||
| } | ||
| if let Some(query) = ws_url.query() { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These two writes are correct for the explicit-override case, which is what the new test covers. Flagging a side effect on the derived path, where let e: SyncEndpointUrl = "https://rpc.example.com/v1?apikey=SECRET".parse()?;
// now renders ?apikey=SECRET twice, once per segmentNot asking for a code change — clearing the query inside |
||
| write!(f, "?{query}")?; | ||
| } | ||
| if let Some(fragment) = ws_url.fragment() { | ||
| write!(f, "#{fragment}")?; | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
@@ -349,6 +378,41 @@ mod tests { | |
| assert_eq!(url.websocket().as_str(), "wss://ws.example.com:1212/"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parse_rejects_http_port_that_would_overflow_derived_websocket_port() { | ||
| let err = "http://localhost:65535" | ||
| .parse::<SyncEndpointUrl>() | ||
| .unwrap_err(); | ||
|
|
||
| assert!(err | ||
| .to_string() | ||
| .contains("derived WebSocket port would overflow")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn display_preserves_http_and_websocket_path_query_and_fragment() { | ||
| let endpoint: SyncEndpointUrl = | ||
| "https://rpc.example.com/api/v1?key=value#http-fragment,wss=ws.example.com/websocket?token=abc#ws-fragment" | ||
| .parse() | ||
| .unwrap(); | ||
|
|
||
| assert_eq!( | ||
| endpoint.to_string(), | ||
| "https://rpc.example.com:443/api/v1?key=value#http-fragment,wss=ws.example.com/websocket?token=abc#ws-fragment" | ||
| ); | ||
| let reparsed: SyncEndpointUrl = endpoint.to_string().parse().unwrap(); | ||
| assert_eq!(endpoint, reparsed); | ||
| } | ||
|
|
||
| #[test] | ||
| fn display_brackets_ipv6_hosts() { | ||
| let endpoint: SyncEndpointUrl = "http://[::1]:8545,ws=8546".parse().unwrap(); | ||
|
|
||
| assert_eq!(endpoint.to_string(), "http://[::1]:8545,ws=8546"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe this assertion passes both with and without the
You already have the right tool for settling this: you sabotage-checked the port fix by removing the validation and confirming the test failed for the right reason. Doing the same here — revert Either way it's reasonable regression coverage and worth keeping; it just isn't evidence for the third change, and the PR description currently cites it as such. |
||
| let reparsed: SyncEndpointUrl = endpoint.to_string().parse().unwrap(); | ||
| assert_eq!(endpoint, reparsed); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parse_wss_with_host_port_and_path_override() { | ||
| let url: SyncEndpointUrl = "https://example.com,wss=ws.example.com:8546/websocket" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function is well placed and correctly scoped — gating on
has_ws_overrideis right, sincewebsocket()only derives a port whenself.wsisNone, so an explicit override genuinely makes port 65535 safe. Nice that you didn't over-reject.One thing worth promoting into the PR description: this doesn't only move a panic earlier, and the pre-existing code deserves some credit.
websocket()useschecked_add(1).expect("port overflow"), which is an unconditional panic — it does not depend onoverflow-checks, which this workspace's[profile.release]doesn't enable. If that line had been a plainhttp_port + 1, release builds would have wrapped silently to port 0 and produced a follower dialling the wrong port rather than a crash. The existingchecked_addis what made this a loud failure; your change makes it a validated one.Minor: the message interpolates
u16::MAXthrough'{}'when the rejected value is by definition 65535. Not worth a round-trip on its own, but if you touch this again, quoting the actualhttp.port()would make the error read more naturally alongside the URL that triggered it.