Skip to main content

zerodds_websocket_bridge/
uri.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! WebSocket URI-Scheme-Parser nach RFC 6455 §3.
5//!
6//! Spec: `ws-URI = "ws:" "//" host [ ":" port ] path [ "?" query ]`,
7//! `wss-URI = "wss:" "//" host [ ":" port ] path [ "?" query ]`.
8//! Default-Ports: 80 (ws), 443 (wss).
9//! Fragment ist explizit verboten in §3.
10
11use alloc::string::String;
12
13/// Parsed WebSocket URI nach RFC 6455 §3.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct WebSocketUri {
16    /// `true` wenn `wss://` (TLS).
17    pub secure: bool,
18    /// Host (IP oder DNS-Name; nicht percent-decoded).
19    pub host: String,
20    /// Port; default 80 (ws) oder 443 (wss).
21    pub port: u16,
22    /// Path; default "/".
23    pub resource_name: String,
24    /// Optional Query-String (ohne fuehrendes "?").
25    pub query: Option<String>,
26}
27
28/// URI-Parser-Errors nach RFC 6455 §3.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum UriError {
31    /// Scheme ist nicht `ws://` oder `wss://`.
32    InvalidScheme,
33    /// Host fehlt.
34    MissingHost,
35    /// Port ist nicht parseable als u16.
36    InvalidPort,
37    /// Spec §3: "fragment identifier MUST NOT be used".
38    FragmentNotAllowed,
39}
40
41impl core::fmt::Display for UriError {
42    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
43        match self {
44            Self::InvalidScheme => write!(f, "InvalidScheme"),
45            Self::MissingHost => write!(f, "MissingHost"),
46            Self::InvalidPort => write!(f, "InvalidPort"),
47            Self::FragmentNotAllowed => write!(f, "FragmentNotAllowed"),
48        }
49    }
50}
51
52#[cfg(feature = "std")]
53impl std::error::Error for UriError {}
54
55/// Parsed eine `ws://` oder `wss://` URI nach RFC 6455 §3.
56///
57/// # Errors
58/// Siehe [`UriError`].
59pub fn parse_websocket_uri(input: &str) -> Result<WebSocketUri, UriError> {
60    let (secure, rest) = if let Some(r) = input.strip_prefix("ws://") {
61        (false, r)
62    } else if let Some(r) = input.strip_prefix("wss://") {
63        (true, r)
64    } else {
65        return Err(UriError::InvalidScheme);
66    };
67
68    if rest.contains('#') {
69        return Err(UriError::FragmentNotAllowed);
70    }
71
72    // Aufteilen Authority vs Path/Query.
73    let (authority, path_query) = match rest.find('/') {
74        Some(i) => (&rest[..i], &rest[i..]),
75        None => (rest, "/"),
76    };
77
78    if authority.is_empty() {
79        return Err(UriError::MissingHost);
80    }
81
82    // Authority: host[:port]. Note: IPv6-Literale [::1] werden hier
83    // nicht unterstuetzt (RFC 6874 — Caller-Layer); wir akzeptieren
84    // Hostnames + IPv4-Literale.
85    let (host, port) = if let Some(colon) = authority.rfind(':') {
86        let host_part = &authority[..colon];
87        let port_str = &authority[colon + 1..];
88        let port_num: u16 = port_str.parse().map_err(|_| UriError::InvalidPort)?;
89        if host_part.is_empty() {
90            return Err(UriError::MissingHost);
91        }
92        (host_part.to_string(), port_num)
93    } else {
94        (authority.to_string(), if secure { 443 } else { 80 })
95    };
96
97    // Path/Query splitten.
98    let (path, query) = match path_query.find('?') {
99        Some(q) => (
100            path_query[..q].to_string(),
101            Some(path_query[q + 1..].to_string()),
102        ),
103        None => (path_query.to_string(), None),
104    };
105
106    Ok(WebSocketUri {
107        secure,
108        host,
109        port,
110        resource_name: path,
111        query,
112    })
113}
114
115/// Default-Port nach Scheme.
116#[must_use]
117pub fn default_port(secure: bool) -> u16 {
118    if secure { 443 } else { 80 }
119}
120
121/// Sammelt die `Resource-Name`-Form nach Spec (`<path> [ "?" <query> ]`).
122#[must_use]
123pub fn resource_name(uri: &WebSocketUri) -> String {
124    match &uri.query {
125        Some(q) => {
126            let mut s = uri.resource_name.clone();
127            s.push('?');
128            s.push_str(q);
129            s
130        }
131        None => uri.resource_name.clone(),
132    }
133}
134
135/// `true` wenn `host` als bekannter Local-Loopback-Wert akzeptiert
136/// werden darf (Same-Origin-Policy-Hint fuer Browser-Pfad).
137#[must_use]
138pub fn is_local_loopback(host: &str) -> bool {
139    matches!(host, "localhost" | "127.0.0.1" | "::1")
140}
141
142// ---------------------------------------------------------------------------
143// Tests
144// ---------------------------------------------------------------------------
145
146#[cfg(test)]
147#[allow(clippy::expect_used)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn parses_basic_ws_uri() {
153        let u = parse_websocket_uri("ws://example.com/chat").expect("ok");
154        assert!(!u.secure);
155        assert_eq!(u.host, "example.com");
156        assert_eq!(u.port, 80);
157        assert_eq!(u.resource_name, "/chat");
158        assert!(u.query.is_none());
159    }
160
161    #[test]
162    fn parses_basic_wss_uri() {
163        let u = parse_websocket_uri("wss://example.com/").expect("ok");
164        assert!(u.secure);
165        assert_eq!(u.port, 443);
166    }
167
168    #[test]
169    fn parses_explicit_port() {
170        let u = parse_websocket_uri("ws://example.com:8080/foo").expect("ok");
171        assert_eq!(u.port, 8080);
172    }
173
174    #[test]
175    fn parses_query_string() {
176        let u = parse_websocket_uri("wss://e.com:443/p?token=abc").expect("ok");
177        assert_eq!(u.query.as_deref(), Some("token=abc"));
178        assert_eq!(u.resource_name, "/p");
179    }
180
181    #[test]
182    fn parses_default_path_when_missing() {
183        let u = parse_websocket_uri("ws://e.com").expect("ok");
184        assert_eq!(u.resource_name, "/");
185    }
186
187    #[test]
188    fn rejects_unknown_scheme() {
189        assert_eq!(
190            parse_websocket_uri("http://e.com"),
191            Err(UriError::InvalidScheme)
192        );
193    }
194
195    #[test]
196    fn rejects_missing_host() {
197        assert_eq!(parse_websocket_uri("ws://"), Err(UriError::MissingHost));
198    }
199
200    #[test]
201    fn rejects_missing_host_before_port() {
202        assert_eq!(
203            parse_websocket_uri("ws://:8080/"),
204            Err(UriError::MissingHost)
205        );
206    }
207
208    #[test]
209    fn rejects_invalid_port() {
210        assert_eq!(
211            parse_websocket_uri("ws://e.com:abc/"),
212            Err(UriError::InvalidPort)
213        );
214    }
215
216    #[test]
217    fn rejects_fragment() {
218        assert_eq!(
219            parse_websocket_uri("ws://e.com/#anchor"),
220            Err(UriError::FragmentNotAllowed)
221        );
222    }
223
224    #[test]
225    fn default_port_returns_443_for_wss() {
226        assert_eq!(default_port(true), 443);
227        assert_eq!(default_port(false), 80);
228    }
229
230    #[test]
231    fn resource_name_combines_path_and_query() {
232        let u = WebSocketUri {
233            secure: false,
234            host: "e.com".into(),
235            port: 80,
236            resource_name: "/foo".into(),
237            query: Some("a=1".into()),
238        };
239        assert_eq!(resource_name(&u), "/foo?a=1");
240    }
241
242    #[test]
243    fn resource_name_without_query_is_path() {
244        let u = WebSocketUri {
245            secure: false,
246            host: "e.com".into(),
247            port: 80,
248            resource_name: "/foo".into(),
249            query: None,
250        };
251        assert_eq!(resource_name(&u), "/foo");
252    }
253
254    #[test]
255    fn is_local_loopback_recognizes_localhost() {
256        assert!(is_local_loopback("localhost"));
257        assert!(is_local_loopback("127.0.0.1"));
258        assert!(is_local_loopback("::1"));
259        assert!(!is_local_loopback("example.com"));
260    }
261}