Skip to main content

jsdet_browser/
network.rs

1/// Network simulation — fetch() and XMLHttpRequest.
2///
3/// NO real network connections. Every request is logged as an observation
4/// and returns a programmable fake response. The researcher can configure
5/// responses per URL pattern.
6///
7use std::collections::HashMap;
8
9use jsdet_core::observation::Value;
10
11/// A fake HTTP response.
12#[derive(Debug, Clone)]
13pub struct FakeResponse {
14    pub status: u16,
15    pub status_text: String,
16    pub headers: HashMap<String, String>,
17    pub body: String,
18    pub content_type: String,
19}
20
21impl Default for FakeResponse {
22    fn default() -> Self {
23        Self {
24            status: 200,
25            status_text: "OK".into(),
26            headers: HashMap::new(),
27            body: String::new(),
28            content_type: "text/html".into(),
29        }
30    }
31}
32
33impl FakeResponse {
34    pub fn ok(body: &str) -> Self {
35        Self {
36            body: body.into(),
37            ..Self::default()
38        }
39    }
40
41    pub fn json(body: &str) -> Self {
42        Self {
43            body: body.into(),
44            content_type: "application/json".into(),
45            ..Self::default()
46        }
47    }
48
49    pub fn not_found() -> Self {
50        Self {
51            status: 404,
52            status_text: "Not Found".into(),
53            body: "Not Found".into(),
54            ..Self::default()
55        }
56    }
57
58    pub fn error() -> Self {
59        Self {
60            status: 500,
61            status_text: "Internal Server Error".into(),
62            body: "Internal Server Error".into(),
63            ..Self::default()
64        }
65    }
66
67    /// Serialize as a JSON object matching the Fetch API Response shape.
68    pub fn to_fetch_response_json(&self) -> String {
69        let headers_json = serde_json::to_string(&self.headers).unwrap_or_default();
70        format!(
71            r#"{{"ok":{},"status":{},"statusText":"{}","headers":{},"body":"{}","type":"basic","url":"","redirected":false}}"#,
72            self.status >= 200 && self.status < 300,
73            self.status,
74            self.status_text,
75            headers_json,
76            self.body.replace('"', "\\\""),
77        )
78    }
79}
80
81/// Registry of URL patterns → fake responses.
82///
83/// Researchers configure this to simulate specific server behaviors:
84/// - C2 communication patterns
85/// - Login page responses
86/// - Redirect chains
87#[derive(Debug, Default)]
88pub struct ResponseRegistry {
89    /// Exact URL → response.
90    exact: HashMap<String, FakeResponse>,
91    /// URL prefix → response.
92    prefix: Vec<(String, FakeResponse)>,
93    /// Default response when no pattern matches.
94    fallback: Option<FakeResponse>,
95}
96
97impl ResponseRegistry {
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Register a response for an exact URL.
103    pub fn add_exact(&mut self, url: impl Into<String>, response: FakeResponse) {
104        self.exact.insert(url.into(), response);
105    }
106
107    /// Register a response for all URLs starting with prefix.
108    pub fn add_prefix(&mut self, prefix: impl Into<String>, response: FakeResponse) {
109        self.prefix.push((prefix.into(), response));
110    }
111
112    /// Set the fallback response for unmatched URLs.
113    pub fn set_fallback(&mut self, response: FakeResponse) {
114        self.fallback = Some(response);
115    }
116
117    /// Look up the response for a URL.
118    pub fn get(&self, url: &str) -> FakeResponse {
119        if let Some(r) = self.exact.get(url) {
120            return r.clone();
121        }
122        for (prefix, response) in &self.prefix {
123            if url.starts_with(prefix.as_str()) {
124                return response.clone();
125            }
126        }
127        self.fallback
128            .clone()
129            .unwrap_or_else(FakeResponse::not_found)
130    }
131}
132
133/// Handle a fetch() call.
134pub fn handle_fetch(url: &str, _method: &str, registry: &ResponseRegistry) -> Value {
135    let response = registry.get(url);
136    Value::json(response.to_fetch_response_json())
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn exact_url_match() {
145        let mut reg = ResponseRegistry::new();
146        reg.add_exact(
147            "https://api.example.com/data",
148            FakeResponse::json(r#"{"key":"val"}"#),
149        );
150        let resp = reg.get("https://api.example.com/data");
151        assert_eq!(resp.status, 200);
152        assert!(resp.body.contains("key"));
153    }
154
155    #[test]
156    fn prefix_match() {
157        let mut reg = ResponseRegistry::new();
158        reg.add_prefix("https://cdn.", FakeResponse::ok("cdn content"));
159        let resp = reg.get("https://cdn.example.com/lib.js");
160        assert_eq!(resp.body, "cdn content");
161    }
162
163    #[test]
164    fn fallback_when_no_match() {
165        let reg = ResponseRegistry::new();
166        let resp = reg.get("https://unknown.com");
167        assert_eq!(resp.status, 404);
168    }
169}