Skip to content

SyncEndpointUrl accepts endpoints that later panic or fail Display round-tripping #323

Description

@dumanoglu1

Summary

SyncEndpointUrl accepts HTTP endpoints that can later panic in websocket() or lose information when formatted and reparsed.

Update after review: the original IPv6 case was incorrect. With url 2.5.8, Url::host_str() already returns bracketed IPv6 hosts, so that case should be ignored. The remaining issue is the runtime panic plus several Display / round-trip cases.

Cases

Maximum explicit HTTP port panics when the WebSocket URL is derived

let endpoint: SyncEndpointUrl = "http://localhost:65535".parse()?;
let _ = endpoint.websocket();

Parsing succeeds, but deriving the WebSocket URL executes:

http_port.checked_add(1).expect("port overflow")

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

let endpoint: SyncEndpointUrl =
    "https://rpc.example.com/api/v1?key=value,wss=ws.example.com/websocket".parse()?;
let reparsed: SyncEndpointUrl = endpoint.to_string().parse()?;
assert_eq!(endpoint, reparsed);

Display reconstructs 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

let endpoint: SyncEndpointUrl = "http://user:pass@localhost:8545,ws=8546".parse()?;
let reparsed: SyncEndpointUrl = endpoint.to_string().parse()?;
assert_eq!(endpoint, reparsed);

The parser accepts credentials because only the scheme is validated, but Display never emits them. Dropping credentials may be desirable for logs, but then Display should be treated as a log/debug format rather than a round-trippable serialization format.

Derived-vs-explicit WebSocket state breaks structural round-tripping

let endpoint: SyncEndpointUrl = "http://localhost:8545".parse()?; // ws: None
let reparsed: SyncEndpointUrl = endpoint.to_string().parse()?;    // ws: Some(ws://localhost:8546/)
assert_eq!(endpoint, reparsed);

The two values resolve to the same websocket() URL, but they are structurally unequal because SyncEndpointUrl derives PartialEq over ws: Option<Url>. This affects the common/default form, including both default follow endpoints in crates/malachite-app/src/config.rs:

  • https://rpc.testnet.arc.io/
  • http://localhost:8545

Expected behavior

  • Accepted endpoint values should not panic when websocket() is called.
  • parse -> Display -> parse should preserve parser-produced endpoint values, or Display should be documented/treated as a lossy log format.
  • Derived and explicitly equivalent WebSocket URLs should not become an accidental equality trap unless the distinction is intentionally meaningful.

Implementation notes

The cases split into two independent fixes.

1. Eagerly derive the WebSocket URL during parsing

Store ws: Url instead of ws: Option<Url>, deriving the default WebSocket URL inside FromStr when no override is supplied.

That would close:

  • the :65535 panic, because checked_add can fail during parsing and return Err
  • the derived-vs-explicit round-trip failure, because both forms store the same concrete Url

This does not require a Display change and should preserve the current canonical output for derived endpoints such as http://localhost:8545,ws=8546 and default-port forms such as https://example.com:443,wss=443.

The semantic caveat is that http://x:8545 and http://x:8545,ws=8546 would compare equal after parsing. That appears to have no production blast radius:

  • SyncEndpointUrl derives Debug, Clone, PartialEq, and Eq, but not Hash, so it cannot be used directly as a HashMap / HashSet key.
  • I did not find any dedup, retain, position, or contains use over rpc_sync_endpoints or PeerRegistry::endpoints.
  • PeerRegistry builds 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:

  • If Display is intended as a serialization format, use URL-aware serialization and handle delimiter/credential edge cases.
  • If Display is intended only for logs/debug output, document that it is lossy and avoid treating parse -> Display -> parse as a contract.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions