Skip to main content

cdp_server/
transport.rs

1// @trace REQ-CDS-001 [entity:CdpServer] [api:GET /json/version]
2// @trace REQ-CDS-002 [entity:CdpTarget]
3// Transport layer: HTTP discovery endpoints + WebSocket upgrade.
4
5use std::io::Write;
6use std::net::TcpStream;
7
8use serde_json::Value;
9
10/// Browser Target information.
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12pub struct TargetInfo {
13    pub id: String,
14    #[serde(rename = "type")]
15    pub target_type: String,
16    pub title: String,
17    pub url: String,
18    pub web_socket_debugger_url: String,
19}
20
21/// Handle an HTTP request. Returns Some((target_id, is_browser)) if this
22/// is a WebSocket upgrade request, None for plain HTTP.
23pub fn handle_http_request(
24    stream: &mut TcpStream,
25    request: &str,
26    config: &crate::ServerConfig,
27    targets: &[TargetInfo],
28) -> Option<(String, bool)> {
29    // GET /json/version
30    if request.starts_with("GET /json/version") {
31        let ws_url = format!("ws://{}:{}/devtools/browser", config.host, config.port);
32        let mut body = serde_json::json!({
33            "Browser": config.browser_name,
34            "Protocol-Version": config.protocol_version,
35            "webSocketDebuggerUrl": ws_url,
36        });
37        if let Some(ref ua) = config.user_agent {
38            body["User-Agent"] = Value::String(ua.clone());
39        }
40        if let Some(ref v8) = config.v8_version {
41            body["V8-Version"] = Value::String(v8.clone());
42        }
43        if let Some(ref wk) = config.webkit_version {
44            body["WebKit-Version"] = Value::String(wk.clone());
45        }
46        respond_json(stream, &body);
47        return None;
48    }
49
50    // GET /json or GET /json/list
51    if request.starts_with("GET /json") && !request.starts_with("GET /json/") {
52        respond_json(stream, &serde_json::json!(targets));
53        return None;
54    }
55
56    // WebSocket upgrade: /devtools/page/{targetId}
57    if let Some(rest) = request.strip_prefix("GET /devtools/page/") {
58        let target_id = rest.split(' ').next().unwrap_or("").to_string();
59        return Some((target_id, false));
60    }
61
62    // WebSocket upgrade: /devtools/browser
63    if request.starts_with("GET /devtools/browser") {
64        return Some(("__browser__".into(), true));
65    }
66
67    respond_raw(stream, "404 Not Found");
68    None
69}
70
71/// Parse the request to determine if it's a WebSocket upgrade.
72/// Returns the pre-read bytes for replay.
73#[allow(dead_code)]
74pub fn is_websocket_upgrade(request: &str) -> bool {
75    request.contains("Upgrade: websocket") || request.contains("upgrade: websocket")
76}
77
78pub fn respond_json(stream: &mut TcpStream, value: &Value) {
79    let body = value.to_string();
80    let response = format!(
81        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
82        body.len(),
83        body
84    );
85    respond_raw(stream, &response);
86}
87
88pub fn respond_raw(stream: &mut TcpStream, text: &str) {
89    let _ = stream.write_all(text.as_bytes());
90    let _ = stream.flush();
91}
92
93/// Parse HTTP path for target actions. Returns (action, target_id).
94pub fn parse_close_request(request: &str) -> Option<String> {
95    let prefix = "GET /json/close/";
96    if let Some(rest) = request.strip_prefix(prefix) {
97        let id = rest.split(' ').next()?;
98        Some(id.to_string())
99    } else {
100        None
101    }
102}
103
104pub fn parse_activate_request(request: &str) -> Option<String> {
105    let prefix = "GET /json/activate/";
106    if let Some(rest) = request.strip_prefix(prefix) {
107        let id = rest.split(' ').next()?;
108        Some(id.to_string())
109    } else {
110        None
111    }
112}
113
114pub fn parse_new_request(request: &str) -> Option<String> {
115    let prefix = "GET /json/new";
116    if let Some(rest) = request.strip_prefix(prefix) {
117        if rest.starts_with('?') {
118            let end = rest.find(' ').unwrap_or(rest.len());
119            Some(percent_decode(&rest[1..end]))
120        } else {
121            Some("about:blank".to_string())
122        }
123    } else {
124        None
125    }
126}
127
128fn percent_decode(input: &str) -> String {
129    let mut result = String::with_capacity(input.len());
130    let mut chars = input.bytes();
131    while let Some(b) = chars.next() {
132        if b == b'%' {
133            let hi = chars.next().unwrap_or(b'0');
134            let lo = chars.next().unwrap_or(b'0');
135            let val = hex_val(hi) << 4 | hex_val(lo);
136            result.push(val as char);
137        } else if b == b'+' {
138            result.push(' ');
139        } else {
140            result.push(b as char);
141        }
142    }
143    result
144}
145
146fn hex_val(b: u8) -> u8 {
147    match b {
148        b'0'..=b'9' => b - b'0',
149        b'a'..=b'f' => b - b'a' + 10,
150        b'A'..=b'F' => b - b'A' + 10,
151        _ => 0,
152    }
153}
154
155// @trace REQ-CDS-004 [req:REQ-CDS-004] [level:unit]
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn websocket_upgrade_mixed_case() {
162        let req = "GET /devtools/page/abc HTTP/1.1\r\nUpgrade: websocket";
163        assert!(is_websocket_upgrade(req));
164    }
165
166    #[test]
167    fn websocket_upgrade_lowercase() {
168        let req = "GET /devtools/page/abc HTTP/1.1\r\nupgrade: websocket";
169        assert!(is_websocket_upgrade(req));
170    }
171
172    #[test]
173    fn websocket_upgrade_missing_header() {
174        let req = "GET /json/version HTTP/1.1\r\nHost: localhost";
175        assert!(!is_websocket_upgrade(req));
176    }
177
178    #[test]
179    fn close_request_extracts_target_id() {
180        let req = "GET /json/close/target-123 HTTP/1.1";
181        assert_eq!(parse_close_request(req), Some("target-123".to_string()));
182    }
183
184    #[test]
185    fn close_request_wrong_path() {
186        let req = "GET /json/list HTTP/1.1";
187        assert_eq!(parse_close_request(req), None);
188    }
189
190    #[test]
191    fn activate_request_extracts_target_id() {
192        let req = "GET /json/activate/tid HTTP/1.1";
193        assert_eq!(parse_activate_request(req), Some("tid".to_string()));
194    }
195
196    #[test]
197    fn new_request_extracts_query_string() {
198        let req = "GET /json/new?url=https://example.com HTTP/1.1";
199        assert_eq!(
200            parse_new_request(req),
201            Some("url=https://example.com".to_string())
202        );
203    }
204
205    #[test]
206    fn new_request_wrong_path() {
207        let req = "GET /json/version HTTP/1.1";
208        assert_eq!(parse_new_request(req), None);
209    }
210
211    #[test]
212    fn percent_decode_space() {
213        assert_eq!(percent_decode("%20"), " ");
214    }
215
216    #[test]
217    fn percent_decode_hello() {
218        assert_eq!(percent_decode("%48%65%6C%6C%6F"), "Hello");
219    }
220
221    #[test]
222    fn percent_decode_no_encoding() {
223        assert_eq!(percent_decode("abc"), "abc");
224    }
225
226    #[test]
227    fn hex_val_digits() {
228        assert_eq!(hex_val(b'0'), 0);
229        assert_eq!(hex_val(b'a'), 10);
230        assert_eq!(hex_val(b'F'), 15);
231    }
232}