-
Notifications
You must be signed in to change notification settings - Fork 158
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 1 commit
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 |
|---|---|---|
|
|
@@ -19,7 +19,7 @@ | |
| use core::fmt; | ||
| use std::str::FromStr; | ||
|
|
||
| use url::Url; | ||
| use url::{Host, Url}; | ||
|
|
||
| /// A parsed endpoint URL for RPC synchronization. | ||
| /// | ||
|
|
@@ -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)) | ||
|
|
@@ -153,17 +169,23 @@ impl FromStr for SyncEndpointUrl { | |
|
|
||
| impl fmt::Display for SyncEndpointUrl { | ||
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| let host = self.http.host_str().expect("validated host"); | ||
| let host = host_for_display(&self.http); | ||
| let http_port = self.http.port_or_known_default().expect("validated port"); | ||
| let ws_url = self.websocket(); | ||
| let ws_host = ws_url.host_str().expect("validated host"); | ||
| let ws_host = host_for_display(&ws_url); | ||
|
|
||
| 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 != "/"; | ||
|
|
@@ -189,6 +211,13 @@ impl fmt::Display for SyncEndpointUrl { | |
| } | ||
| } | ||
|
|
||
| fn host_for_display(url: &Url) -> String { | ||
| match url.host().expect("validated host") { | ||
| Host::Ipv6(addr) => format!("[{addr}]"), | ||
|
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 is the line I'd remove, for two independent reasons. (a) It's redundant. (b) Where it isn't redundant, it's a regression. For Simplest resolution is to delete the helper and keep |
||
| host => host.to_string(), | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
@@ -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_path_and_query() { | ||
| let endpoint: SyncEndpointUrl = | ||
| "https://rpc.example.com/api/v1?key=value,wss=ws.example.com/websocket" | ||
| .parse() | ||
| .unwrap(); | ||
|
|
||
| assert_eq!( | ||
| endpoint.to_string(), | ||
| "https://rpc.example.com:443/api/v1?key=value,wss=ws.example.com/websocket" | ||
| ); | ||
| 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.