Skip to main content

harn_vm/http/
mod.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, HashMap};
3use std::rc::Rc;
4
5use crate::value::{VmClosure, VmError, VmValue};
6use crate::vm::Vm;
7
8mod client;
9pub(crate) mod framing;
10mod mock;
11mod streaming;
12#[cfg(test)]
13mod tests;
14
15use mock::{
16    clear_http_mocks, http_mock_calls_value, parse_mock_responses, register_http_mock,
17    reset_http_mocks,
18};
19pub use mock::{http_mock_calls_snapshot, push_http_mock, HttpMockCallSnapshot, HttpMockResponse};
20
21/// Route a Harn HTTP request through the standard verb pipeline.
22///
23/// This is the entry point used by the `harness.net.*` sub-handle so
24/// every script-visible network call observes the same egress allowlist,
25/// retry policy, and mock plumbing as the legacy ambient builtins.
26pub(crate) async fn execute_http_request(
27    method: &str,
28    url: &str,
29    options: &BTreeMap<String, VmValue>,
30) -> Result<VmValue, VmError> {
31    client::vm_execute_http_request(method, url, options).await
32}
33#[cfg(test)]
34use mock::{mock_call_headers_value, redact_mock_call_url};
35
36#[derive(Clone)]
37struct HttpServerRoute {
38    method: String,
39    template: String,
40    handler: Rc<VmClosure>,
41    max_body_bytes: Option<usize>,
42    retain_raw_body: Option<bool>,
43}
44
45#[derive(Clone)]
46struct HttpServer {
47    routes: Vec<HttpServerRoute>,
48    before: Vec<Rc<VmClosure>>,
49    after: Vec<Rc<VmClosure>>,
50    ready: bool,
51    readiness: Option<Rc<VmClosure>>,
52    shutdown_hooks: Vec<Rc<VmClosure>>,
53    shutdown: bool,
54    max_body_bytes: usize,
55    retain_raw_body: bool,
56}
57
58pub(super) const DEFAULT_TIMEOUT_MS: u64 = 30_000;
59pub(super) const DEFAULT_BACKOFF_MS: u64 = 1_000;
60pub(super) const MAX_RETRY_DELAY_MS: u64 = 60_000;
61pub(super) const DEFAULT_RETRYABLE_STATUSES: [u16; 6] = [408, 429, 500, 502, 503, 504];
62pub(super) const DEFAULT_RETRYABLE_METHODS: [&str; 5] = ["GET", "HEAD", "PUT", "DELETE", "OPTIONS"];
63pub(super) const DEFAULT_TRANSPORT_RECEIVE_TIMEOUT_MS: u64 = 30_000;
64pub(super) const DEFAULT_MAX_STREAM_EVENTS: usize = 10_000;
65pub(super) const DEFAULT_MAX_MESSAGE_BYTES: usize = 1024 * 1024;
66pub(super) const DEFAULT_SERVER_MAX_BODY_BYTES: usize = 1024 * 1024;
67pub(super) const DEFAULT_WEBSOCKET_SERVER_IDLE_TIMEOUT_MS: u64 = 30_000;
68pub(super) const MAX_HTTP_SESSIONS: usize = 64;
69pub(super) const MAX_HTTP_STREAMS: usize = 64;
70pub(super) const MAX_SSE_STREAMS: usize = 64;
71pub(super) const MAX_SSE_SERVER_STREAMS: usize = 64;
72pub(super) const MAX_WEBSOCKETS: usize = 64;
73pub(super) const MULTIPART_MOCK_BOUNDARY: &str = "harn-boundary";
74pub(super) const MAX_HTTP_SERVERS: usize = 128;
75pub(super) const MAX_WEBSOCKET_SERVERS: usize = 16;
76
77thread_local! {
78    static TRANSPORT_HANDLE_COUNTER: RefCell<u64> = const { RefCell::new(0) };
79    static HTTP_SERVERS: RefCell<HashMap<String, HttpServer>> = RefCell::new(HashMap::new());
80}
81
82/// Reset thread-local HTTP mock state. Call between test runs.
83pub fn reset_http_state() {
84    reset_http_mocks();
85    client::reset_client_state();
86    streaming::reset_streaming_state();
87    TRANSPORT_HANDLE_COUNTER.with(|counter| *counter.borrow_mut() = 0);
88    HTTP_SERVERS.with(|servers| servers.borrow_mut().clear());
89}
90
91pub(super) fn vm_error(message: impl Into<String>) -> VmError {
92    VmError::Thrown(VmValue::String(Rc::from(message.into())))
93}
94
95pub(super) fn next_transport_handle(prefix: &str) -> String {
96    TRANSPORT_HANDLE_COUNTER.with(|counter| {
97        let mut counter = counter.borrow_mut();
98        *counter += 1;
99        format!("{prefix}-{}", *counter)
100    })
101}
102
103pub(super) fn handle_from_value(value: &VmValue, builtin: &str) -> Result<String, VmError> {
104    match value {
105        VmValue::String(handle) => Ok(handle.to_string()),
106        VmValue::Dict(dict) => dict
107            .get("id")
108            .map(|id| id.display())
109            .filter(|id| !id.is_empty())
110            .ok_or_else(|| vm_error(format!("{builtin}: handle dict must contain id"))),
111        _ => Err(vm_error(format!(
112            "{builtin}: first argument must be a handle string or dict"
113        ))),
114    }
115}
116
117pub(super) fn get_options_arg(args: &[VmValue], index: usize) -> BTreeMap<String, VmValue> {
118    args.get(index)
119        .and_then(|value| value.as_dict())
120        .cloned()
121        .unwrap_or_default()
122}
123
124fn vm_string(value: impl AsRef<str>) -> VmValue {
125    VmValue::String(Rc::from(value.as_ref()))
126}
127
128fn dict_value(entries: BTreeMap<String, VmValue>) -> VmValue {
129    VmValue::Dict(Rc::new(entries))
130}
131
132fn get_bool_option(options: &BTreeMap<String, VmValue>, key: &str, default: bool) -> bool {
133    match options.get(key) {
134        Some(VmValue::Bool(value)) => *value,
135        _ => default,
136    }
137}
138
139fn get_usize_option(
140    options: &BTreeMap<String, VmValue>,
141    key: &str,
142    default: usize,
143) -> Result<usize, VmError> {
144    match options.get(key).and_then(VmValue::as_int) {
145        Some(value) if value >= 0 => Ok(value as usize),
146        Some(_) => Err(vm_error(format!("http_server: {key} must be non-negative"))),
147        None => Ok(default),
148    }
149}
150
151fn get_optional_usize_option(
152    options: &BTreeMap<String, VmValue>,
153    key: &str,
154) -> Result<Option<usize>, VmError> {
155    match options.get(key).and_then(VmValue::as_int) {
156        Some(value) if value >= 0 => Ok(Some(value as usize)),
157        Some(_) => Err(vm_error(format!(
158            "http_server_route: {key} must be non-negative"
159        ))),
160        None => Ok(None),
161    }
162}
163
164fn server_from_value(value: &VmValue, builtin: &str) -> Result<String, VmError> {
165    handle_from_value(value, builtin)
166}
167
168fn closure_arg(args: &[VmValue], index: usize, builtin: &str) -> Result<Rc<VmClosure>, VmError> {
169    match args.get(index) {
170        Some(VmValue::Closure(closure)) => Ok(closure.clone()),
171        Some(other) => Err(vm_error(format!(
172            "{builtin}: argument {} must be a closure, got {}",
173            index + 1,
174            other.type_name()
175        ))),
176        None => Err(vm_error(format!(
177            "{builtin}: missing closure argument {}",
178            index + 1
179        ))),
180    }
181}
182
183fn http_server_handle_value(id: &str) -> VmValue {
184    let mut dict = BTreeMap::new();
185    dict.insert("id".to_string(), vm_string(id));
186    dict.insert("kind".to_string(), vm_string("http_server"));
187    dict_value(dict)
188}
189
190fn header_lookup_value(headers: &BTreeMap<String, VmValue>, name: &str) -> VmValue {
191    headers
192        .iter()
193        .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
194        .map(|(_, value)| value.clone())
195        .unwrap_or(VmValue::Nil)
196}
197
198fn headers_from_value(value: &VmValue) -> BTreeMap<String, VmValue> {
199    match value {
200        VmValue::Dict(dict) => dict
201            .get("headers")
202            .and_then(VmValue::as_dict)
203            .map(|headers| {
204                headers
205                    .iter()
206                    .map(|(key, value)| (key.to_ascii_lowercase(), vm_string(value.display())))
207                    .collect()
208            })
209            .unwrap_or_else(|| {
210                dict.iter()
211                    .map(|(key, value)| (key.to_ascii_lowercase(), vm_string(value.display())))
212                    .collect()
213            }),
214        _ => BTreeMap::new(),
215    }
216}
217
218fn normalize_headers(value: Option<&VmValue>) -> BTreeMap<String, VmValue> {
219    match value.and_then(VmValue::as_dict) {
220        Some(headers) => headers
221            .iter()
222            .map(|(key, value)| (key.to_ascii_lowercase(), vm_string(value.display())))
223            .collect(),
224        None => BTreeMap::new(),
225    }
226}
227
228fn percent_decode(input: &str) -> String {
229    let bytes = input.as_bytes();
230    let mut out = Vec::with_capacity(bytes.len());
231    let mut i = 0;
232    while i < bytes.len() {
233        if bytes[i] == b'+' {
234            out.push(b' ');
235            i += 1;
236            continue;
237        }
238        if bytes[i] == b'%' && i + 2 < bytes.len() {
239            if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
240                out.push((hi << 4) | lo);
241                i += 3;
242                continue;
243            }
244        }
245        out.push(bytes[i]);
246        i += 1;
247    }
248    String::from_utf8_lossy(&out).into_owned()
249}
250
251fn hex_val(byte: u8) -> Option<u8> {
252    match byte {
253        b'0'..=b'9' => Some(byte - b'0'),
254        b'a'..=b'f' => Some(byte - b'a' + 10),
255        b'A'..=b'F' => Some(byte - b'A' + 10),
256        _ => None,
257    }
258}
259
260fn split_path_and_query(raw_path: &str) -> (String, BTreeMap<String, VmValue>) {
261    let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, ""));
262    let mut query_map = BTreeMap::new();
263    for pair in query.split('&').filter(|part| !part.is_empty()) {
264        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
265        query_map.insert(percent_decode(key), vm_string(percent_decode(value)));
266    }
267    (
268        if path.is_empty() { "/" } else { path }.to_string(),
269        query_map,
270    )
271}
272
273fn request_body_bytes(input: &BTreeMap<String, VmValue>) -> Vec<u8> {
274    match input.get("raw_body").or_else(|| input.get("body")) {
275        Some(VmValue::Bytes(bytes)) => bytes.as_ref().clone(),
276        Some(value) => value.display().into_bytes(),
277        None => Vec::new(),
278    }
279}
280
281fn request_value(
282    method: &str,
283    path: &str,
284    path_params: BTreeMap<String, VmValue>,
285    mut query: BTreeMap<String, VmValue>,
286    input: &BTreeMap<String, VmValue>,
287    body_bytes: &[u8],
288    retain_raw_body: bool,
289) -> VmValue {
290    if let Some(explicit_query) = input.get("query").and_then(VmValue::as_dict) {
291        query.extend(
292            explicit_query
293                .iter()
294                .map(|(key, value)| (key.clone(), value.clone())),
295        );
296    }
297
298    let headers = normalize_headers(input.get("headers"));
299    let body = String::from_utf8_lossy(body_bytes).into_owned();
300    let mut request = BTreeMap::new();
301    request.insert("method".to_string(), vm_string(method));
302    request.insert("path".to_string(), vm_string(path));
303    let path_params = dict_value(path_params);
304    request.insert("path_params".to_string(), path_params.clone());
305    request.insert("params".to_string(), path_params);
306    request.insert("query".to_string(), dict_value(query));
307    request.insert("headers".to_string(), dict_value(headers));
308    request.insert("body".to_string(), vm_string(body));
309    request.insert(
310        "raw_body".to_string(),
311        if retain_raw_body {
312            VmValue::Bytes(Rc::new(body_bytes.to_vec()))
313        } else {
314            VmValue::Nil
315        },
316    );
317    request.insert(
318        "body_bytes".to_string(),
319        VmValue::Int(body_bytes.len() as i64),
320    );
321    request.insert(
322        "remote_addr".to_string(),
323        input
324            .get("remote_addr")
325            .or_else(|| input.get("remote"))
326            .map(|value| vm_string(value.display()))
327            .unwrap_or(VmValue::Nil),
328    );
329    request.insert(
330        "client_ip".to_string(),
331        input
332            .get("client_ip")
333            .or_else(|| input.get("remote_ip"))
334            .or_else(|| input.get("ip"))
335            .map(|value| vm_string(value.display()))
336            .unwrap_or(VmValue::Nil),
337    );
338    dict_value(request)
339}
340
341fn normalize_status(status: i64) -> i64 {
342    if (100..=999).contains(&status) {
343        status
344    } else {
345        500
346    }
347}
348
349fn response_with_kind(
350    status: i64,
351    mut headers: BTreeMap<String, VmValue>,
352    body: VmValue,
353    body_kind: &str,
354) -> VmValue {
355    let status = normalize_status(status);
356    let mut response = BTreeMap::new();
357    if body_kind == "json" && matches!(header_lookup_value(&headers, "content-type"), VmValue::Nil)
358    {
359        headers.insert(
360            "content-type".to_string(),
361            vm_string("application/json; charset=utf-8"),
362        );
363    } else if body_kind == "text"
364        && matches!(header_lookup_value(&headers, "content-type"), VmValue::Nil)
365    {
366        headers.insert(
367            "content-type".to_string(),
368            vm_string("text/plain; charset=utf-8"),
369        );
370    }
371    response.insert("status".to_string(), VmValue::Int(status));
372    response.insert("headers".to_string(), dict_value(headers));
373    response.insert(
374        "ok".to_string(),
375        VmValue::Bool((200..300).contains(&status)),
376    );
377    response.insert("body_kind".to_string(), vm_string(body_kind));
378    match body {
379        VmValue::Bytes(bytes) => {
380            response.insert(
381                "body".to_string(),
382                vm_string(String::from_utf8_lossy(&bytes)),
383            );
384            response.insert("raw_body".to_string(), VmValue::Bytes(bytes));
385        }
386        other => {
387            response.insert("body".to_string(), vm_string(other.display()));
388            response.insert(
389                "raw_body".to_string(),
390                VmValue::Bytes(Rc::new(other.display().into_bytes())),
391            );
392        }
393    }
394    dict_value(response)
395}
396
397fn normalize_response(value: VmValue) -> VmValue {
398    match value {
399        VmValue::Dict(dict) if dict.contains_key("status") => {
400            let status = dict.get("status").and_then(VmValue::as_int).unwrap_or(200);
401            let headers = dict
402                .get("headers")
403                .and_then(VmValue::as_dict)
404                .cloned()
405                .unwrap_or_default();
406            let body_kind = dict
407                .get("body_kind")
408                .or_else(|| dict.get("kind"))
409                .map(|value| value.display())
410                .unwrap_or_else(|| "text".to_string());
411            let body = dict
412                .get("raw_body")
413                .filter(|value| matches!(value, VmValue::Bytes(_)))
414                .or_else(|| dict.get("body"))
415                .cloned()
416                .unwrap_or(VmValue::Nil);
417            response_with_kind(status, headers, body, &body_kind)
418        }
419        VmValue::Nil => response_with_kind(204, BTreeMap::new(), VmValue::Nil, "text"),
420        other => response_with_kind(200, BTreeMap::new(), other, "text"),
421    }
422}
423
424fn body_limit_response(limit: usize, actual: usize) -> VmValue {
425    let mut headers = BTreeMap::new();
426    headers.insert(
427        "content-type".to_string(),
428        vm_string("text/plain; charset=utf-8"),
429    );
430    headers.insert("connection".to_string(), vm_string("close"));
431    headers.insert(
432        "x-harn-body-limit".to_string(),
433        vm_string(limit.to_string()),
434    );
435    response_with_kind(
436        413,
437        headers,
438        vm_string(format!("request body too large: {actual} > {limit} bytes")),
439        "text",
440    )
441}
442
443fn not_found_response(method: &str, path: &str) -> VmValue {
444    response_with_kind(
445        404,
446        BTreeMap::new(),
447        vm_string(format!("no route for {method} {path}")),
448        "text",
449    )
450}
451
452fn unavailable_response(message: &str) -> VmValue {
453    response_with_kind(503, BTreeMap::new(), vm_string(message), "text")
454}
455
456fn route_template_match(template: &str, path: &str) -> Option<BTreeMap<String, VmValue>> {
457    let template_segments: Vec<&str> = template.trim_matches('/').split('/').collect();
458    let path_segments: Vec<&str> = path.trim_matches('/').split('/').collect();
459    if template == "/" && path == "/" {
460        return Some(BTreeMap::new());
461    }
462    if template_segments.len() != path_segments.len() {
463        return None;
464    }
465    let mut params = BTreeMap::new();
466    for (tmpl, actual) in template_segments.iter().zip(path_segments.iter()) {
467        if tmpl.starts_with('{') && tmpl.ends_with('}') && tmpl.len() > 2 {
468            params.insert(
469                tmpl[1..tmpl.len() - 1].to_string(),
470                vm_string(percent_decode(actual)),
471            );
472        } else if tmpl.starts_with(':') && tmpl.len() > 1 {
473            params.insert(tmpl[1..].to_string(), vm_string(percent_decode(actual)));
474        } else if tmpl != actual {
475            return None;
476        }
477    }
478    Some(params)
479}
480
481fn matching_route(
482    server: &HttpServer,
483    method: &str,
484    path: &str,
485) -> Option<(HttpServerRoute, BTreeMap<String, VmValue>)> {
486    server.routes.iter().find_map(|route| {
487        if route.method != "*" && !route.method.eq_ignore_ascii_case(method) {
488            return None;
489        }
490        route_template_match(&route.template, path).map(|params| (route.clone(), params))
491    })
492}
493
494async fn call_server_closure(
495    closure: &Rc<VmClosure>,
496    args: &[VmValue],
497    builtin: &str,
498) -> Result<VmValue, VmError> {
499    let mut vm = crate::vm::clone_async_builtin_child_vm()
500        .ok_or_else(|| vm_error(format!("{builtin}: requires an async builtin VM context")))?;
501    vm.call_closure_pub(closure, args).await
502}
503
504fn value_is_response(value: &VmValue) -> bool {
505    matches!(value, VmValue::Dict(dict) if dict.contains_key("status"))
506}
507
508async fn run_http_server_request(server_id: &str, request: VmValue) -> Result<VmValue, VmError> {
509    let server = HTTP_SERVERS.with(|servers| servers.borrow().get(server_id).cloned());
510    let Some(server) = server else {
511        return Err(vm_error(format!(
512            "http_server_request: unknown server handle '{server_id}'"
513        )));
514    };
515    if server.shutdown {
516        return Ok(unavailable_response("server is shut down"));
517    }
518    if !server.ready {
519        return Ok(unavailable_response("server is not ready"));
520    }
521    if let Some(readiness) = &server.readiness {
522        let ready = call_server_closure(
523            readiness,
524            &[http_server_handle_value(server_id)],
525            "http_server_request",
526        )
527        .await?;
528        if !ready.is_truthy() {
529            return Ok(unavailable_response("server is not ready"));
530        }
531    }
532
533    let input = request.as_dict().cloned().unwrap_or_default();
534    let method = input
535        .get("method")
536        .map(|value| value.display())
537        .filter(|value| !value.is_empty())
538        .unwrap_or_else(|| "GET".to_string())
539        .to_ascii_uppercase();
540    let raw_path = input
541        .get("path")
542        .map(|value| value.display())
543        .filter(|value| !value.is_empty())
544        .unwrap_or_else(|| "/".to_string());
545    let (path, query) = split_path_and_query(&raw_path);
546    let body_bytes = request_body_bytes(&input);
547
548    let Some((route, path_params)) = matching_route(&server, &method, &path) else {
549        return Ok(not_found_response(&method, &path));
550    };
551
552    let limit = route.max_body_bytes.unwrap_or(server.max_body_bytes);
553    if body_bytes.len() > limit {
554        return Ok(body_limit_response(limit, body_bytes.len()));
555    }
556    let retain_raw_body = route.retain_raw_body.unwrap_or(server.retain_raw_body);
557    let mut req = request_value(
558        &method,
559        &path,
560        path_params,
561        query,
562        &input,
563        &body_bytes,
564        retain_raw_body,
565    );
566
567    for before in &server.before {
568        let result = call_server_closure(before, &[req.clone()], "http_server_request").await?;
569        if value_is_response(&result) {
570            return Ok(normalize_response(result));
571        }
572        if !matches!(result, VmValue::Nil) {
573            req = result;
574        }
575    }
576
577    let handler_result =
578        call_server_closure(&route.handler, &[req.clone()], "http_server_request").await?;
579    let mut response = normalize_response(handler_result);
580
581    for after in &server.after {
582        let result = call_server_closure(
583            after,
584            &[response.clone(), req.clone()],
585            "http_server_request",
586        )
587        .await?;
588        if !matches!(result, VmValue::Nil) {
589            response = normalize_response(result);
590        }
591    }
592
593    Ok(response)
594}
595
596/// Register HTTP builtins on a VM.
597pub fn register_http_builtins(vm: &mut Vm) {
598    register_http_tls_builtins(vm);
599    client::register_http_verb_builtins(vm);
600    register_http_server_builtins(vm);
601    register_http_mock_builtins(vm);
602    client::register_http_client_builtins(vm);
603    streaming::register_http_streaming_builtins(vm);
604}
605
606fn register_http_tls_builtins(vm: &mut Vm) {
607    vm.register_builtin("http_server_tls_plain", |_args, _out| {
608        Ok(http_server_tls_config_value(
609            "plain",
610            false,
611            "http",
612            false,
613            BTreeMap::new(),
614        ))
615    });
616    vm.register_builtin("http_server_tls_edge", |args, _out| {
617        let options = get_options_arg(args, 0);
618        Ok(http_server_tls_config_value(
619            "edge",
620            false,
621            "https",
622            vm_get_bool_option(&options, "hsts", true),
623            hsts_options(&options),
624        ))
625    });
626    vm.register_builtin("http_server_tls_pem", |args, _out| {
627        if args.len() < 2 {
628            return Err(vm_error(
629                "http_server_tls_pem: requires cert path and key path",
630            ));
631        }
632        let cert_path = args[0].display();
633        let key_path = args[1].display();
634        if !std::path::Path::new(&cert_path).is_file() {
635            return Err(vm_error(format!(
636                "http_server_tls_pem: certificate not found: {cert_path}"
637            )));
638        }
639        if !std::path::Path::new(&key_path).is_file() {
640            return Err(vm_error(format!(
641                "http_server_tls_pem: private key not found: {key_path}"
642            )));
643        }
644        let mut extra = BTreeMap::new();
645        extra.insert(
646            "cert_path".to_string(),
647            VmValue::String(Rc::from(cert_path)),
648        );
649        extra.insert("key_path".to_string(), VmValue::String(Rc::from(key_path)));
650        Ok(http_server_tls_config_value(
651            "pem", true, "https", true, extra,
652        ))
653    });
654    vm.register_builtin("http_server_tls_self_signed_dev", |args, _out| {
655        let hosts = tls_hosts_arg(args.first())?;
656        let cert = rcgen::generate_simple_self_signed(hosts.clone()).map_err(|error| {
657            vm_error(format!(
658                "http_server_tls_self_signed_dev: failed to generate certificate: {error}"
659            ))
660        })?;
661        let mut extra = BTreeMap::new();
662        extra.insert(
663            "hosts".to_string(),
664            VmValue::List(Rc::new(
665                hosts
666                    .into_iter()
667                    .map(|host| VmValue::String(Rc::from(host)))
668                    .collect(),
669            )),
670        );
671        extra.insert(
672            "cert_pem".to_string(),
673            VmValue::String(Rc::from(cert.cert.pem())),
674        );
675        extra.insert(
676            "key_pem".to_string(),
677            VmValue::String(Rc::from(cert.signing_key.serialize_pem())),
678        );
679        Ok(http_server_tls_config_value(
680            "self_signed_dev",
681            true,
682            "https",
683            false,
684            extra,
685        ))
686    });
687    vm.register_builtin("http_server_security_headers", |args, _out| {
688        let Some(VmValue::Dict(config)) = args.first() else {
689            return Err(vm_error(
690                "http_server_security_headers: requires a TLS config dict",
691            ));
692        };
693        Ok(VmValue::Dict(Rc::new(http_server_security_headers(config))))
694    });
695}
696
697fn register_http_server_builtins(vm: &mut Vm) {
698    // --- Inbound HTTP server primitives ---
699
700    vm.register_builtin("http_server", |args, _out| {
701        let options = get_options_arg(args, 0);
702        let server = HttpServer {
703            routes: Vec::new(),
704            before: Vec::new(),
705            after: Vec::new(),
706            ready: get_bool_option(&options, "ready", true),
707            readiness: None,
708            shutdown_hooks: Vec::new(),
709            shutdown: false,
710            max_body_bytes: get_usize_option(
711                &options,
712                "max_body_bytes",
713                DEFAULT_SERVER_MAX_BODY_BYTES,
714            )?,
715            retain_raw_body: get_bool_option(&options, "retain_raw_body", true),
716        };
717        let id = next_transport_handle("http-server");
718        HTTP_SERVERS.with(|servers| {
719            let mut servers = servers.borrow_mut();
720            if servers.len() >= MAX_HTTP_SERVERS {
721                return Err(vm_error(format!(
722                    "http_server: maximum open servers ({MAX_HTTP_SERVERS}) reached"
723                )));
724            }
725            servers.insert(id.clone(), server);
726            Ok(())
727        })?;
728        Ok(http_server_handle_value(&id))
729    });
730
731    vm.register_builtin("http_server_route", |args, _out| {
732        if args.len() < 4 {
733            return Err(vm_error(
734                "http_server_route: requires server, method, path template, and handler",
735            ));
736        }
737        let server_id = server_from_value(&args[0], "http_server_route")?;
738        let method = args[1].display().to_ascii_uppercase();
739        if method.is_empty() {
740            return Err(vm_error("http_server_route: method is required"));
741        }
742        let template = args[2].display();
743        if !template.starts_with('/') {
744            return Err(vm_error(
745                "http_server_route: path template must start with '/'",
746            ));
747        }
748        let handler = closure_arg(args, 3, "http_server_route")?;
749        let options = get_options_arg(args, 4);
750        let route = HttpServerRoute {
751            method,
752            template,
753            handler,
754            max_body_bytes: get_optional_usize_option(&options, "max_body_bytes")?,
755            retain_raw_body: match options.get("retain_raw_body") {
756                Some(VmValue::Bool(value)) => Some(*value),
757                _ => None,
758            },
759        };
760        HTTP_SERVERS.with(|servers| {
761            let mut servers = servers.borrow_mut();
762            let server = servers.get_mut(&server_id).ok_or_else(|| {
763                vm_error(format!("http_server_route: unknown server '{server_id}'"))
764            })?;
765            server.routes.push(route);
766            Ok::<_, VmError>(())
767        })?;
768        Ok(http_server_handle_value(&server_id))
769    });
770
771    vm.register_builtin("http_server_before", |args, _out| {
772        if args.len() < 2 {
773            return Err(vm_error("http_server_before: requires server and handler"));
774        }
775        let server_id = server_from_value(&args[0], "http_server_before")?;
776        let handler = closure_arg(args, 1, "http_server_before")?;
777        HTTP_SERVERS.with(|servers| {
778            let mut servers = servers.borrow_mut();
779            let server = servers.get_mut(&server_id).ok_or_else(|| {
780                vm_error(format!("http_server_before: unknown server '{server_id}'"))
781            })?;
782            server.before.push(handler);
783            Ok::<_, VmError>(())
784        })?;
785        Ok(http_server_handle_value(&server_id))
786    });
787
788    vm.register_builtin("http_server_after", |args, _out| {
789        if args.len() < 2 {
790            return Err(vm_error("http_server_after: requires server and handler"));
791        }
792        let server_id = server_from_value(&args[0], "http_server_after")?;
793        let handler = closure_arg(args, 1, "http_server_after")?;
794        HTTP_SERVERS.with(|servers| {
795            let mut servers = servers.borrow_mut();
796            let server = servers.get_mut(&server_id).ok_or_else(|| {
797                vm_error(format!("http_server_after: unknown server '{server_id}'"))
798            })?;
799            server.after.push(handler);
800            Ok::<_, VmError>(())
801        })?;
802        Ok(http_server_handle_value(&server_id))
803    });
804
805    vm.register_async_builtin("http_server_request", |args| async move {
806        if args.len() < 2 {
807            return Err(vm_error("http_server_request: requires server and request"));
808        }
809        let server_id = server_from_value(&args[0], "http_server_request")?;
810        run_http_server_request(&server_id, args[1].clone()).await
811    });
812
813    vm.register_async_builtin("http_server_test", |args| async move {
814        if args.len() < 2 {
815            return Err(vm_error("http_server_test: requires server and request"));
816        }
817        let server_id = server_from_value(&args[0], "http_server_test")?;
818        run_http_server_request(&server_id, args[1].clone()).await
819    });
820
821    vm.register_builtin("http_server_set_ready", |args, _out| {
822        if args.len() < 2 {
823            return Err(vm_error(
824                "http_server_set_ready: requires server and ready bool",
825            ));
826        }
827        let server_id = server_from_value(&args[0], "http_server_set_ready")?;
828        let ready = matches!(args[1], VmValue::Bool(true));
829        HTTP_SERVERS.with(|servers| {
830            let mut servers = servers.borrow_mut();
831            let server = servers.get_mut(&server_id).ok_or_else(|| {
832                vm_error(format!(
833                    "http_server_set_ready: unknown server '{server_id}'"
834                ))
835            })?;
836            server.ready = ready;
837            Ok::<_, VmError>(())
838        })?;
839        Ok(VmValue::Bool(ready))
840    });
841
842    vm.register_builtin("http_server_readiness", |args, _out| {
843        if args.len() < 2 {
844            return Err(vm_error(
845                "http_server_readiness: requires server and readiness closure",
846            ));
847        }
848        let server_id = server_from_value(&args[0], "http_server_readiness")?;
849        let handler = closure_arg(args, 1, "http_server_readiness")?;
850        HTTP_SERVERS.with(|servers| {
851            let mut servers = servers.borrow_mut();
852            let server = servers.get_mut(&server_id).ok_or_else(|| {
853                vm_error(format!(
854                    "http_server_readiness: unknown server '{server_id}'"
855                ))
856            })?;
857            server.readiness = Some(handler);
858            Ok::<_, VmError>(())
859        })?;
860        Ok(http_server_handle_value(&server_id))
861    });
862
863    vm.register_async_builtin("http_server_ready", |args| async move {
864        let Some(server_arg) = args.first() else {
865            return Err(vm_error("http_server_ready: requires server"));
866        };
867        let server_id = server_from_value(server_arg, "http_server_ready")?;
868        let server = HTTP_SERVERS.with(|servers| servers.borrow().get(&server_id).cloned());
869        let Some(server) = server else {
870            return Err(vm_error(format!(
871                "http_server_ready: unknown server '{server_id}'"
872            )));
873        };
874        if server.shutdown {
875            return Ok(VmValue::Bool(false));
876        }
877        let Some(readiness) = server.readiness else {
878            return Ok(VmValue::Bool(server.ready));
879        };
880        let result = call_server_closure(
881            &readiness,
882            &[http_server_handle_value(&server_id)],
883            "http_server_ready",
884        )
885        .await?;
886        Ok(VmValue::Bool(result.is_truthy()))
887    });
888
889    vm.register_builtin("http_server_on_shutdown", |args, _out| {
890        if args.len() < 2 {
891            return Err(vm_error(
892                "http_server_on_shutdown: requires server and handler",
893            ));
894        }
895        let server_id = server_from_value(&args[0], "http_server_on_shutdown")?;
896        let handler = closure_arg(args, 1, "http_server_on_shutdown")?;
897        HTTP_SERVERS.with(|servers| {
898            let mut servers = servers.borrow_mut();
899            let server = servers.get_mut(&server_id).ok_or_else(|| {
900                vm_error(format!(
901                    "http_server_on_shutdown: unknown server '{server_id}'"
902                ))
903            })?;
904            server.shutdown_hooks.push(handler);
905            Ok::<_, VmError>(())
906        })?;
907        Ok(http_server_handle_value(&server_id))
908    });
909
910    vm.register_async_builtin("http_server_shutdown", |args| async move {
911        let Some(server_arg) = args.first() else {
912            return Err(vm_error("http_server_shutdown: requires server"));
913        };
914        let server_id = server_from_value(server_arg, "http_server_shutdown")?;
915        let hooks = HTTP_SERVERS.with(|servers| {
916            let mut servers = servers.borrow_mut();
917            let server = servers.get_mut(&server_id).ok_or_else(|| {
918                vm_error(format!(
919                    "http_server_shutdown: unknown server '{server_id}'"
920                ))
921            })?;
922            server.shutdown = true;
923            Ok::<_, VmError>(server.shutdown_hooks.clone())
924        })?;
925        for hook in hooks {
926            let _ = call_server_closure(
927                &hook,
928                &[http_server_handle_value(&server_id)],
929                "http_server_shutdown",
930            )
931            .await?;
932        }
933        Ok(VmValue::Bool(true))
934    });
935
936    vm.register_builtin("http_response", |args, _out| {
937        let status = args.first().and_then(VmValue::as_int).unwrap_or(200);
938        let body = args.get(1).cloned().unwrap_or(VmValue::Nil);
939        let headers = args
940            .get(2)
941            .and_then(VmValue::as_dict)
942            .cloned()
943            .unwrap_or_default();
944        Ok(response_with_kind(status, headers, body, "text"))
945    });
946
947    vm.register_builtin("http_response_text", |args, _out| {
948        let body = args.first().cloned().unwrap_or(VmValue::Nil);
949        let options = get_options_arg(args, 1);
950        let status = options
951            .get("status")
952            .and_then(VmValue::as_int)
953            .unwrap_or(200);
954        let headers = options
955            .get("headers")
956            .and_then(VmValue::as_dict)
957            .cloned()
958            .unwrap_or_default();
959        Ok(response_with_kind(status, headers, body, "text"))
960    });
961
962    vm.register_builtin("http_response_json", |args, _out| {
963        let body = args
964            .first()
965            .map(crate::stdlib::json::vm_value_to_json)
966            .map(vm_string)
967            .unwrap_or_else(|| vm_string("null"));
968        let options = get_options_arg(args, 1);
969        let status = options
970            .get("status")
971            .and_then(VmValue::as_int)
972            .unwrap_or(200);
973        let headers = options
974            .get("headers")
975            .and_then(VmValue::as_dict)
976            .cloned()
977            .unwrap_or_default();
978        Ok(response_with_kind(status, headers, body, "json"))
979    });
980
981    vm.register_builtin("http_response_bytes", |args, _out| {
982        let body = match args.first() {
983            Some(VmValue::Bytes(bytes)) => VmValue::Bytes(bytes.clone()),
984            Some(value) => VmValue::Bytes(Rc::new(value.display().into_bytes())),
985            None => VmValue::Bytes(Rc::new(Vec::new())),
986        };
987        let options = get_options_arg(args, 1);
988        let status = options
989            .get("status")
990            .and_then(VmValue::as_int)
991            .unwrap_or(200);
992        let headers = options
993            .get("headers")
994            .and_then(VmValue::as_dict)
995            .cloned()
996            .unwrap_or_default();
997        Ok(response_with_kind(status, headers, body, "bytes"))
998    });
999
1000    vm.register_builtin("http_header", |args, _out| {
1001        if args.len() < 2 {
1002            return Err(vm_error(
1003                "http_header: requires headers/request/response and name",
1004            ));
1005        }
1006        let headers = headers_from_value(&args[0]);
1007        Ok(header_lookup_value(&headers, &args[1].display()))
1008    });
1009}
1010
1011fn register_http_mock_builtins(vm: &mut Vm) {
1012    // --- Mock HTTP builtins ---
1013
1014    // http_mock(method, url_pattern, response) -> nil
1015    //
1016    // Calling http_mock again with the same (method, url_pattern) tuple
1017    // *replaces* the prior mock for that target — tests can override a
1018    // per-case response without first calling http_mock_clear().
1019    vm.register_builtin("http_mock", |args, _out| {
1020        let method = args.first().map(|a| a.display()).unwrap_or_default();
1021        let url_pattern = args.get(1).map(|a| a.display()).unwrap_or_default();
1022        let response = args
1023            .get(2)
1024            .and_then(|a| a.as_dict())
1025            .cloned()
1026            .unwrap_or_default();
1027        let responses = parse_mock_responses(&response);
1028
1029        register_http_mock(method, url_pattern, responses);
1030        Ok(VmValue::Nil)
1031    });
1032
1033    // http_mock_clear() -> nil
1034    vm.register_builtin("http_mock_clear", |_args, _out| {
1035        clear_http_mocks();
1036        client::clear_http_streams();
1037        Ok(VmValue::Nil)
1038    });
1039
1040    // http_mock_calls(options?) -> list of {method, url, headers, body}
1041    vm.register_builtin("http_mock_calls", |args, _out| {
1042        let options = get_options_arg(args, 0);
1043        let include_sensitive = get_bool_option(&options, "include_sensitive", false)
1044            || get_bool_option(&options, "include_sensitive_headers", false);
1045        let redact_sensitive = get_bool_option(
1046            &options,
1047            "redact_sensitive",
1048            get_bool_option(&options, "redact_headers", true),
1049        ) && !include_sensitive;
1050        Ok(VmValue::List(Rc::new(http_mock_calls_value(
1051            redact_sensitive,
1052        ))))
1053    });
1054}
1055
1056fn http_server_tls_config_value(
1057    mode: &str,
1058    terminate_tls: bool,
1059    scheme: &str,
1060    hsts: bool,
1061    extra: BTreeMap<String, VmValue>,
1062) -> VmValue {
1063    let mut dict = BTreeMap::new();
1064    dict.insert("mode".to_string(), VmValue::String(Rc::from(mode)));
1065    dict.insert("terminate_tls".to_string(), VmValue::Bool(terminate_tls));
1066    dict.insert("scheme".to_string(), VmValue::String(Rc::from(scheme)));
1067    dict.insert("hsts".to_string(), VmValue::Bool(hsts));
1068    for (key, value) in extra {
1069        dict.insert(key, value);
1070    }
1071    VmValue::Dict(Rc::new(dict))
1072}
1073
1074fn hsts_options(options: &BTreeMap<String, VmValue>) -> BTreeMap<String, VmValue> {
1075    let mut hsts = BTreeMap::new();
1076    hsts.insert(
1077        "hsts_max_age_seconds".to_string(),
1078        VmValue::Int(vm_get_int_option(
1079            options,
1080            "hsts_max_age_seconds",
1081            31_536_000,
1082        )),
1083    );
1084    hsts.insert(
1085        "hsts_include_subdomains".to_string(),
1086        VmValue::Bool(vm_get_bool_option(
1087            options,
1088            "hsts_include_subdomains",
1089            false,
1090        )),
1091    );
1092    hsts.insert(
1093        "hsts_preload".to_string(),
1094        VmValue::Bool(vm_get_bool_option(options, "hsts_preload", false)),
1095    );
1096    hsts
1097}
1098
1099fn http_server_security_headers(config: &BTreeMap<String, VmValue>) -> BTreeMap<String, VmValue> {
1100    let hsts_enabled = vm_get_bool_option(config, "hsts", false);
1101    if !hsts_enabled {
1102        return BTreeMap::new();
1103    }
1104    let mut value = format!(
1105        "max-age={}",
1106        vm_get_int_option(config, "hsts_max_age_seconds", 31_536_000).max(0)
1107    );
1108    if vm_get_bool_option(config, "hsts_include_subdomains", false) {
1109        value.push_str("; includeSubDomains");
1110    }
1111    if vm_get_bool_option(config, "hsts_preload", false) {
1112        value.push_str("; preload");
1113    }
1114    BTreeMap::from([(
1115        "strict-transport-security".to_string(),
1116        VmValue::String(Rc::from(value)),
1117    )])
1118}
1119
1120fn tls_hosts_arg(value: Option<&VmValue>) -> Result<Vec<String>, VmError> {
1121    match value {
1122        None | Some(VmValue::Nil) => Ok(vec!["localhost".to_string(), "127.0.0.1".to_string()]),
1123        Some(VmValue::List(hosts)) => {
1124            let mut parsed = Vec::new();
1125            for host in hosts.iter() {
1126                let host = host.display();
1127                if host.is_empty() {
1128                    return Err(vm_error(
1129                        "http_server_tls_self_signed_dev: host names must be non-empty",
1130                    ));
1131                }
1132                parsed.push(host);
1133            }
1134            if parsed.is_empty() {
1135                return Err(vm_error(
1136                    "http_server_tls_self_signed_dev: host list must not be empty",
1137                ));
1138            }
1139            Ok(parsed)
1140        }
1141        Some(other) => {
1142            let host = other.display();
1143            if host.is_empty() {
1144                return Err(vm_error(
1145                    "http_server_tls_self_signed_dev: host name must be non-empty",
1146                ));
1147            }
1148            Ok(vec![host])
1149        }
1150    }
1151}
1152
1153pub(super) fn vm_get_int_option(
1154    options: &BTreeMap<String, VmValue>,
1155    key: &str,
1156    default: i64,
1157) -> i64 {
1158    options.get(key).and_then(|v| v.as_int()).unwrap_or(default)
1159}
1160
1161pub(super) fn vm_get_bool_option(
1162    options: &BTreeMap<String, VmValue>,
1163    key: &str,
1164    default: bool,
1165) -> bool {
1166    match options.get(key) {
1167        Some(VmValue::Bool(b)) => *b,
1168        _ => default,
1169    }
1170}
1171
1172pub(super) fn vm_get_int_option_prefer(
1173    options: &BTreeMap<String, VmValue>,
1174    canonical: &str,
1175    alias: &str,
1176    default: i64,
1177) -> i64 {
1178    options
1179        .get(canonical)
1180        .and_then(|value| value.as_int())
1181        .or_else(|| options.get(alias).and_then(|value| value.as_int()))
1182        .unwrap_or(default)
1183}
1184
1185pub(super) fn vm_get_optional_int_option(
1186    options: &BTreeMap<String, VmValue>,
1187    key: &str,
1188) -> Option<u64> {
1189    options
1190        .get(key)
1191        .and_then(|value| value.as_int())
1192        .map(|value| value.max(0) as u64)
1193}
1194
1195pub(super) fn string_option(options: &BTreeMap<String, VmValue>, key: &str) -> Option<String> {
1196    options
1197        .get(key)
1198        .map(|value| value.display())
1199        .filter(|value| !value.is_empty())
1200}