Skip to main content

submilli_engine/stdlib/http/
mod.rs

1//! `submilli:http` — agent-facing HTTP client library.
2//!
3//! Pure Rust host functions registered directly under the package name.
4//! `Response` and `DownloadResult` are host-built backing structs the guest
5//! holds opaquely and reads through the registered getters; `Headers` maps are
6//! real prelude `Map<string, string>`s built and consumed host-side. The
7//! embedder-facing transport traits live in [`transport`], the SSRF policy in
8//! [`policy`].
9
10use crate::runtime::effects;
11use crate::runtime::host::{
12    abi_arg, abi_result, read_boxed_number, read_string_arg, read_uint8_array_arg,
13    register_host_fn, register_host_value_async, write_submilli_string_struct,
14};
15mod declaration;
16pub mod policy;
17mod redirect_guard;
18pub mod transport;
19mod transport_policy;
20#[cfg(test)]
21mod transport_policy_tests;
22
23use wasmtime::{
24    Caller, FuncType, HeapType, Linker, RefType, Rooted, StructRef, StructType, Val, ValType,
25};
26
27use crate::runtime::call_log::{Payload, Side, mask_headers, mask_url, record_payload};
28use crate::runtime::fs::{ContainError, ContentPath};
29use crate::runtime::fuel;
30use crate::runtime::intrinsic_types::{IntrinsicTypes, build_intrinsic_types, intrinsic_types};
31use crate::runtime::metrics::{HttpMetric, MetricsSink};
32use crate::runtime::prelude::collection::{is_a, object_field, unbox_bool};
33use crate::runtime::prelude::map;
34use crate::runtime::prelude::vtable::dispatch_vtable_slot;
35use crate::runtime::{QuotaCharge, QuotaExceeded, StoreData};
36use crate::stdlib::abi::{
37    self, HostView, backing_receiver, backing_struct, f64_field, i32_field, install_field_getters,
38    nullable_object_field, string_field,
39};
40use crate::stdlib::dot_segments::refuse_dot_segments;
41use crate::stdlib::shared::{
42    check_security_call, contain_trap, quota_refusal, refuse_volume_root, require_writable,
43    resolve_content_or_trap,
44};
45use redirect_guard::{
46    CapabilityGuard, DownloadTarget, GuardedRequest, RequestForm, host_and_path, request_check,
47};
48use transport::{DownloadMeta, DownloadProgress, http_failure_outcome};
49
50pub const MODULE_NAME: &str = "submilli:http";
51
52pub use declaration::package_declaration;
53pub use policy::NetworkPolicy;
54pub use transport::{
55    AuthProxy, AuthProxyError, EgressAt, HttpClient, HttpError, HttpRequest, HttpResponse,
56    NoopAuthProxy, RecordedRequest, RedirectDenied, RedirectGuard, RedirectHop, ReqwestHttpClient,
57    default_auth_proxy, default_http_client, describe_error_chain, is_policy_refusal,
58};
59pub use transport_policy::{HttpTransportPolicy, TransportPolicyError};
60
61/// Verb-form helpers' per-request timeout; `download` defaults to
62/// [`DOWNLOAD_TIMEOUT_MS`] instead (downloads are usually larger).
63const DEFAULT_TIMEOUT_MS: u64 = 30_000;
64const DOWNLOAD_TIMEOUT_MS: u64 = 60_000;
65
66/// `toJson` is slot 1 of the four-slot `$VTable`.
67const TO_JSON_SLOT: usize = 1;
68
69// `$ResponseBacking` field indices (0 is the vtable).
70const R_BODY: usize = 1;
71const R_HEADERS: usize = 2;
72const R_OK: usize = 3;
73const R_STATUS: usize = 4;
74const R_STATUS_TEXT: usize = 5;
75const R_URL: usize = 6;
76
77// `$DownloadResultBacking` field indices (0 is the vtable).
78const D_BYTES_WRITTEN: usize = 1;
79const D_CONTENT_TYPE: usize = 2;
80const D_DURATION_MS: usize = 3;
81const D_FINAL_URL: usize = 4;
82const D_PATH: usize = 5;
83const D_STATUS: usize = 6;
84
85/// `$ResponseBacking` — a host-only `$Object` subtype; the guest holds it as
86/// `(ref null $Object)` and reads it through the registered getters, so the
87/// layout is the host's to choose.
88fn response_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
89    let intr = build_intrinsic_types(engine)?;
90    backing_struct(
91        engine,
92        &intr,
93        vec![
94            string_field(&intr),          // body
95            nullable_object_field(&intr), // headers map
96            i32_field(),                  // ok
97            f64_field(),                  // status
98            string_field(&intr),          // statusText
99            string_field(&intr),          // url (final, after redirects)
100        ],
101    )
102}
103
104/// `$DownloadResultBacking` — same host-only pattern as `$ResponseBacking`.
105fn download_result_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
106    let intr = build_intrinsic_types(engine)?;
107    backing_struct(
108        engine,
109        &intr,
110        vec![
111            f64_field(),         // bytesWritten
112            string_field(&intr), // contentType
113            f64_field(),         // duration_ms
114            string_field(&intr), // finalUrl
115            string_field(&intr), // path
116            f64_field(),         // status
117        ],
118    )
119}
120
121#[derive(serde::Serialize)]
122struct DownloadRequest {
123    url: String,
124    path: String,
125    options: DownloadOptions,
126}
127
128pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
129    let engine = linker.engine().clone();
130    let intr = build_intrinsic_types(&engine)?;
131    let string = ValType::Ref(RefType::new(
132        false,
133        HeapType::ConcreteStruct(intr.string.clone()),
134    ));
135    let object = ValType::Ref(RefType::new(
136        false,
137        HeapType::ConcreteStruct(intr.object.clone()),
138    ));
139    let nullable_object = ValType::Ref(RefType::new(
140        true,
141        HeapType::ConcreteStruct(intr.object.clone()),
142    ));
143
144    // Body-less verbs: (url, headers?) → Response.
145    for verb in ["get", "delete", "head", "options"] {
146        let method = verb.to_ascii_uppercase();
147        register_host_value_async(
148            linker,
149            MODULE_NAME,
150            crate::mangle::package_symbol(MODULE_NAME, verb),
151            FuncType::new(
152                &engine,
153                [string.clone(), nullable_object.clone()],
154                [nullable_object.clone()],
155            ),
156            move |caller, params| {
157                let method = method.clone();
158                Box::pin(async move {
159                    let url = read_string_arg(&mut *caller, abi_arg(params, 0)?, "http (url)")?;
160                    let body = crate::runtime::prelude::undefined::value(caller)?;
161                    perform_request(
162                        caller,
163                        RequestForm::Verb,
164                        &method,
165                        &url,
166                        &body,
167                        abi_arg(params, 1)?,
168                    )
169                    .await
170                })
171            },
172        )?;
173    }
174
175    // Body-carrying verbs: (url, body?, headers?) → Response.
176    for verb in ["post", "put", "patch"] {
177        let method = verb.to_ascii_uppercase();
178        register_host_value_async(
179            linker,
180            MODULE_NAME,
181            crate::mangle::package_symbol(MODULE_NAME, verb),
182            FuncType::new(
183                &engine,
184                [
185                    string.clone(),
186                    nullable_object.clone(),
187                    nullable_object.clone(),
188                ],
189                [nullable_object.clone()],
190            ),
191            move |caller, params| {
192                let method = method.clone();
193                Box::pin(async move {
194                    let url = read_string_arg(&mut *caller, abi_arg(params, 0)?, "http (url)")?;
195                    perform_request(
196                        caller,
197                        RequestForm::Verb,
198                        &method,
199                        &url,
200                        abi_arg(params, 1)?,
201                        abi_arg(params, 2)?,
202                    )
203                    .await
204                })
205            },
206        )?;
207    }
208
209    // Runtime-verb form: (method, url, body?, headers?) → Response.
210    register_host_value_async(
211        linker,
212        MODULE_NAME,
213        crate::mangle::package_symbol(MODULE_NAME, "request"),
214        FuncType::new(
215            &engine,
216            [
217                string.clone(),
218                string.clone(),
219                nullable_object.clone(),
220                nullable_object.clone(),
221            ],
222            [nullable_object.clone()],
223        ),
224        |caller, params| {
225            Box::pin(async move {
226                let method =
227                    read_string_arg(&mut *caller, abi_arg(params, 0)?, "http.request (method)")?;
228                let url = read_string_arg(&mut *caller, abi_arg(params, 1)?, "http.request (url)")?;
229                perform_request(
230                    caller,
231                    RequestForm::Request,
232                    &method,
233                    &url,
234                    abi_arg(params, 2)?,
235                    abi_arg(params, 3)?,
236                )
237                .await
238            })
239        },
240    )?;
241
242    register_host_value_async(
243        linker,
244        MODULE_NAME,
245        crate::mangle::package_symbol(MODULE_NAME, "download"),
246        FuncType::new(
247            &engine,
248            [string.clone(), string, nullable_object.clone()],
249            [nullable_object],
250        ),
251        |caller, params| Box::pin(async move { perform_download(caller, params).await }),
252    )?;
253
254    install_response_members(linker, &engine, &intr, object.clone())?;
255    install_download_result_members(linker, &engine, &intr, object)?;
256    Ok(())
257}
258
259// ---------------------------------------------------------------------------
260// Request path
261// ---------------------------------------------------------------------------
262
263/// What the guest passed as the request body, discriminated host-side.
264enum RequestBody {
265    Empty,
266    /// UTF-8 text; defaults `Content-Type: text/plain; charset=utf-8`.
267    Text(Vec<u8>),
268    /// JSON-encoded object/array; defaults `Content-Type: application/json`.
269    Json(Vec<u8>),
270    /// Raw bytes; never defaults a Content-Type.
271    Binary(Vec<u8>),
272}
273
274impl RequestBody {
275    fn bytes(self) -> Vec<u8> {
276        match self {
277            RequestBody::Empty => Vec::new(),
278            RequestBody::Text(b) | RequestBody::Json(b) | RequestBody::Binary(b) => b,
279        }
280    }
281
282    fn default_content_type(&self) -> Option<&'static str> {
283        match self {
284            RequestBody::Text(_) => Some("text/plain; charset=utf-8"),
285            RequestBody::Json(_) => Some("application/json"),
286            RequestBody::Empty | RequestBody::Binary(_) => None,
287        }
288    }
289}
290
291/// Discriminate the `string | Uint8Array | object | Array | null | undefined` body union.
292/// Objects and arrays serialize through their `toJson` vtable slot (which may
293/// re-enter the guest for user classes).
294async fn read_request_body(
295    caller: &mut Caller<'_, StoreData>,
296    val: &Val,
297) -> wasmtime::Result<RequestBody> {
298    if crate::runtime::prelude::undefined::is_undefined(caller, val)? {
299        return Ok(RequestBody::Empty);
300    }
301    if matches!(val, Val::AnyRef(None)) {
302        return Ok(RequestBody::Json(b"null".to_vec()));
303    }
304    let intr = intrinsic_types(&mut *caller)?;
305    if is_a(caller, val, &intr.string)? {
306        let text = read_string_arg(caller, val, "http (body)")?;
307        return Ok(RequestBody::Text(text.into_bytes()));
308    }
309    if is_a(caller, val, &intr.uint8_array)? {
310        return Ok(RequestBody::Binary(read_uint8_array_arg(
311            caller,
312            val,
313            "http (body)",
314        )?));
315    }
316    let json_val = dispatch_vtable_slot(caller, val, TO_JSON_SLOT, &[]).await?;
317    let json = read_string_arg(caller, &json_val, "http (body json)")?;
318    Ok(RequestBody::Json(json.into_bytes()))
319}
320
321/// Read a `Headers | undefined` param into name/value pairs, names as given —
322/// casing is the caller's; lookups here are case-insensitive.
323fn read_headers(
324    caller: &mut Caller<'_, StoreData>,
325    val: &Val,
326) -> wasmtime::Result<Vec<(String, String)>> {
327    if crate::runtime::prelude::undefined::is_undefined(caller, val)? {
328        return Ok(Vec::new());
329    }
330    map::string_entries(caller, val)
331}
332
333/// [`host_and_path`] of an unparsed URL; empty strings when it doesn't parse
334/// (the transport reports the real failure).
335fn url_host_and_path(url: &str) -> (String, String) {
336    url::Url::parse(url).map_or_else(|_| (String::new(), String::new()), |u| host_and_path(&u))
337}
338
339/// Record one transport operation, mapping a failure to its bounded outcome class.
340fn record_http_metric(
341    metrics: &dyn MetricsSink,
342    capability: String,
343    host: String,
344    duration_ms: u64,
345    outcome: Result<(u16, u64), &HttpError>,
346) {
347    let (status, bytes, outcome) = match outcome {
348        Ok((status, bytes)) => (status, bytes, "ok"),
349        Err(e) => (0, 0, http_failure_outcome(e)),
350    };
351    metrics.http_operation(HttpMetric {
352        capability,
353        host,
354        duration_ms,
355        status,
356        bytes,
357        outcome,
358    });
359}
360
361/// The shared verb/`request` path: discriminate the body, default the
362/// Content-Type, gate the capability, run the transport, and build the
363/// `$ResponseBacking` the guest sees.
364async fn perform_request(
365    caller: &mut Caller<'_, StoreData>,
366    form: RequestForm,
367    method: &str,
368    url: &str,
369    body_val: &Val,
370    headers_val: &Val,
371) -> wasmtime::Result<Val> {
372    // Before the body, whose `toJson` may run guest code, so a refused URL has no effects.
373    refuse_dot_segments(url)
374        .map_err(|refusal| refusal.into_error(&format!("http {}", method.to_ascii_uppercase())))?;
375    let body = read_request_body(caller, body_val).await?;
376    let mut headers = read_headers(caller, headers_val)?;
377
378    // User-supplied Content-Type (any casing) always wins over the body-shaped default.
379    if let Some(default_ct) = body.default_content_type()
380        && !headers
381            .iter()
382            .any(|(name, _)| name.eq_ignore_ascii_case("content-type"))
383    {
384        headers.push(("content-type".to_string(), default_ct.to_string()));
385    }
386    let body = body.bytes();
387
388    let (host_str, path_str) = url_host_and_path(url);
389    let (capability, context) = request_check(
390        form,
391        method,
392        &host_str,
393        &path_str,
394        body.len() as u64,
395        DEFAULT_TIMEOUT_MS,
396    );
397    let ticket = check_security_call(&mut *caller, &capability, context)?;
398    // The program's own request, before the auth proxy adds credentials.
399    record_payload(&*caller, ticket, Side::Request, || {
400        request_payload(method, url, &headers, &body)
401    });
402    let recorded_as = recorded_request(&*caller, || request_payload(method, url, &headers, &body));
403
404    let (who, guard) = request_principal(
405        caller,
406        GuardedRequest::Send {
407            form,
408            timeout_ms: DEFAULT_TIMEOUT_MS,
409        },
410        ticket,
411    )?;
412    let req = HttpRequest {
413        method: method.to_ascii_uppercase(),
414        url: url.to_string(),
415        headers,
416        body,
417        timeout_ms: DEFAULT_TIMEOUT_MS,
418        max_response_size: caller.data().http_max_response_size,
419        decompress: false,
420        transport_policy: None,
421        redirect_guard: None,
422        recorded_as,
423    };
424    let response = effects::execute(caller, "http.request", req, move |caller, _, req| {
425        Box::pin(async move {
426            let method = req.method.clone();
427            let auth_proxy = std::sync::Arc::clone(&caller.data().auth_proxy);
428            let http_client = std::sync::Arc::clone(&caller.data().http_client);
429            let mut req = auth_proxy
430                .transform(req, &who)
431                .await
432                .map_err(|e| wasmtime::Error::msg(format!("http {method}: auth proxy: {e}")))?;
433            // Attached after the proxy, so no proxy can drop it and leave hops unchecked.
434            req.redirect_guard = Some(guard);
435
436            // The request's bytes are the work before any effect; the response's are
437            // charged once it is here, since a stop in between would lose it.
438            fuel::charge(&mut *caller, fuel::IO, request_bytes(&req))?;
439            let metrics = std::sync::Arc::clone(&caller.data().metrics);
440            let start = std::time::Instant::now();
441            let send_result = http_client.send(&req).await;
442            let duration_ms = start.elapsed().as_millis() as u64;
443            record_http_metric(
444                metrics.as_ref(),
445                capability,
446                host_str,
447                duration_ms,
448                send_result
449                    .as_ref()
450                    .map(|resp| (resp.status, resp.body.len() as u64)),
451            );
452            record_payload(&*caller, ticket, Side::Response, || {
453                response_payload(&send_result)
454            });
455            settle_native_response(caller, send_result, &method)
456        })
457    })
458    .await?;
459    write_response(caller, response)
460}
461
462/// The request's key and digest as the call log records them, when the transport reads
463/// them. Computed from the program's own request, before the auth proxy changes it.
464fn recorded_request<'a>(
465    caller: &Caller<'_, StoreData>,
466    payload: impl FnOnce() -> Payload<'a>,
467) -> Option<RecordedRequest> {
468    if !caller.data().http_client.wants_recorded_request() {
469        return None;
470    }
471    let payload = payload();
472    let masked_url = payload.meta["url"].as_str().map(str::to_owned)?;
473    Some(RecordedRequest {
474        masked_url,
475        digest: payload.digest(),
476    })
477}
478
479/// A request as the recorder keeps it: credential headers and URL credentials masked.
480fn request_payload<'a>(
481    method: &str,
482    url: &str,
483    headers: &[(String, String)],
484    body: &'a [u8],
485) -> Payload<'a> {
486    let (headers, masked) = mask_headers(headers);
487    Payload::meta(serde_json::json!({
488        "method": method.to_ascii_uppercase(),
489        "url": mask_url(url),
490        "headers": headers,
491    }))
492    .with_body(body)
493    .with_masked(masked)
494}
495
496/// A response, or the transport failure, as the recorder keeps it.
497fn response_payload(result: &std::result::Result<HttpResponse, HttpError>) -> Payload<'_> {
498    match result {
499        Ok(resp) => {
500            let (headers, masked) = mask_headers(&resp.headers);
501            Payload::meta(serde_json::json!({
502                "status": resp.status,
503                "status_text": resp.status_text,
504                "url": mask_url(&resp.final_url),
505                "headers": headers,
506            }))
507            .with_body(&resp.body)
508            .with_masked(masked)
509        }
510        Err(error) => Payload::meta(failure_meta(error)),
511    }
512}
513
514/// A transport failure as the recorder keeps it: a stable kind beside the message.
515fn failure_meta(error: &HttpError) -> serde_json::Value {
516    serde_json::json!({ "kind": error.kind(), "error": error.to_string() })
517}
518
519fn settle_native_response(
520    caller: &mut Caller<'_, StoreData>,
521    send_result: std::result::Result<HttpResponse, HttpError>,
522    method: &str,
523) -> wasmtime::Result<HttpResponse> {
524    let response = send_result.map_err(|error| {
525        let message = format!("http {method}: {error}");
526        match error {
527            HttpError::TooLarge { .. } => crate::runtime::host::range_error(message),
528            HttpError::UnsupportedMethod(_) => crate::runtime::host::type_error(message),
529            HttpError::Internal(_) => crate::runtime::host::fatal_host_error(message),
530            HttpError::PermissionDenied(denied) => denied.into_error(),
531            _ => wasmtime::Error::msg(message),
532        }
533    })?;
534    fuel::settle(&mut *caller, fuel::IO, response_bytes(&response))?;
535    fuel::settle(&mut *caller, fuel::SCAN, response.body.len() as u64)?;
536    Ok(response)
537}
538
539/// The bytes a request sends: method, URL, headers and body.
540fn request_bytes(req: &HttpRequest) -> u64 {
541    let headers: usize = req.headers.iter().map(|(k, v)| k.len() + v.len()).sum();
542    (req.method.len() + req.url.len() + headers + req.body.len()) as u64
543}
544
545/// The bytes a response carried: status text, final URL, headers and body.
546fn response_bytes(resp: &HttpResponse) -> u64 {
547    let headers: usize = resp.headers.iter().map(|(k, v)| k.len() + v.len()).sum();
548    (resp.status_text.len() + resp.final_url.len() + headers + resp.body.len()) as u64
549}
550
551/// The principal a request is attributed to, and the guard that checks its
552/// redirect hops for that same principal.
553///
554/// The running code, not the last export entered: injection is main-only, so a package
555/// misattributed to `main` would be handed the operator's credentials. An unresolvable
556/// principal keeps its bracketed label, which can never equal `main`.
557fn request_principal(
558    caller: &Caller<'_, StoreData>,
559    request: GuardedRequest,
560    ticket: Option<crate::runtime::decision::CallTicket>,
561) -> wasmtime::Result<(String, std::sync::Arc<CapabilityGuard>)> {
562    let who = crate::stdlib::shared::running_package(caller)
563        .or_else(crate::stdlib::shared::PrincipalError::label_or_error)?;
564    let guard = CapabilityGuard::new(
565        who.clone(),
566        std::sync::Arc::clone(&caller.data().security_check),
567        request,
568        caller.data().vfs.cwd().to_owned(),
569        ticket,
570    );
571    Ok((who, std::sync::Arc::new(guard)))
572}
573
574/// Build the `$ResponseBacking` from a transport [`HttpResponse`].
575fn write_response(caller: &mut Caller<'_, StoreData>, resp: HttpResponse) -> wasmtime::Result<Val> {
576    let body_text = std::str::from_utf8(&resp.body)
577        .map_err(|e| wasmtime::Error::msg(format!("http: response body is not UTF-8: {e}")))?;
578    let body = write_submilli_string_struct(caller, body_text)?.to_anyref();
579    let headers = map::host_string_map_from_pairs(caller, &resp.headers)?;
580    let ok = (200..300).contains(&resp.status);
581    let status_text = write_submilli_string_struct(caller, &resp.status_text)?.to_anyref();
582    let url = write_submilli_string_struct(caller, &resp.final_url)?.to_anyref();
583
584    let ty = response_backing_struct(caller.engine())?;
585    abi::new_backing(
586        caller,
587        ty,
588        &[
589            Val::AnyRef(Some(body)),
590            headers,
591            Val::I32(i32::from(ok)),
592            Val::F64(f64::from(resp.status).to_bits()),
593            Val::AnyRef(Some(status_text)),
594            Val::AnyRef(Some(url)),
595        ],
596    )
597}
598
599// ---------------------------------------------------------------------------
600// Download path
601// ---------------------------------------------------------------------------
602
603/// The `DownloadOptions` bag with every default filled in.
604#[derive(serde::Serialize)]
605struct DownloadOptions {
606    overwrite: bool,
607    max_bytes: u64,
608    headers: Vec<(String, String)>,
609    timeout_ms: u64,
610    decompress: bool,
611}
612
613/// Unpack a `DownloadOptions | undefined` param, filling defaults for absent fields.
614fn read_download_options(
615    caller: &mut Caller<'_, StoreData>,
616    val: &Val,
617) -> wasmtime::Result<DownloadOptions> {
618    let mut options = DownloadOptions {
619        overwrite: false,
620        max_bytes: caller.data().http_max_response_size,
621        headers: Vec::new(),
622        timeout_ms: DOWNLOAD_TIMEOUT_MS.min(caller.data().http_max_download_timeout_ms),
623        decompress: false,
624    };
625    if crate::runtime::prelude::undefined::is_undefined(caller, val)? {
626        return Ok(options);
627    }
628    if let Some(v) = present_field(caller, val, "overwrite")? {
629        options.overwrite = unbox_bool(caller, &v)?;
630    }
631    if let Some(v) = present_field(caller, val, "maxBytes")? {
632        let n = read_boxed_number(caller, &v, "http.download (maxBytes)")?;
633        options.max_bytes = download_limit(n, caller.data().http_max_response_size, "maxBytes")?;
634    }
635    if let Some(v) = present_field(caller, val, "headers")? {
636        options.headers = read_headers(caller, &v)?;
637    }
638    if let Some(v) = present_field(caller, val, "timeout")? {
639        let n = read_boxed_number(caller, &v, "http.download (timeout)")?;
640        options.timeout_ms =
641            download_limit(n, caller.data().http_max_download_timeout_ms, "timeout")?;
642    }
643    if let Some(v) = present_field(caller, val, "decompress")? {
644        options.decompress = unbox_bool(caller, &v)?;
645    }
646    Ok(options)
647}
648
649fn download_limit(value: f64, ceiling: u64, name: &str) -> wasmtime::Result<u64> {
650    if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > ceiling as f64 {
651        return Err(crate::runtime::host::range_error(format!(
652            "http.download: {name} must be a finite integer between 0 and {ceiling}; use a smaller value"
653        )));
654    }
655    Ok((value as u64).min(ceiling))
656}
657
658/// An options-bag field, `None` when absent — an omitted optional field may
659/// still occupy a slot holding `undefined`, which reads as absent.
660fn present_field(
661    caller: &mut Caller<'_, StoreData>,
662    obj: &Val,
663    name: &str,
664) -> wasmtime::Result<Option<Val>> {
665    let Some(value) = object_field(caller, obj, name)? else {
666        return Ok(None);
667    };
668    if crate::runtime::prelude::undefined::is_undefined(caller, &value)? {
669        return Ok(None);
670    }
671    Ok(Some(value))
672}
673
674/// `download(url, path, options?)`: two security checks (`http.download` then
675/// `fs.write`), refuse-on-exists, stream to a temp sibling, then commit with
676/// fsync + atomic rename and build the `$DownloadResultBacking`.
677async fn perform_download(
678    caller: &mut Caller<'_, StoreData>,
679    params: &[Val],
680) -> wasmtime::Result<Val> {
681    let url = read_string_arg(&mut *caller, abi_arg(params, 0)?, "http.download (url)")?;
682    refuse_dot_segments(&url).map_err(|refusal| refusal.into_error("http.download"))?;
683    let guest_path = read_string_arg(&mut *caller, abi_arg(params, 1)?, "http.download (path)")?;
684    let options = read_download_options(caller, abi_arg(params, 2)?)?;
685
686    let (host_str, url_path_str) = url_host_and_path(&url);
687
688    let target = DownloadTarget {
689        vfs_path: guest_path.clone(),
690        max_bytes: options.max_bytes,
691        overwrite: options.overwrite,
692        decompress: options.decompress,
693    };
694    // http-side check first; remote-only policies can deny without path-context cost.
695    let ticket = check_security_call(
696        &mut *caller,
697        "http.download",
698        target.context(&host_str, &url_path_str),
699    )?;
700    let write_ticket = check_security_call(
701        &mut *caller,
702        "fs.write",
703        serde_json::json!({
704            "path": guest_path,
705            "max_bytes": options.max_bytes,
706        }),
707    )?;
708
709    let request = DownloadRequest {
710        url,
711        path: guest_path.clone(),
712        options,
713    };
714    let downloaded = effects::execute(caller, "http.download", request, move |caller, _, request| Box::pin(async move {
715        let url = request.url;
716        let guest_path = request.path;
717        let options = request.options;
718    // Resolution follows the policy checks, matching every `fs` module's ordering: no
719    // filesystem work happens until the call is authorized.
720    let resolved = resolve_content_or_trap(caller.data(), &guest_path, "http.download")?;
721    // Before the request goes out, so a target that can never be written costs no
722    // network traffic.
723    require_writable(
724        &*caller,
725        write_ticket,
726        resolved.placement(),
727        "fs.write",
728        &guest_path,
729    )?;
730    refuse_volume_root(&resolved, "http.download", &guest_path)?;
731
732    if !options.overwrite
733        && resolved
734            .try_exists()
735            .map_err(|err| contain_trap("http.download", &guest_path, &err))?
736    {
737        wasmtime::bail!(
738            "http.download {guest_path}: file exists (pass {{ overwrite: true }} to clobber)"
739        );
740    }
741    // The checks the final rename runs, so a target spelled by an alias of a
742    // mount point is refused before the request rather than after the body.
743    resolved
744        .check_rename_end()
745        .map_err(|err| contain_trap("http.download", &guest_path, &err))?;
746
747    let (who, guard) = request_principal(caller, GuardedRequest::Download(target), ticket)?;
748    record_payload(&*caller, ticket, Side::Request, || {
749        request_payload("GET", &url, &options.headers, &[]).with_size(0)
750    });
751    let recorded_as = recorded_request(&*caller, || {
752        request_payload("GET", &url, &options.headers, &[])
753    });
754    let req = HttpRequest {
755        method: "GET".to_string(),
756        url: url.clone(),
757        headers: options.headers,
758        body: Vec::new(),
759        timeout_ms: options.timeout_ms,
760        max_response_size: options.max_bytes,
761        decompress: options.decompress,
762        transport_policy: None,
763        redirect_guard: None,
764        recorded_as,
765    };
766    let auth_proxy = std::sync::Arc::clone(&caller.data().auth_proxy);
767    let mut req = auth_proxy
768        .transform(req, &who)
769        .await
770        .map_err(|e| wasmtime::Error::msg(format!("http.download: auth proxy: {e}")))?;
771    // Attached after the proxy, so no proxy can drop it and leave hops unchecked.
772    req.redirect_guard = Some(guard);
773
774    // No auto-mkdir; a missing parent surfaces when the temp sibling is created, which
775    // is also where an escaping parent is refused.
776    let tmp = resolved.temp_sibling();
777    let start = std::time::Instant::now();
778    // No program code runs while the download streams, so an overwrite draws on the
779    // size of the file it replaces and reserves only what goes beyond it.
780    let disk_charge = QuotaCharge::new(
781        resolved.placement().quota().cloned(),
782        resolved.regular_file(),
783    );
784    fuel::charge(&mut *caller, fuel::IO, request_bytes(&req))?;
785    let progress = DownloadProgress::default();
786    let streamed = stream_to_temp(caller, &req, &tmp, &guest_path, disk_charge, &progress).await;
787    // Network receipt and disk writes already happened, even on failure.
788    // The body went to disk, not to the program: the record keeps its size alone.
789    record_payload(&*caller, ticket, Side::Response, || {
790        let meta = match &streamed {
791            Ok(streamed) => serde_json::json!({
792                "status": streamed.meta.status,
793                "path": guest_path,
794                "bytes_written": streamed.meta.bytes_written,
795            }),
796            Err(DownloadFailure::Transport(error, message)) => {
797                serde_json::json!({ "kind": error.kind(), "error": message })
798            }
799            Err(DownloadFailure::Full(_, message)) => {
800                serde_json::json!({ "kind": "full", "error": message })
801            }
802            Err(DownloadFailure::Fs(error)) => serde_json::json!({ "error": error.to_string() }),
803        };
804        Payload::meta(meta).with_size(progress.bytes_received())
805    });
806    fuel::settle(&mut *caller, fuel::IO, progress.bytes_received())?;
807    fuel::settle(&mut *caller, fuel::IO, progress.bytes_written())?;
808    // A filesystem failure has no transport outcome to record.
809    match &streamed {
810        Ok(streamed) => record_http_metric(
811            caller.data().metrics.as_ref(),
812            "http.download".to_string(),
813            host_str,
814            start.elapsed().as_millis() as u64,
815            Ok((streamed.meta.status, streamed.meta.bytes_written)),
816        ),
817        Err(DownloadFailure::Transport(e, _)) => record_http_metric(
818            caller.data().metrics.as_ref(),
819            "http.download".to_string(),
820            host_str,
821            start.elapsed().as_millis() as u64,
822            Err(e),
823        ),
824        Err(DownloadFailure::Fs(_) | DownloadFailure::Full(..)) => {}
825    }
826        let Streamed { meta, file, disk_charge } = streamed.map_err(DownloadFailure::into_error)?;
827        commit_temp(file, disk_charge, &tmp, &resolved, &guest_path)?;
828        Ok(Downloaded { meta, duration_ms: start.elapsed().as_millis() as f64 })
829    })).await?;
830    write_download_result(
831        caller,
832        &downloaded.meta,
833        &guest_path,
834        downloaded.duration_ms,
835    )
836}
837
838/// Only metadata enters history. The downloaded body remains in the mounted file.
839#[derive(serde::Serialize, serde::Deserialize)]
840struct Downloaded {
841    meta: DownloadMeta,
842    duration_ms: f64,
843}
844
845/// Why a download attempt failed before commit. Transport failures carry the
846/// [`HttpError`] for metric classification; filesystem failures don't touch
847/// the transport metrics (matching the pre-stream error paths).
848enum DownloadFailure {
849    Transport(HttpError, String),
850    Fs(wasmtime::Error),
851    /// The body would have passed the VFS's size limit.
852    Full(QuotaExceeded, String),
853}
854
855impl DownloadFailure {
856    fn into_error(self) -> wasmtime::Error {
857        match self {
858            DownloadFailure::Transport(HttpError::TooLarge { .. }, msg) => {
859                crate::runtime::host::range_error(msg)
860            }
861            DownloadFailure::Transport(HttpError::UnsupportedMethod(_), msg) => {
862                crate::runtime::host::type_error(msg)
863            }
864            DownloadFailure::Transport(HttpError::PermissionDenied(denied), _) => {
865                denied.into_error()
866            }
867            DownloadFailure::Transport(HttpError::Internal(_), msg) => {
868                crate::runtime::host::fatal_host_error(msg)
869            }
870            DownloadFailure::Transport(_, msg) => wasmtime::Error::msg(msg),
871            DownloadFailure::Fs(err) => err,
872            DownloadFailure::Full(exceeded, guest_path) => {
873                quota_refusal("http.download", &guest_path, exceeded)
874            }
875        }
876    }
877}
878
879/// Create the temp sibling, stream the response body into it, and flush.
880/// Every error path removes the temp file — through the same handle, so cleanup
881/// cannot be redirected either.
882async fn stream_to_temp(
883    caller: &mut Caller<'_, StoreData>,
884    req: &HttpRequest,
885    tmp: &ContentPath,
886    guest_path: &str,
887    disk_charge: QuotaCharge,
888    progress: &DownloadProgress,
889) -> Result<Streamed, DownloadFailure> {
890    let file = tmp
891        .create()
892        .map_err(|err| DownloadFailure::Fs(temp_create_error(guest_path, &err)))?;
893    let mut writer = std::io::BufWriter::new(QuotaWriter {
894        file,
895        disk_charge,
896        refused: None,
897        progress,
898    });
899    let http_client = std::sync::Arc::clone(&caller.data().http_client);
900    let result = http_client
901        .download_with_progress(req, &mut writer, progress)
902        .await;
903    // Flush explicitly; BufWriter swallows errors on drop. Every failure below removes
904    // the temp file, and dropping the writer's disk charge gives back what it held.
905    let inner = match writer.into_inner() {
906        Ok(inner) => inner,
907        Err(e) => {
908            let failure = e.error().to_string();
909            let inner = e.into_inner().into_parts().0;
910            let _ = tmp.remove_file();
911            return Err(match inner.refused {
912                Some(exceeded) => DownloadFailure::Full(exceeded, guest_path.to_string()),
913                None => DownloadFailure::Fs(wasmtime::Error::msg(format!(
914                    "http.download {guest_path}: flush tempfile: {failure}"
915                ))),
916            });
917        }
918    };
919    match result {
920        Ok(meta) => Ok(Streamed {
921            meta,
922            file: inner.file,
923            disk_charge: inner.disk_charge,
924        }),
925        Err(e) => {
926            let _ = tmp.remove_file();
927            if let Some(exceeded) = inner.refused {
928                return Err(DownloadFailure::Full(exceeded, guest_path.to_string()));
929            }
930            let msg = format!("http.download: {e}");
931            Err(DownloadFailure::Transport(e, msg))
932        }
933    }
934}
935
936/// A download's body, streamed into its temp file and not yet committed.
937struct Streamed {
938    meta: DownloadMeta,
939    file: cap_std::fs::File,
940    disk_charge: QuotaCharge,
941}
942
943/// The temp file a download streams into, reserving each chunk against the VFS's
944/// size limit before writing it, so a download stops at the limit rather than
945/// after it.
946struct QuotaWriter<'a> {
947    progress: &'a DownloadProgress,
948    file: cap_std::fs::File,
949    disk_charge: QuotaCharge,
950    refused: Option<QuotaExceeded>,
951}
952
953impl std::io::Write for QuotaWriter<'_> {
954    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
955        let asked = buf.len() as u64;
956        if let Err(exceeded) = self.disk_charge.reserve(asked) {
957            self.refused = Some(exceeded);
958            return Err(std::io::Error::other(exceeded.to_string()));
959        }
960        let written = self.file.write(buf);
961        // A short or failed write keeps less than it reserved; settle on what landed.
962        let kept = written.as_ref().map_or(0, |n| *n as u64);
963        self.disk_charge.unreserve(asked.saturating_sub(kept));
964        self.progress.written(kept);
965        written
966    }
967
968    fn flush(&mut self) -> std::io::Result<()> {
969        self.file.flush()
970    }
971}
972
973/// Creating the temp sibling is where a missing parent and an escaping one both
974/// surface. Keeping them apart is what an LLM needs: one is fixable with `mkdir`, the
975/// other can never succeed.
976fn temp_create_error(guest_path: &str, err: &ContainError) -> wasmtime::Error {
977    match err {
978        ContainError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => wasmtime::Error::msg(
979            format!("http.download {guest_path}: parent directory does not exist"),
980        ),
981        _ => contain_trap("http.download", guest_path, err),
982    }
983}
984
985/// Fsync and atomically rename the streamed temp file into place; a crash
986/// leaves a `.tmp` sibling instead of a half-written final file. Any failure
987/// removes the temp file, and dropping `disk_charge` with it gives back what it
988/// held; a commit frees the file it replaced.
989///
990/// The rename goes through the handle the destination resolved against, so a link
991/// swapped over a parent component while the body streamed cannot redirect the commit.
992fn commit_temp(
993    file: cap_std::fs::File,
994    mut disk_charge: QuotaCharge,
995    tmp: &ContentPath,
996    resolved: &ContentPath,
997    guest_path: &str,
998) -> wasmtime::Result<()> {
999    let committed = (|| {
1000        file.sync_all()
1001            .map_err(|e| wasmtime::Error::msg(format!("http.download {guest_path}: fsync: {e}")))?;
1002        drop(file);
1003        disk_charge
1004            .cover(resolved.regular_file())
1005            .map_err(|exceeded| quota_refusal("http.download", guest_path, exceeded))?;
1006        tmp.rename_to(resolved).map_err(|err| match err {
1007            ContainError::Escape => contain_trap("http.download", guest_path, &err),
1008            _ => wasmtime::Error::msg(format!("http.download {guest_path}: rename: {err}")),
1009        })
1010    })();
1011    match committed {
1012        Ok(()) => disk_charge.commit(),
1013        Err(_) => {
1014            let _ = tmp.remove_file();
1015        }
1016    }
1017    committed
1018}
1019
1020/// Build the `$DownloadResultBacking` from the committed download's metadata.
1021fn write_download_result(
1022    caller: &mut Caller<'_, StoreData>,
1023    meta: &DownloadMeta,
1024    guest_path: &str,
1025    duration_ms: f64,
1026) -> wasmtime::Result<Val> {
1027    let content_type = meta
1028        .headers
1029        .iter()
1030        .find(|(k, _)| k == "content-type")
1031        .map(|(_, v)| v.clone())
1032        .unwrap_or_default();
1033
1034    let content_type = write_submilli_string_struct(caller, &content_type)?.to_anyref();
1035    let final_url = write_submilli_string_struct(caller, &meta.final_url)?.to_anyref();
1036    let path = write_submilli_string_struct(caller, guest_path)?.to_anyref();
1037
1038    let ty = download_result_backing_struct(caller.engine())?;
1039    abi::new_backing(
1040        caller,
1041        ty,
1042        &[
1043            Val::F64((meta.bytes_written as f64).to_bits()),
1044            Val::AnyRef(Some(content_type)),
1045            Val::F64(duration_ms.to_bits()),
1046            Val::AnyRef(Some(final_url)),
1047            Val::AnyRef(Some(path)),
1048            Val::F64(f64::from(meta.status).to_bits()),
1049        ],
1050    )
1051}
1052
1053// ---------------------------------------------------------------------------
1054// Response / DownloadResult members
1055// ---------------------------------------------------------------------------
1056
1057const RESPONSE_VIEW: HostView = HostView {
1058    module: MODULE_NAME,
1059    interface: "Response",
1060    backing: response_backing_struct,
1061    properties: &[
1062        ("body", R_BODY),
1063        ("headers", R_HEADERS),
1064        ("ok", R_OK),
1065        ("status", R_STATUS),
1066        ("statusText", R_STATUS_TEXT),
1067        ("url", R_URL),
1068    ],
1069};
1070
1071const DOWNLOAD_RESULT_VIEW: HostView = HostView {
1072    module: MODULE_NAME,
1073    interface: "DownloadResult",
1074    backing: download_result_backing_struct,
1075    properties: &[
1076        ("bytesWritten", D_BYTES_WRITTEN),
1077        ("contentType", D_CONTENT_TYPE),
1078        ("duration_ms", D_DURATION_MS),
1079        ("finalUrl", D_FINAL_URL),
1080        ("path", D_PATH),
1081        ("status", D_STATUS),
1082    ],
1083};
1084
1085/// The host-backed interfaces this package's values are.
1086pub(crate) const HOST_VIEWS: &[&HostView] = &[&RESPONSE_VIEW, &DOWNLOAD_RESULT_VIEW];
1087
1088fn install_response_members(
1089    linker: &mut Linker<StoreData>,
1090    engine: &wasmtime::Engine,
1091    intr: &IntrinsicTypes,
1092    receiver: ValType,
1093) -> wasmtime::Result<()> {
1094    let string = ValType::Ref(RefType::new(
1095        false,
1096        HeapType::ConcreteStruct(intr.string.clone()),
1097    ));
1098    install_field_getters(linker, engine, &receiver, &RESPONSE_VIEW)?;
1099
1100    let response_key = crate::mangle::package_symbol(MODULE_NAME, "Response");
1101    register_host_fn(
1102        linker,
1103        MODULE_NAME,
1104        crate::mangle::extend(&response_key, "throwForStatus"),
1105        FuncType::new(engine, [receiver.clone()], []),
1106        |caller, params, _results| {
1107            let st = backing_receiver(caller, abi_arg(params, 0)?)?;
1108            if matches!(st.field(&mut *caller, R_OK)?, Val::I32(ok) if ok != 0) {
1109                return Ok(());
1110            }
1111            let (status, status_text, url) = read_response_status_line(caller, &st)?;
1112            Err(wasmtime::Error::msg(if status_text.is_empty() {
1113                format!("HTTP {status}: {url}")
1114            } else {
1115                format!("HTTP {status} {status_text}: {url}")
1116            }))
1117        },
1118    )?;
1119
1120    register_host_fn(
1121        linker,
1122        MODULE_NAME,
1123        crate::mangle::extend(&response_key, "toString"),
1124        FuncType::new(engine, [receiver], [string]),
1125        |caller, params, results| {
1126            let st = backing_receiver(caller, abi_arg(params, 0)?)?;
1127            let (status, status_text, url) = read_response_status_line(caller, &st)?;
1128            let text = if status_text.is_empty() {
1129                format!("Response({status}, {url})")
1130            } else {
1131                format!("Response({status} {status_text}, {url})")
1132            };
1133            let out = write_submilli_string_struct(caller, &text)?;
1134            *abi_result(results, 0)? = Val::AnyRef(Some(out.to_anyref()));
1135            Ok(())
1136        },
1137    )?;
1138
1139    Ok(())
1140}
1141
1142fn read_response_status_line(
1143    caller: &mut Caller<'_, StoreData>,
1144    st: &Rooted<StructRef>,
1145) -> wasmtime::Result<(i64, String, String)> {
1146    let Val::F64(bits) = st.field(&mut *caller, R_STATUS)? else {
1147        wasmtime::bail!("Response: status is not a number");
1148    };
1149    let status = f64::from_bits(bits) as i64;
1150    let status_text_val = st.field(&mut *caller, R_STATUS_TEXT)?;
1151    let status_text = read_string_arg(caller, &status_text_val, "Response (statusText)")?;
1152    let url_val = st.field(&mut *caller, R_URL)?;
1153    let url = read_string_arg(caller, &url_val, "Response (url)")?;
1154    Ok((status, status_text, url))
1155}
1156
1157fn install_download_result_members(
1158    linker: &mut Linker<StoreData>,
1159    engine: &wasmtime::Engine,
1160    intr: &IntrinsicTypes,
1161    receiver: ValType,
1162) -> wasmtime::Result<()> {
1163    let string = ValType::Ref(RefType::new(
1164        false,
1165        HeapType::ConcreteStruct(intr.string.clone()),
1166    ));
1167    install_field_getters(linker, engine, &receiver, &DOWNLOAD_RESULT_VIEW)?;
1168
1169    let result_key = crate::mangle::package_symbol(MODULE_NAME, "DownloadResult");
1170    register_host_fn(
1171        linker,
1172        MODULE_NAME,
1173        crate::mangle::extend(&result_key, "toString"),
1174        FuncType::new(engine, [receiver], [string]),
1175        |caller, params, results| {
1176            let st = backing_receiver(caller, abi_arg(params, 0)?)?;
1177            let Val::F64(status_bits) = st.field(&mut *caller, D_STATUS)? else {
1178                wasmtime::bail!("DownloadResult: status is not a number");
1179            };
1180            let Val::F64(bytes_bits) = st.field(&mut *caller, D_BYTES_WRITTEN)? else {
1181                wasmtime::bail!("DownloadResult: bytesWritten is not a number");
1182            };
1183            let path_val = st.field(&mut *caller, D_PATH)?;
1184            let path = read_string_arg(caller, &path_val, "DownloadResult (path)")?;
1185            let status = f64::from_bits(status_bits) as i64;
1186            let bytes_written = f64::from_bits(bytes_bits) as i64;
1187            let text = format!("Download({status}, {bytes_written} bytes -> {path})");
1188            let out = write_submilli_string_struct(caller, &text)?;
1189            *abi_result(results, 0)? = Val::AnyRef(Some(out.to_anyref()));
1190            Ok(())
1191        },
1192    )?;
1193
1194    Ok(())
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199    use std::collections::VecDeque;
1200    use std::sync::{Arc, Mutex};
1201
1202    use crate::compile_script;
1203    use crate::runtime::security::{CheckOutcome, SecurityCheck};
1204    use crate::runtime::{
1205        RuntimeConfig, StoreData, Vfs, dispatch_main_async, install_runtime_async,
1206    };
1207
1208    use super::transport::{
1209        DownloadMeta, HttpClient, HttpError, HttpRequest, HttpResponse, detect_decompression,
1210        stream_to_writer,
1211    };
1212
1213    struct MockHttpClient {
1214        scripted: Mutex<VecDeque<HttpResponse>>,
1215        seen: Mutex<Vec<HttpRequest>>,
1216    }
1217
1218    impl MockHttpClient {
1219        fn new(scripted: Vec<HttpResponse>) -> Self {
1220            Self {
1221                scripted: Mutex::new(scripted.into()),
1222                seen: Mutex::new(Vec::new()),
1223            }
1224        }
1225    }
1226
1227    #[async_trait::async_trait]
1228    impl HttpClient for MockHttpClient {
1229        async fn send(&self, req: &HttpRequest) -> Result<HttpResponse, HttpError> {
1230            self.seen.lock().unwrap().push(req.clone());
1231            self.scripted
1232                .lock()
1233                .unwrap()
1234                .pop_front()
1235                .ok_or_else(|| HttpError::Other("mock: scripted queue empty".into()))
1236        }
1237
1238        // Uses `stream_to_writer` so `max_response_size` enforcement matches the production path.
1239        async fn download(
1240            &self,
1241            req: &HttpRequest,
1242            writer: &mut (dyn std::io::Write + Send),
1243        ) -> Result<DownloadMeta, HttpError> {
1244            self.seen.lock().unwrap().push(req.clone());
1245            let resp = self
1246                .scripted
1247                .lock()
1248                .unwrap()
1249                .pop_front()
1250                .ok_or_else(|| HttpError::Other("mock: scripted queue empty".into()))?;
1251            let kind = detect_decompression(&resp.headers, &req.url, req.decompress);
1252            let cursor = std::io::Cursor::new(resp.body);
1253            let bytes_written = stream_to_writer(cursor, writer, kind, req.max_response_size)?;
1254            Ok(DownloadMeta {
1255                status: resp.status,
1256                status_text: resp.status_text,
1257                headers: resp.headers,
1258                final_url: resp.final_url,
1259                bytes_written,
1260            })
1261        }
1262    }
1263
1264    struct DenyAllHttp;
1265    impl SecurityCheck for DenyAllHttp {
1266        fn check(
1267            &self,
1268            _caller: &str,
1269            capability: &str,
1270            _context: &serde_json::Value,
1271        ) -> CheckOutcome {
1272            if capability.starts_with("http.") {
1273                CheckOutcome::Deny {
1274                    rule: None,
1275                    reason: format!("denied {capability} in test"),
1276                }
1277            } else {
1278                CheckOutcome::Allow { rule: None }
1279            }
1280        }
1281    }
1282
1283    async fn run_with_mock(source: &str, scripted: Vec<HttpResponse>) -> Arc<MockHttpClient> {
1284        let compiled = crate::compile_script(source, "test.subm", crate::FileId(0), &[], &[])
1285            .expect("compile clean");
1286        let cfg = RuntimeConfig::default();
1287        let engine = cfg.engine().expect("engine");
1288        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1289        data.install_type_info(compiled.type_info.clone());
1290        let mock = Arc::new(MockHttpClient::new(scripted));
1291        data.http_client = mock.clone();
1292        let mut store = cfg.store(&engine, data).expect("store");
1293        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1294        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1295        install_runtime_async(&mut linker, &mut store)
1296            .await
1297            .expect("install");
1298        let inst = linker
1299            .instantiate_async(&mut store, &module)
1300            .await
1301            .expect("instantiate");
1302        dispatch_main_async(&mut store, &inst)
1303            .await
1304            .expect("main ran without trap");
1305        mock
1306    }
1307
1308    #[tokio::test]
1309    async fn response_marshalling_checks_fuel_and_uses_normal_error_handling() {
1310        let config = RuntimeConfig::default();
1311        let engine = config.engine().unwrap();
1312        let mut store = config
1313            .store_async(&engine, StoreData::with_vfs("test-run".into(), Vfs::none()))
1314            .unwrap();
1315        let mut linker = wasmtime::Linker::new(&engine);
1316        install_runtime_async(&mut linker, &mut store)
1317            .await
1318            .unwrap();
1319        let probe = crate::runtime::host::host_value_async(
1320            &mut store,
1321            wasmtime::FuncType::new(
1322                &engine,
1323                [wasmtime::ValType::I32, wasmtime::ValType::I32],
1324                [wasmtime::ValType::I32],
1325            ),
1326            |caller, params| {
1327                Box::new(async move {
1328                    let depleted = params[0].i32().unwrap() != 0;
1329                    let invalid = params[1].i32().unwrap() != 0;
1330                    if depleted {
1331                        caller.set_fuel(0)?;
1332                    }
1333                    let mut response = ok_response(200, "café");
1334                    response.headers = vec![
1335                        ("a".into(), "first".into()),
1336                        ("a".into(), "last".into()),
1337                        ("b".into(), "second".into()),
1338                    ];
1339                    if invalid {
1340                        response.body = vec![0xFF];
1341                    }
1342                    let response = super::settle_native_response(caller, Ok(response), "GET")?;
1343                    let response = super::write_response(caller, response)?;
1344                    let response = crate::runtime::prelude::iterator::as_struct(
1345                        caller, &response, "response",
1346                    )?;
1347                    let body = response.field(&mut *caller, 1)?;
1348                    assert_eq!(
1349                        super::read_string_arg(caller, &body, "response body")?,
1350                        "café"
1351                    );
1352                    let headers = response.field(&mut *caller, 2)?;
1353                    assert_eq!(
1354                        super::map::string_entries(caller, &headers)?,
1355                        vec![("a".into(), "last".into()), ("b".into(), "second".into())]
1356                    );
1357                    Ok(wasmtime::Val::I32(1))
1358                })
1359            },
1360        );
1361        for (depleted, invalid) in [(1, 0), (0, 1), (0, 0)] {
1362            store.set_fuel(1_000_000).unwrap();
1363            let result = probe
1364                .call_async(
1365                    &mut store,
1366                    &[wasmtime::Val::I32(depleted), wasmtime::Val::I32(invalid)],
1367                    &mut [wasmtime::Val::I32(0)],
1368                )
1369                .await;
1370            if depleted != 0 {
1371                assert_eq!(
1372                    result.unwrap_err().downcast_ref::<wasmtime::Trap>(),
1373                    Some(&wasmtime::Trap::OutOfFuel)
1374                );
1375            } else if invalid != 0 {
1376                let error = result.unwrap_err();
1377                assert!(error.is::<wasmtime::ThrownException>());
1378                let original = crate::runtime::exec::uncaught_error(&mut store, error);
1379                assert!(original.to_string().contains("response body is not UTF-8"));
1380            } else {
1381                result.unwrap();
1382            }
1383            assert!(!store.data().settling_host_result);
1384        }
1385    }
1386
1387    fn ok_response(status: u16, body: &str) -> HttpResponse {
1388        HttpResponse {
1389            status,
1390            status_text: "OK".to_string(),
1391            headers: vec![("content-type".to_string(), "text/plain".to_string())],
1392            body: body.as_bytes().to_vec(),
1393            final_url: "https://example.test/".to_string(),
1394        }
1395    }
1396
1397    #[tokio::test]
1398    async fn narrowed_host_carriers() {
1399        let source = r#"
1400import { get, download, Response, DownloadResult } from "submilli:http";
1401import { info } from "submilli:fs";
1402
1403class Parent { value: unknown = null; reset(value: unknown): void { this.value = value; } }
1404function rejects(read: () => void): void {
1405 let caught = false;
1406 try { read(); } catch (e) { caught = e instanceof TypeError; }
1407 assert(caught, "unrelated carrier must throw TypeError");
1408}
1409
1410class ResponseField extends Parent { value: Response | null = null; }
1411class DownloadField extends Parent { value: DownloadResult | null = null; }
1412function main(): void {
1413 const r = new ResponseField(); r.reset(get("https://example.test/"));
1414 assert(r.value!.status === 200, "Response");
1415 const d = new DownloadField(); d.reset(download("https://example.test/file", "/out.txt"));
1416 assert(d.value!.bytesWritten === 5, "DownloadResult");
1417 r.reset(d.value); rejects(() => { const v = r.value; });
1418 d.reset(info()); rejects(() => { const v = d.value; });
1419}
1420"#;
1421        let tmp = tempfile::tempdir().unwrap();
1422        let (_, result) = run_download_with_mock(
1423            source,
1424            vec![ok_response(200, "hello"), ok_response(200, "hello")],
1425            None,
1426            tmp.path(),
1427        )
1428        .await;
1429        result.expect("host guards");
1430    }
1431
1432    #[tokio::test]
1433    async fn responses_and_downloads_stringify_their_properties() {
1434        let source = r#"
1435import { get, download } from "submilli:http";
1436function main(): void {
1437 const r = JSON.stringify(get("https://example.test/"));
1438 assert(r === '{"body":"hello","headers":{"content-type":"text/plain"},"ok":true,"status":200,"statusText":"OK","url":"https://example.test/"}', r!);
1439 const d = JSON.parse(JSON.stringify(download("https://example.test/file", "/out.txt"))!) as { bytesWritten: number; path: string; status: number };
1440 assert(d.bytesWritten === 5 && d.path === "/out.txt" && d.status === 200, JSON.stringify(d)!);
1441}
1442"#;
1443        let tmp = tempfile::tempdir().unwrap();
1444        let (_, result) = run_download_with_mock(
1445            source,
1446            vec![ok_response(200, "hello"), ok_response(200, "hello")],
1447            None,
1448            tmp.path(),
1449        )
1450        .await;
1451        result.expect("host values stringify");
1452    }
1453
1454    #[tokio::test]
1455    async fn get_status_and_body_roundtrip() {
1456        let source = r#"
1457            import { get, Response } from "submilli:http";
1458            function main(): void {
1459                const r: Response = get("https://example.test/u");
1460                assert(r.status === 200, "status is 200");
1461                assert(r.body === "hello", "body decoded");
1462                assert(r.ok, "ok for 2xx");
1463            }
1464        "#;
1465        let mock = run_with_mock(source, vec![ok_response(200, "hello")]).await;
1466        let seen = mock.seen.lock().unwrap();
1467        assert_eq!(seen.len(), 1, "exactly one request");
1468        assert_eq!(seen[0].method, "GET");
1469        assert_eq!(seen[0].url, "https://example.test/u");
1470    }
1471
1472    /// Records the caller it's handed and injects a marker header, so a test can
1473    /// assert both the caller-threading and that injection reaches the wire.
1474    struct RecordingAuthProxy {
1475        callers: Mutex<Vec<String>>,
1476    }
1477    #[async_trait::async_trait]
1478    impl super::transport::AuthProxy for RecordingAuthProxy {
1479        async fn transform(
1480            &self,
1481            mut req: HttpRequest,
1482            caller: &str,
1483        ) -> Result<HttpRequest, super::transport::AuthProxyError> {
1484            self.callers.lock().unwrap().push(caller.to_string());
1485            req.headers
1486                .push(("x-injected".to_string(), "yes".to_string()));
1487            Ok(req)
1488        }
1489    }
1490
1491    /// Runs `source` as code owned by `owner` (`None` for `main`) and returns the callers the
1492    /// auth proxy was told, plus whether the injected header reached the wire.
1493    async fn auth_proxy_callers_for(owner: Option<&str>, source: &str) -> (Vec<String>, bool) {
1494        let compiled = match owner {
1495            Some(package) => crate::compile::compile_script_owned_by(
1496                package,
1497                source,
1498                "test.subm",
1499                crate::FileId(0),
1500                &[],
1501                &[],
1502            ),
1503            None => compile_script(source, "test.subm", crate::FileId(0), &[], &[]),
1504        }
1505        .expect("compile clean");
1506        let cfg = RuntimeConfig::default();
1507        let engine = cfg.engine().expect("engine");
1508        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1509        let mock = Arc::new(MockHttpClient::new(vec![ok_response(200, "hi")]));
1510        let proxy = Arc::new(RecordingAuthProxy {
1511            callers: Mutex::new(Vec::new()),
1512        });
1513        data.http_client = mock.clone();
1514        data.auth_proxy = proxy.clone();
1515        let mut store = cfg.store(&engine, data).expect("store");
1516        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1517        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1518        install_runtime_async(&mut linker, &mut store)
1519            .await
1520            .expect("install");
1521        let inst = linker
1522            .instantiate_async(&mut store, &module)
1523            .await
1524            .expect("instantiate");
1525        dispatch_main_async(&mut store, &inst)
1526            .await
1527            .expect("main ran without trap");
1528        let callers = proxy.callers.lock().unwrap().clone();
1529        let injected = mock.seen.lock().unwrap()[0]
1530            .headers
1531            .iter()
1532            .any(|(k, v)| k == "x-injected" && v == "yes");
1533        (callers, injected)
1534    }
1535
1536    const REQUESTS_A_URL: &str = r#"
1537        import { get, Response } from "submilli:http";
1538        function main(): number {
1539            const r: Response = get("https://example.test/u");
1540            return r.status;
1541        }
1542    "#;
1543
1544    /// Credential injection is scoped to `main` and withheld from libraries
1545    /// (`submilli-shared`'s `BlueprintAuthProxy` gates on `caller != MAIN_PACKAGE`), so the
1546    /// identity it selects on must be the running code's — not the top of a stack that only
1547    /// package *export wrappers* push to.
1548    ///
1549    /// This is package-owned code running with nothing pushed, the same condition as the
1550    /// exported class methods the report names: they carry no identity wrapper, so the stack
1551    /// still reads `main` while the package's own code runs. Attributed to `main`, that code
1552    /// would be handed the operator's credentials.
1553    #[tokio::test]
1554    async fn a_package_making_a_request_is_never_attributed_to_main() {
1555        let (callers, _injected) =
1556            auth_proxy_callers_for(Some("@pied-piper/sdk"), REQUESTS_A_URL).await;
1557        assert_eq!(
1558            callers,
1559            vec!["@pied-piper/sdk".to_string()],
1560            "package code must not borrow main's identity at the auth proxy",
1561        );
1562    }
1563
1564    /// The mirror of the above: over-correcting here would silently strip the operator's own
1565    /// credentials, which fails as an auth error far from its cause.
1566    #[tokio::test]
1567    async fn mains_own_request_still_gets_injection() {
1568        let (callers, injected) = auth_proxy_callers_for(None, REQUESTS_A_URL).await;
1569        assert_eq!(callers, vec!["main".to_string()]);
1570        assert!(injected, "main's own request must still be injected");
1571    }
1572
1573    /// R7, the inverse direction, asserted deliberately rather than discovered: when a package
1574    /// invokes `main`-authored code — here a `toJson` reached through the package's
1575    /// `JSON.stringify` — the innermost frame is `main`'s, so the request is `main`'s and is
1576    /// injected. That follows from reading identity off the running code, and it is safe:
1577    /// the code is `main`'s own, `main` chose to hand it over, and the package cannot read the
1578    /// injected header. What R3 forbids is the reverse, covered by the test above.
1579    #[tokio::test]
1580    async fn main_authored_code_invoked_by_a_package_is_still_main() {
1581        let (lib_bytes, lib_decl, lib_type_info) = crate::codegen::tests::compile_package_modules(
1582            "test:wrap",
1583            &[(
1584                "lib",
1585                r#"
1586                /**
1587                 * Pass-through JSON encoder.
1588                 * @param value Value to encode.
1589                 * @returns `value` as JSON.
1590                 */
1591                export function passthrough(value: unknown): string | undefined {
1592                    return JSON.stringify(value);
1593                }
1594                "#,
1595            )],
1596            &[],
1597        );
1598        let cfg = RuntimeConfig::default();
1599        let engine = cfg.engine().expect("engine");
1600        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1601        data.install_type_info(lib_type_info);
1602        let mock = Arc::new(MockHttpClient::new(vec![ok_response(200, "hi")]));
1603        let proxy = Arc::new(RecordingAuthProxy {
1604            callers: Mutex::new(Vec::new()),
1605        });
1606        data.http_client = mock.clone();
1607        data.auth_proxy = proxy.clone();
1608        let mut store = cfg.store(&engine, data).expect("store");
1609        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1610        install_runtime_async(&mut linker, &mut store)
1611            .await
1612            .expect("install");
1613        let lib_module = wasmtime::Module::new(&engine, &lib_bytes).expect("library module");
1614        let lib_inst = linker
1615            .instantiate_async(&mut store, &lib_module)
1616            .await
1617            .expect("instantiate library");
1618        linker
1619            .instance(&mut store, "test:wrap", lib_inst)
1620            .expect("register library instance");
1621        let public_name = crate::mangle::package_symbol("test:wrap", "passthrough");
1622        let func = lib_inst
1623            .get_func(&mut store, public_name.as_str())
1624            .expect("library public export");
1625        linker
1626            .define(&mut store, "test:wrap", "passthrough", func)
1627            .expect("plain package import alias");
1628
1629        let consumer = compile_script(
1630            r#"
1631            import { passthrough } from "test:wrap";
1632            import { get, Response } from "submilli:http";
1633
1634            class Pinger {
1635                hit: number;
1636                constructor() { this.hit = 0; }
1637                toJson(): string {
1638                    const r: Response = get("https://example.test/u");
1639                    this.hit = r.status;
1640                    return "\"ok\"";
1641                }
1642            }
1643
1644            function main(): number {
1645                const p = new Pinger();
1646                const _ = passthrough(p);
1647                return p.hit;
1648            }
1649            "#,
1650            "consumer.subm",
1651            crate::FileId(0),
1652            &[&lib_decl],
1653            &[],
1654        )
1655        .expect("consumer compiles");
1656        store
1657            .data_mut()
1658            .install_type_info(consumer.type_info.clone());
1659        let consumer_module = wasmtime::Module::new(&engine, &consumer.wasm).expect("module");
1660        let inst = linker
1661            .instantiate_async(&mut store, &consumer_module)
1662            .await
1663            .expect("instantiate consumer");
1664        dispatch_main_async(&mut store, &inst)
1665            .await
1666            .expect("main ran without trap");
1667
1668        assert_eq!(
1669            proxy.callers.lock().unwrap().as_slice(),
1670            &["main".to_string()],
1671            "main-authored code stays main's wherever a package invokes it",
1672        );
1673    }
1674
1675    #[tokio::test]
1676    async fn auth_proxy_sees_main_caller_and_injects_to_wire() {
1677        let source = r#"
1678            import { get, Response } from "submilli:http";
1679            function main(): number {
1680                const r: Response = get("https://example.test/u");
1681                return r.status;
1682            }
1683        "#;
1684        let compiled =
1685            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
1686        let cfg = RuntimeConfig::default();
1687        let engine = cfg.engine().expect("engine");
1688        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1689        let mock = Arc::new(MockHttpClient::new(vec![ok_response(200, "hi")]));
1690        let proxy = Arc::new(RecordingAuthProxy {
1691            callers: Mutex::new(Vec::new()),
1692        });
1693        data.http_client = mock.clone();
1694        data.auth_proxy = proxy.clone();
1695        let mut store = cfg.store(&engine, data).expect("store");
1696        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1697        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1698        install_runtime_async(&mut linker, &mut store)
1699            .await
1700            .expect("install");
1701        let inst = linker
1702            .instantiate_async(&mut store, &module)
1703            .await
1704            .expect("instantiate");
1705        dispatch_main_async(&mut store, &inst)
1706            .await
1707            .expect("main ran without trap");
1708
1709        assert_eq!(
1710            proxy.callers.lock().unwrap().as_slice(),
1711            &["main".to_string()]
1712        );
1713        let seen = mock.seen.lock().unwrap();
1714        assert!(
1715            seen[0]
1716                .headers
1717                .iter()
1718                .any(|(k, v)| k == "x-injected" && v == "yes"),
1719            "injected header reached the outbound request"
1720        );
1721    }
1722
1723    #[tokio::test]
1724    async fn response_ok_false_for_non_2xx() {
1725        let source = r#"
1726            import { get, Response } from "submilli:http";
1727            function main(): void {
1728                const r: Response = get("https://example.test/x");
1729                assert(!r.ok, "300 is not ok");
1730                assert(r.status === 300, "status preserved");
1731            }
1732        "#;
1733        run_with_mock(source, vec![ok_response(300, "")]).await;
1734    }
1735
1736    #[tokio::test]
1737    async fn deny_policy_blocks_http_get() {
1738        let source = r#"
1739            import { get } from "submilli:http";
1740            function main(): void {
1741                get("https://example.test/y");
1742            }
1743        "#;
1744        let compiled =
1745            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
1746        let cfg = RuntimeConfig::default();
1747        let engine = cfg.engine().expect("engine");
1748        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1749        data.security_check = Arc::new(DenyAllHttp);
1750        // Mock client never invoked — the security check denies first.
1751        data.http_client = Arc::new(MockHttpClient::new(vec![ok_response(200, "")]));
1752        let mut store = cfg.store(&engine, data).expect("store");
1753        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1754        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1755        install_runtime_async(&mut linker, &mut store)
1756            .await
1757            .expect("install");
1758        let inst = linker
1759            .instantiate_async(&mut store, &module)
1760            .await
1761            .expect("instantiate");
1762        let err = dispatch_main_async(&mut store, &inst)
1763            .await
1764            .expect_err("must trap on deny");
1765        let msg = format!("{err:?}");
1766        assert!(
1767            msg.contains("permission denied"),
1768            "expected deny trap; got: {msg}"
1769        );
1770        assert!(
1771            msg.contains("http.get"),
1772            "expected http.get capability in trap; got: {msg}"
1773        );
1774        assert!(
1775            msg.contains("caller=main"),
1776            "expected caller=main in trap; got: {msg}"
1777        );
1778    }
1779
1780    #[tokio::test]
1781    async fn verb_matrix_method_passthrough() {
1782        let source = r#"
1783            import { post, put, patch, delete, head, options, Response } from "submilli:http";
1784            function main(): void {
1785                const a: Response = post("https://example.test/a");
1786                const b: Response = put("https://example.test/b");
1787                const c: Response = patch("https://example.test/c");
1788                const d: Response = delete("https://example.test/d");
1789                const e: Response = head("https://example.test/e");
1790                const f: Response = options("https://example.test/f");
1791                assert(a.status === 200, "post status");
1792                assert(b.status === 200, "put status");
1793                assert(c.status === 200, "patch status");
1794                assert(d.status === 200, "delete status");
1795                assert(e.status === 200, "head status");
1796                assert(f.status === 200, "options status");
1797            }
1798        "#;
1799        let mock = run_with_mock(
1800            source,
1801            vec![
1802                ok_response(200, ""),
1803                ok_response(200, ""),
1804                ok_response(200, ""),
1805                ok_response(200, ""),
1806                ok_response(200, ""),
1807                ok_response(200, ""),
1808            ],
1809        )
1810        .await;
1811        let seen = mock.seen.lock().unwrap();
1812        assert_eq!(seen.len(), 6);
1813        assert_eq!(seen[0].method, "POST");
1814        assert_eq!(seen[1].method, "PUT");
1815        assert_eq!(seen[2].method, "PATCH");
1816        assert_eq!(seen[3].method, "DELETE");
1817        assert_eq!(seen[4].method, "HEAD");
1818        assert_eq!(seen[5].method, "OPTIONS");
1819    }
1820
1821    struct VerbContextCheck {
1822        capability: String,
1823        method: Option<String>,
1824    }
1825
1826    impl SecurityCheck for VerbContextCheck {
1827        fn check(
1828            &self,
1829            caller: &str,
1830            capability: &str,
1831            context: &serde_json::Value,
1832        ) -> CheckOutcome {
1833            assert_eq!(caller, "main");
1834            assert_eq!(capability, self.capability);
1835            let mut expected = serde_json::json!({
1836                "host": "example.test",
1837                "path": "/resource",
1838                "body_size": 0,
1839                "timeout_ms": super::DEFAULT_TIMEOUT_MS,
1840            });
1841            if let Some(method) = &self.method {
1842                expected["method"] = method.as_str().into();
1843            }
1844            assert_eq!(context, &expected);
1845            CheckOutcome::Allow { rule: None }
1846        }
1847    }
1848
1849    #[tokio::test]
1850    async fn verb_functions_check_their_verb_and_request_checks_its_method() {
1851        let tmp = tempfile::tempdir().expect("tempdir");
1852        for verb in ["get", "post", "put", "patch", "delete", "head", "options"] {
1853            let upper = verb.to_ascii_uppercase();
1854            for (call, check) in [
1855                (
1856                    format!("{verb}(\"https://example.test/resource\")"),
1857                    VerbContextCheck {
1858                        capability: format!("http.{verb}"),
1859                        method: None,
1860                    },
1861                ),
1862                (
1863                    format!("request(\"{verb}\", \"https://example.test/resource\")"),
1864                    VerbContextCheck {
1865                        capability: "http.request".to_string(),
1866                        method: Some(upper.clone()),
1867                    },
1868                ),
1869            ] {
1870                let source = format!(
1871                    "import {{ {verb}, request }} from \"submilli:http\";\n\
1872                     function main(): void {{ {call}; }}"
1873                );
1874                let (mock, result) = run_download_with_mock(
1875                    &source,
1876                    vec![ok_response(200, "")],
1877                    Some(Arc::new(check)),
1878                    tmp.path(),
1879                )
1880                .await;
1881                result.expect("request allowed");
1882                let seen = mock.seen.lock().unwrap();
1883                assert_eq!(seen.len(), 1);
1884                assert_eq!(seen[0].method, upper);
1885            }
1886        }
1887    }
1888
1889    #[tokio::test]
1890    async fn generic_request_checks_its_capability_before_transport() {
1891        let tmp = tempfile::tempdir().expect("tempdir");
1892        let source = r#"
1893            import { request } from "submilli:http";
1894            function main(): void {
1895                request("pOsT", "https://example.test/resource");
1896            }
1897        "#;
1898        let (mock, result) =
1899            run_download_with_mock(source, vec![], Some(Arc::new(DenyAllHttp)), tmp.path()).await;
1900        let error = result.expect_err("request denied");
1901        assert!(error.contains("http.request"), "{error}");
1902        assert!(mock.seen.lock().unwrap().is_empty());
1903    }
1904
1905    struct RecordingCheck {
1906        seen: Mutex<Vec<(String, String)>>,
1907    }
1908    impl SecurityCheck for RecordingCheck {
1909        fn check(
1910            &self,
1911            caller: &str,
1912            capability: &str,
1913            _context: &serde_json::Value,
1914        ) -> CheckOutcome {
1915            self.seen
1916                .lock()
1917                .unwrap()
1918                .push((caller.to_string(), capability.to_string()));
1919            CheckOutcome::Allow { rule: None }
1920        }
1921    }
1922
1923    #[tokio::test]
1924    async fn a_script_is_attributed_to_main() {
1925        let source = r#"
1926            import { get, Response } from "submilli:http";
1927            function main(): void {
1928                const _r: Response = get("https://example.test/c");
1929            }
1930        "#;
1931        let recording = Arc::new(RecordingCheck {
1932            seen: Mutex::new(Vec::new()),
1933        });
1934        let compiled = crate::compile_script(source, "test.subm", crate::FileId(0), &[], &[])
1935            .expect("compile clean");
1936        let cfg = RuntimeConfig::default();
1937        let engine = cfg.engine().expect("engine");
1938        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1939        data.security_check = recording.clone();
1940        data.http_client = Arc::new(MockHttpClient::new(vec![ok_response(200, "")]));
1941        let mut store = cfg.store(&engine, data).expect("store");
1942        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1943        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1944        install_runtime_async(&mut linker, &mut store)
1945            .await
1946            .expect("install");
1947        let inst = linker
1948            .instantiate_async(&mut store, &module)
1949            .await
1950            .expect("instantiate");
1951        dispatch_main_async(&mut store, &inst)
1952            .await
1953            .expect("main ran");
1954
1955        let seen = recording.seen.lock().unwrap().clone();
1956        assert!(
1957            !seen.is_empty(),
1958            "RecordingCheck should have captured at least one http.* call"
1959        );
1960        for (caller, capability) in &seen {
1961            assert_eq!(
1962                caller, "main",
1963                "expected caller=main for capability {capability}; got {caller}"
1964            );
1965        }
1966    }
1967
1968    #[tokio::test]
1969    async fn a_package_is_attributed_to_the_package() {
1970        let source = r#"
1971            import { get, Response } from "submilli:http";
1972            function main(): void {
1973                const _r: Response = get("https://example.test/c2");
1974            }
1975        "#;
1976        let recording = Arc::new(RecordingCheck {
1977            seen: Mutex::new(Vec::new()),
1978        });
1979        // Compiled under the package name so the identity rides the wasm frame the runtime
1980        // reads, rather than being declared out-of-band.
1981        let compiled = crate::compile::compile_script_owned_by(
1982            "submilli:foo",
1983            source,
1984            "test.subm",
1985            crate::FileId(0),
1986            &[],
1987            &[],
1988        )
1989        .expect("compile clean");
1990        let cfg = RuntimeConfig::default();
1991        let engine = cfg.engine().expect("engine");
1992        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1993        data.security_check = recording.clone();
1994        data.http_client = Arc::new(MockHttpClient::new(vec![ok_response(200, "")]));
1995        let mut store = cfg.store(&engine, data).expect("store");
1996        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1997        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1998        install_runtime_async(&mut linker, &mut store)
1999            .await
2000            .expect("install");
2001        let inst = linker
2002            .instantiate_async(&mut store, &module)
2003            .await
2004            .expect("instantiate");
2005        dispatch_main_async(&mut store, &inst)
2006            .await
2007            .expect("main ran");
2008
2009        let seen = recording.seen.lock().unwrap().clone();
2010        assert!(
2011            !seen.is_empty(),
2012            "RecordingCheck should have captured calls"
2013        );
2014        for (caller, capability) in &seen {
2015            assert_eq!(
2016                caller, "submilli:foo",
2017                "expected caller=submilli:foo for capability {capability}; got {caller}"
2018            );
2019        }
2020    }
2021
2022    #[tokio::test]
2023    async fn headers_roundtrip_to_client() {
2024        let source = r#"
2025            import { get, Response } from "submilli:http";
2026            function main(): void {
2027                const h = new Map<string, string>();
2028                h.set("Authorization", "Bearer xyz");
2029                h.set("X-Foo", "bar");
2030                const r: Response = get("https://example.test/h", h);
2031                assert(r.status === 200, "status");
2032            }
2033        "#;
2034        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2035        let seen = mock.seen.lock().unwrap();
2036        assert_eq!(seen.len(), 1);
2037        // Map iteration is insertion-ordered so the
2038        // emitted header sequence is stable; membership check is
2039        // still sufficient for this regression.
2040        let names: Vec<&str> = seen[0].headers.iter().map(|(n, _)| n.as_str()).collect();
2041        assert!(names.contains(&"Authorization"), "auth header sent");
2042        assert!(names.contains(&"X-Foo"), "x-foo header sent");
2043        let auth_value = seen[0]
2044            .headers
2045            .iter()
2046            .find(|(n, _)| n == "Authorization")
2047            .map_or("", |(_, v)| v.as_str());
2048        assert_eq!(auth_value, "Bearer xyz");
2049    }
2050
2051    #[tokio::test]
2052    async fn headers_default_null_sends_no_headers() {
2053        let source = r#"
2054            import { get, Response } from "submilli:http";
2055            function main(): void {
2056                const r: Response = get("https://example.test/n");
2057                assert(r.status === 200, "status");
2058            }
2059        "#;
2060        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2061        let seen = mock.seen.lock().unwrap();
2062        assert_eq!(seen.len(), 1);
2063        assert!(seen[0].headers.is_empty(), "no headers when omitted");
2064    }
2065
2066    #[tokio::test]
2067    async fn response_to_string_format() {
2068        let source = r#"
2069            import { get, Response } from "submilli:http";
2070            function main(): void {
2071                const r: Response = get("https://example.test/t");
2072                const s: string = r.toString();
2073                assert(s === "Response(200 OK, https://example.test/)", s);
2074            }
2075        "#;
2076        run_with_mock(source, vec![ok_response(200, "")]).await;
2077    }
2078
2079    #[tokio::test]
2080    async fn throw_for_status_no_op_when_ok() {
2081        let source = r#"
2082            import { get, Response } from "submilli:http";
2083            function main(): void {
2084                const r: Response = get("https://example.test/ok");
2085                r.throwForStatus();
2086                assert(r.ok, "still alive");
2087            }
2088        "#;
2089        run_with_mock(source, vec![ok_response(200, "")]).await;
2090    }
2091
2092    #[tokio::test]
2093    async fn throw_for_status_traps_on_4xx() {
2094        let source = r#"
2095            import { get, Response } from "submilli:http";
2096            function main(): void {
2097                const r: Response = get("https://example.test/bad");
2098                r.throwForStatus();
2099            }
2100        "#;
2101        let compiled = crate::compile_script(source, "test.subm", crate::FileId(0), &[], &[])
2102            .expect("compile clean");
2103        let cfg = RuntimeConfig::default();
2104        let engine = cfg.engine().expect("engine");
2105        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
2106        let response_404 = HttpResponse {
2107            status: 404,
2108            status_text: "Not Found".to_string(),
2109            headers: vec![],
2110            body: vec![],
2111            final_url: "https://example.test/bad".to_string(),
2112        };
2113        data.http_client = Arc::new(MockHttpClient::new(vec![response_404]));
2114        let mut store = cfg.store(&engine, data).expect("store");
2115        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2116        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2117        install_runtime_async(&mut linker, &mut store)
2118            .await
2119            .expect("install");
2120        let inst = linker
2121            .instantiate_async(&mut store, &module)
2122            .await
2123            .expect("instantiate");
2124        let err = dispatch_main_async(&mut store, &inst)
2125            .await
2126            .expect_err("must trap on throwForStatus");
2127        let msg = format!("{err:?}");
2128        assert!(
2129            msg.contains("404") && msg.contains("Not Found"),
2130            "expected formatted HTTP error in trap; got: {msg}"
2131        );
2132    }
2133
2134    #[tokio::test]
2135    async fn request_runtime_verb_passthrough() {
2136        let source = r#"
2137            import { request, Response } from "submilli:http";
2138            function main(): void {
2139                const r: Response = request("post", "https://example.test/q");
2140                assert(r.status === 201, "status 201");
2141            }
2142        "#;
2143        let mock = run_with_mock(source, vec![ok_response(201, "")]).await;
2144        let seen = mock.seen.lock().unwrap();
2145        assert_eq!(seen.len(), 1);
2146        // Host fn upper-cases the method before storing it on the
2147        // `HttpRequest`.
2148        assert_eq!(seen[0].method, "POST");
2149    }
2150
2151    #[tokio::test]
2152    async fn internal_http_setup_failure_bypasses_catch_and_cleans_download() {
2153        struct BrokenSetup;
2154        #[async_trait::async_trait]
2155        impl HttpClient for BrokenSetup {
2156            async fn send(&self, _: &HttpRequest) -> Result<HttpResponse, HttpError> {
2157                Err(HttpError::Internal("injected setup failure".into()))
2158            }
2159            async fn download(
2160                &self,
2161                _: &HttpRequest,
2162                _: &mut (dyn std::io::Write + Send),
2163            ) -> Result<DownloadMeta, HttpError> {
2164                Err(HttpError::Internal("injected setup failure".into()))
2165            }
2166        }
2167        for operation in [
2168            "get(\"https://example.com/\");",
2169            "download(\"https://example.com/\", \"/payload\");",
2170        ] {
2171            let source = format!(
2172                r#"
2173                import {{ get, download }} from "submilli:http";
2174                function main(): void {{
2175                    try {{ {operation} }} catch (error) {{ return; }}
2176                }}
2177            "#
2178            );
2179            let compiled = compile_script(&source, "test.ts", crate::FileId(0), &[], &[]).unwrap();
2180            let cfg = RuntimeConfig::default();
2181            let engine = cfg.engine().unwrap();
2182            let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().unwrap());
2183            data.http_client = Arc::new(BrokenSetup);
2184            let mut store = cfg.store(&engine, data).unwrap();
2185            let module = wasmtime::Module::new(&engine, &compiled.wasm).unwrap();
2186            let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2187            install_runtime_async(&mut linker, &mut store)
2188                .await
2189                .unwrap();
2190            let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
2191            let error = dispatch_main_async(&mut store, &instance)
2192                .await
2193                .unwrap_err();
2194            assert!(
2195                format!("{error:?}").contains("injected setup failure"),
2196                "{error:?}"
2197            );
2198            assert_eq!(
2199                store.data().vfs.dir().unwrap().entries().unwrap().count(),
2200                0
2201            );
2202        }
2203    }
2204
2205    #[tokio::test]
2206    async fn download_into_a_read_only_mount_is_refused_before_any_request() {
2207        struct CountingClient(std::sync::atomic::AtomicUsize);
2208        #[async_trait::async_trait]
2209        impl HttpClient for CountingClient {
2210            async fn send(&self, _: &HttpRequest) -> Result<HttpResponse, HttpError> {
2211                self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2212                Err(HttpError::Network("unexpected".into()))
2213            }
2214            async fn download(
2215                &self,
2216                _: &HttpRequest,
2217                _: &mut (dyn std::io::Write + Send),
2218            ) -> Result<DownloadMeta, HttpError> {
2219                self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2220                Err(HttpError::Network("unexpected".into()))
2221            }
2222        }
2223        let source = r#"
2224            import { download } from "submilli:http";
2225            function main(): string {
2226                let mountPoint = "allowed";
2227                try { download("https://example.com/", "/rw", { overwrite: true }); }
2228                catch (e) { mountPoint = String(e).includes("mount point") ? "refused" : String(e); }
2229                try { download("https://example.com/", "/ro/payload"); }
2230                catch (e: PermissionDeniedError) { return e.capability + "|" + mountPoint; }
2231                return "allowed";
2232            }
2233        "#;
2234        let volume = tempfile::tempdir().unwrap();
2235        let writable = tempfile::tempdir().unwrap();
2236        let vfs = Vfs::tempdir()
2237            .unwrap()
2238            .with_mount(crate::runtime::vfs::MountSpec {
2239                guest_path: "/ro".into(),
2240                host: volume.path().to_path_buf(),
2241                volume: "ro".into(),
2242                access: crate::runtime::vfs::Access::ReadOnly,
2243                quota: None,
2244            })
2245            .unwrap()
2246            .with_mount(crate::runtime::vfs::MountSpec {
2247                guest_path: "/rw".into(),
2248                host: writable.path().to_path_buf(),
2249                volume: "rw".into(),
2250                access: crate::runtime::vfs::Access::ReadWrite,
2251                quota: None,
2252            })
2253            .unwrap();
2254        let compiled = compile_script(source, "test.ts", crate::FileId(0), &[], &[]).unwrap();
2255        let cfg = RuntimeConfig::default();
2256        let engine = cfg.engine().unwrap();
2257        let client = Arc::new(CountingClient(std::sync::atomic::AtomicUsize::new(0)));
2258        let mut data = StoreData::with_vfs("test-run".into(), vfs);
2259        data.http_client = client.clone();
2260        let mut store = cfg.store(&engine, data).unwrap();
2261        let module = wasmtime::Module::new(&engine, &compiled.wasm).unwrap();
2262        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2263        install_runtime_async(&mut linker, &mut store)
2264            .await
2265            .unwrap();
2266        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
2267        let value = dispatch_main_async(&mut store, &instance).await.unwrap();
2268        assert!(
2269            format!("{value:?}").contains("fs.write|refused"),
2270            "{value:?}"
2271        );
2272        assert_eq!(client.0.load(std::sync::atomic::Ordering::SeqCst), 0);
2273        assert_eq!(std::fs::read_dir(volume.path()).unwrap().count(), 0);
2274        assert_eq!(std::fs::read_dir(writable.path()).unwrap().count(), 0);
2275    }
2276
2277    #[tokio::test]
2278    async fn network_error_traps_with_message() {
2279        struct FailingClient;
2280        #[async_trait::async_trait]
2281        impl HttpClient for FailingClient {
2282            async fn send(&self, _req: &HttpRequest) -> Result<HttpResponse, HttpError> {
2283                Err(HttpError::Network("dns: no such host".into()))
2284            }
2285            async fn download(
2286                &self,
2287                _req: &HttpRequest,
2288                _writer: &mut (dyn std::io::Write + Send),
2289            ) -> Result<DownloadMeta, HttpError> {
2290                Err(HttpError::Network("dns: no such host".into()))
2291            }
2292        }
2293        let source = r#"
2294            import { get } from "submilli:http";
2295            function main(): void {
2296                get("https://example.test/z");
2297            }
2298        "#;
2299        let compiled =
2300            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
2301        let cfg = RuntimeConfig::default();
2302        let engine = cfg.engine().expect("engine");
2303        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
2304        data.http_client = Arc::new(FailingClient);
2305        let mut store = cfg.store(&engine, data).expect("store");
2306        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2307        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2308        install_runtime_async(&mut linker, &mut store)
2309            .await
2310            .expect("install");
2311        let inst = linker
2312            .instantiate_async(&mut store, &module)
2313            .await
2314            .expect("instantiate");
2315        let err = dispatch_main_async(&mut store, &inst)
2316            .await
2317            .expect_err("must trap on network error");
2318        let msg = format!("{err:?}");
2319        assert!(
2320            msg.contains("dns: no such host"),
2321            "expected network error message in trap; got: {msg}"
2322        );
2323    }
2324
2325    #[tokio::test]
2326    async fn post_with_string_body_defaults_ct() {
2327        let source = r#"
2328            import { post, Response } from "submilli:http";
2329            function main(): void {
2330                const r: Response = post("https://example.test/p", "hello");
2331                assert(r.status === 200, "status round-tripped");
2332            }
2333        "#;
2334        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2335        let seen = mock.seen.lock().unwrap();
2336        assert_eq!(seen.len(), 1);
2337        assert_eq!(seen[0].method, "POST");
2338        assert_eq!(seen[0].body, b"hello");
2339        let ct = seen[0]
2340            .headers
2341            .iter()
2342            .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2343            .map(|(_, v)| v.as_str());
2344        assert_eq!(
2345            ct,
2346            Some("text/plain; charset=utf-8"),
2347            "string body must default Content-Type for string body"
2348        );
2349    }
2350
2351    #[tokio::test]
2352    async fn post_with_uint8_body_no_ct_default() {
2353        let source = r#"
2354            import { post, Response } from "submilli:http";
2355            function main(): void {
2356                const r: Response = post(
2357                    "https://example.test/p",
2358                    new Uint8Array([1, 2, 3])
2359                );
2360                assert(r.status === 200, "status round-tripped");
2361            }
2362        "#;
2363        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2364        let seen = mock.seen.lock().unwrap();
2365        assert_eq!(seen.len(), 1);
2366        assert_eq!(seen[0].body, vec![1u8, 2, 3]);
2367        let ct_present = seen[0]
2368            .headers
2369            .iter()
2370            .any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
2371        assert!(
2372            !ct_present,
2373            "binary body must NOT default Content-Type for Uint8Array body; saw headers={:?}",
2374            seen[0].headers
2375        );
2376    }
2377
2378    #[tokio::test]
2379    async fn post_user_ct_wins_over_default() {
2380        let source = r#"
2381            import { post, Response, Headers } from "submilli:http";
2382            function main(): void {
2383                const h: Headers = new Map<string, string>();
2384                h.set("Content-Type", "application/json");
2385                const r: Response = post(
2386                    "https://example.test/p",
2387                    "{\"k\":1}",
2388                    h
2389                );
2390                assert(r.status === 200, "status round-tripped");
2391            }
2392        "#;
2393        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2394        let seen = mock.seen.lock().unwrap();
2395        assert_eq!(seen.len(), 1);
2396        assert_eq!(seen[0].body, br#"{"k":1}"#);
2397        let cts: Vec<&str> = seen[0]
2398            .headers
2399            .iter()
2400            .filter(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2401            .map(|(_, v)| v.as_str())
2402            .collect();
2403        assert_eq!(
2404            cts,
2405            vec!["application/json"],
2406            "user Content-Type must win and not be duplicated"
2407        );
2408    }
2409
2410    #[tokio::test]
2411    async fn null_body_is_json_and_undefined_body_is_empty() {
2412        let source = r#"
2413            import { post, request } from "submilli:http";
2414            function main(): void {
2415                post("https://example.test/p", null);
2416                request("POST", "https://example.test/p", null);
2417                post("https://example.test/p", undefined);
2418            }
2419        "#;
2420        let mock = run_with_mock(source, vec![ok_response(200, ""); 3]).await;
2421        let seen = mock.seen.lock().unwrap();
2422        assert_eq!(seen.len(), 3);
2423        for req in seen.iter().take(2) {
2424            assert_eq!(req.body, b"null");
2425            let content_types: Vec<&str> = req
2426                .headers
2427                .iter()
2428                .filter(|(name, _)| name.eq_ignore_ascii_case("content-type"))
2429                .map(|(_, value)| value.as_str())
2430                .collect();
2431            assert_eq!(content_types, vec!["application/json"]);
2432        }
2433        let absent = &seen[2];
2434        assert!(absent.body.is_empty());
2435        assert!(
2436            !absent
2437                .headers
2438                .iter()
2439                .any(|(name, _)| { name.eq_ignore_ascii_case("content-type") })
2440        );
2441    }
2442
2443    #[tokio::test]
2444    async fn post_undefined_body_sends_empty() {
2445        let source = r#"
2446            import { post, Response } from "submilli:http";
2447            function main(): void {
2448                const r: Response = post("https://example.test/p", undefined);
2449                assert(r.status === 200, "status round-tripped");
2450                const r2: Response = post("https://example.test/p");
2451                assert(r2.status === 200, "omitted body round-tripped");
2452            }
2453        "#;
2454        let mock = run_with_mock(source, vec![ok_response(200, ""), ok_response(200, "")]).await;
2455        let seen = mock.seen.lock().unwrap();
2456        assert_eq!(seen.len(), 2);
2457        for req in seen.iter() {
2458            assert!(
2459                req.body.is_empty(),
2460                "undefined body must wire as zero bytes"
2461            );
2462            let ct_present = req
2463                .headers
2464                .iter()
2465                .any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
2466            assert!(
2467                !ct_present,
2468                "undefined body must not trigger CT default; saw headers={:?}",
2469                req.headers
2470            );
2471        }
2472    }
2473
2474    #[tokio::test]
2475    async fn post_lowercase_user_ct_blocks_default() {
2476        let source = r#"
2477            import { post, Response, Headers } from "submilli:http";
2478            function main(): void {
2479                const h: Headers = new Map<string, string>();
2480                h.set("content-type", "application/xml");
2481                const r: Response = post("https://example.test/p", "<x/>", h);
2482                assert(r.status === 200, "status round-tripped");
2483            }
2484        "#;
2485        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2486        let seen = mock.seen.lock().unwrap();
2487        assert_eq!(seen.len(), 1);
2488        let cts: Vec<&str> = seen[0]
2489            .headers
2490            .iter()
2491            .filter(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2492            .map(|(_, v)| v.as_str())
2493            .collect();
2494        assert_eq!(cts, vec!["application/xml"]);
2495    }
2496
2497    #[tokio::test]
2498    async fn post_object_body_json_ct() {
2499        let source = r#"
2500            import { post, Response } from "submilli:http";
2501            function main(): void {
2502                const r: Response = post("https://example.test/p", { name: "alice" });
2503                assert(r.status === 200, "status round-tripped");
2504            }
2505        "#;
2506        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2507        let seen = mock.seen.lock().unwrap();
2508        assert_eq!(seen.len(), 1);
2509        assert_eq!(seen[0].body, br#"{"name":"alice"}"#);
2510        let ct = seen[0]
2511            .headers
2512            .iter()
2513            .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2514            .map(|(_, v)| v.as_str());
2515        assert_eq!(
2516            ct,
2517            Some("application/json"),
2518            "object body must default Content-Type to application/json"
2519        );
2520    }
2521
2522    #[tokio::test]
2523    async fn post_array_body_json_ct() {
2524        let source = r#"
2525            import { post, Response } from "submilli:http";
2526            function main(): void {
2527                const r: Response = post("https://example.test/p", [1, 2, 3]);
2528                assert(r.status === 200, "status round-tripped");
2529            }
2530        "#;
2531        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2532        let seen = mock.seen.lock().unwrap();
2533        assert_eq!(seen.len(), 1);
2534        assert_eq!(seen[0].body, b"[1,2,3]");
2535        let ct = seen[0]
2536            .headers
2537            .iter()
2538            .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2539            .map(|(_, v)| v.as_str());
2540        assert_eq!(
2541            ct,
2542            Some("application/json"),
2543            "array body must default Content-Type to application/json"
2544        );
2545    }
2546
2547    #[tokio::test]
2548    async fn post_object_body_user_ct_wins() {
2549        let source = r#"
2550            import { post, Response, Headers } from "submilli:http";
2551            function main(): void {
2552                const h: Headers = new Map<string, string>();
2553                h.set("Content-Type", "application/vnd.custom");
2554                const r: Response = post("https://example.test/p", { name: "alice" }, h);
2555                assert(r.status === 200, "status round-tripped");
2556            }
2557        "#;
2558        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2559        let seen = mock.seen.lock().unwrap();
2560        assert_eq!(seen.len(), 1);
2561        assert_eq!(seen[0].body, br#"{"name":"alice"}"#);
2562        let cts: Vec<&str> = seen[0]
2563            .headers
2564            .iter()
2565            .filter(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2566            .map(|(_, v)| v.as_str())
2567            .collect();
2568        assert_eq!(
2569            cts,
2570            vec!["application/vnd.custom"],
2571            "user Content-Type must win over the application/json default"
2572        );
2573    }
2574
2575    #[tokio::test]
2576    async fn request_form_with_body() {
2577        let source = r#"
2578            import { request, Response } from "submilli:http";
2579            function main(): void {
2580                const r: Response = request("POST", "https://example.test/p", "abc");
2581                assert(r.status === 200, "status round-tripped");
2582            }
2583        "#;
2584        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2585        let seen = mock.seen.lock().unwrap();
2586        assert_eq!(seen.len(), 1);
2587        assert_eq!(seen[0].method, "POST");
2588        assert_eq!(seen[0].body, b"abc");
2589    }
2590
2591    async fn run_download_with_mock(
2592        source: &str,
2593        scripted: Vec<HttpResponse>,
2594        security: Option<Arc<dyn SecurityCheck>>,
2595        vfs_root: &std::path::Path,
2596    ) -> (Arc<MockHttpClient>, Result<(), String>) {
2597        let mock = Arc::new(MockHttpClient::new(scripted));
2598        let res = run_download_with_client(source, mock.clone(), security, vfs_root).await;
2599        (mock, res)
2600    }
2601
2602    async fn run_download_with_client(
2603        source: &str,
2604        client: Arc<dyn HttpClient>,
2605        security: Option<Arc<dyn SecurityCheck>>,
2606        vfs_root: &std::path::Path,
2607    ) -> Result<(), String> {
2608        run_download_measured(source, client, security, vfs_root)
2609            .await
2610            .0
2611    }
2612
2613    async fn run_download_measured(
2614        source: &str,
2615        client: Arc<dyn HttpClient>,
2616        security: Option<Arc<dyn SecurityCheck>>,
2617        vfs_root: &std::path::Path,
2618    ) -> (Result<(), String>, u64) {
2619        run_download_measured_at(source, client, security, vfs_root, "/").await
2620    }
2621
2622    async fn run_download_at(
2623        source: &str,
2624        client: Arc<dyn HttpClient>,
2625        security: Option<Arc<dyn SecurityCheck>>,
2626        vfs_root: &std::path::Path,
2627        cwd: &str,
2628    ) -> Result<(), String> {
2629        run_download_measured_at(source, client, security, vfs_root, cwd)
2630            .await
2631            .0
2632    }
2633
2634    async fn run_download_measured_at(
2635        source: &str,
2636        client: Arc<dyn HttpClient>,
2637        security: Option<Arc<dyn SecurityCheck>>,
2638        vfs_root: &std::path::Path,
2639        cwd: &str,
2640    ) -> (Result<(), String>, u64) {
2641        let compiled =
2642            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
2643        let cfg = RuntimeConfig::default();
2644        let engine = cfg.engine().expect("engine");
2645        let vfs = Vfs::external(vfs_root.to_path_buf())
2646            .expect("external vfs")
2647            .with_cwd(cwd)
2648            .expect("cwd");
2649        let mut data = StoreData::with_vfs("test-run".into(), vfs);
2650        data.http_client = client;
2651        if let Some(sec) = security {
2652            data.security_check = sec;
2653        }
2654        let mut store = cfg.store(&engine, data).expect("store");
2655        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2656        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2657        install_runtime_async(&mut linker, &mut store)
2658            .await
2659            .expect("install");
2660        let inst = linker
2661            .instantiate_async(&mut store, &module)
2662            .await
2663            .expect("instantiate");
2664        let result = dispatch_main_async(&mut store, &inst)
2665            .await
2666            .map(|_| ())
2667            .map_err(|e| format!("{e:?}"));
2668        (result, store.data().host_fuel)
2669    }
2670
2671    #[test]
2672    fn download_options_cannot_exceed_operator_limits() {
2673        for value in [-1.0, 0.5, f64::NAN, f64::INFINITY, 129.0] {
2674            assert!(super::download_limit(value, 128, "maxBytes").is_err());
2675        }
2676        assert_eq!(super::download_limit(0.0, 128, "maxBytes").unwrap(), 0);
2677        assert_eq!(super::download_limit(128.0, 128, "maxBytes").unwrap(), 128);
2678    }
2679
2680    #[tokio::test]
2681    async fn download_over_limit_options_throw_before_request() {
2682        let source = r#"
2683            import { download } from "submilli:http";
2684            function main(): void {
2685                let caught = 0;
2686                try { download("https://example.test/f", "/out", {maxBytes: 52428801}); }
2687                catch (e: RangeError) { caught += 1; }
2688                try { download("https://example.test/f", "/out", {timeout: 60001}); }
2689                catch (e: RangeError) { caught += 1; }
2690                assert(caught === 2);
2691            }
2692        "#;
2693        let root = tempfile::tempdir().unwrap();
2694        let (mock, result) = run_download_with_mock(source, vec![], None, root.path()).await;
2695        result.unwrap();
2696        assert!(mock.seen.lock().unwrap().is_empty());
2697        assert!(dir_is_empty(root.path()));
2698    }
2699
2700    struct CwdPolicy;
2701    impl SecurityCheck for CwdPolicy {
2702        fn check(&self, _: &str, _: &str, _: &serde_json::Value) -> CheckOutcome {
2703            CheckOutcome::Deny {
2704                rule: None,
2705                reason: "missing cwd".into(),
2706            }
2707        }
2708        fn check_with_cwd(
2709            &self,
2710            _: &str,
2711            capability: &str,
2712            context: &serde_json::Value,
2713            cwd: &str,
2714        ) -> CheckOutcome {
2715            let field = if capability == "http.download" {
2716                "vfs_path"
2717            } else {
2718                "path"
2719            };
2720            let path = context
2721                .get(field)
2722                .and_then(serde_json::Value::as_str)
2723                .unwrap_or("");
2724            if crate::runtime::fs::guest_normalize(cwd, path)
2725                .is_ok_and(|path| path == "/notes/out.bin")
2726            {
2727                CheckOutcome::Allow { rule: None }
2728            } else {
2729                CheckOutcome::Deny {
2730                    rule: None,
2731                    reason: "outside notes".into(),
2732                }
2733            }
2734        }
2735    }
2736    #[tokio::test]
2737    async fn download_uses_cwd_for_policy_and_io() {
2738        let tmp = tempfile::tempdir().unwrap();
2739        let mock = Arc::new(MockHttpClient::new(vec![ok_response(200, "hello")]));
2740        run_download_at(r#"import { download } from "submilli:http"; function main(): void { download("https://example.test/file", "out.bin"); }"#,
2741            mock, Some(Arc::new(CwdPolicy)), tmp.path(), "/notes").await.unwrap();
2742        assert_eq!(
2743            std::fs::read(tmp.path().join("notes/out.bin")).unwrap(),
2744            b"hello"
2745        );
2746        assert!(!tmp.path().join("out.bin").exists());
2747    }
2748
2749    #[tokio::test]
2750    async fn download_basic_writes_file_and_returns_meta() {
2751        let source = r#"
2752            import { download, DownloadResult } from "submilli:http";
2753            function main(): void {
2754                const r: DownloadResult = download(
2755                    "https://example.test/file.bin",
2756                    "/out.bin"
2757                );
2758                assert(r.status === 200, "status 200");
2759                assert(r.bytesWritten === 5, "wrote 5 bytes");
2760                assert(r.path === "/out.bin", "path echoed");
2761                assert(r.contentType === "text/plain", "content type extracted");
2762            }
2763        "#;
2764        let tmp = tempfile::tempdir().expect("tempdir");
2765        let (mock, res) =
2766            run_download_with_mock(source, vec![ok_response(200, "hello")], None, tmp.path()).await;
2767        res.expect("main ran");
2768        let on_disk = std::fs::read(tmp.path().join("out.bin")).expect("file written");
2769        assert_eq!(on_disk, b"hello");
2770        let seen = mock.seen.lock().unwrap();
2771        assert_eq!(seen.len(), 1);
2772        assert_eq!(seen[0].method, "GET");
2773        assert_eq!(seen[0].url, "https://example.test/file.bin");
2774    }
2775
2776    #[tokio::test]
2777    async fn download_refuses_when_file_exists_default() {
2778        let source = r#"
2779            import { download } from "submilli:http";
2780            function main(): void {
2781                download("https://example.test/f", "/exists.bin");
2782            }
2783        "#;
2784        let cfg = RuntimeConfig::default();
2785        let engine = cfg.engine().expect("engine");
2786        let vfs = Vfs::tempdir().expect("tempdir");
2787        std::fs::write(vfs.root().join("exists.bin"), b"old").expect("seed");
2788        let mut data = StoreData::with_vfs("test-run".into(), vfs);
2789        let mock = Arc::new(MockHttpClient::new(vec![ok_response(200, "hello")]));
2790        data.http_client = mock;
2791        let compiled =
2792            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile");
2793        let mut store = cfg.store(&engine, data).expect("store");
2794        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2795        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2796        install_runtime_async(&mut linker, &mut store)
2797            .await
2798            .expect("install");
2799        let inst = linker
2800            .instantiate_async(&mut store, &module)
2801            .await
2802            .expect("instantiate");
2803        let err = dispatch_main_async(&mut store, &inst)
2804            .await
2805            .expect_err("must trap on existing file");
2806        let msg = format!("{err:?}");
2807        assert!(
2808            msg.contains("file exists"),
2809            "expected file-exists trap; got: {msg}"
2810        );
2811        assert!(
2812            msg.contains("overwrite: true"),
2813            "expected actionable hint; got: {msg}"
2814        );
2815    }
2816
2817    #[tokio::test]
2818    async fn download_overwrites_when_true() {
2819        let source = r#"
2820            import { download, DownloadResult } from "submilli:http";
2821            function main(): void {
2822                const r: DownloadResult = download(
2823                    "https://example.test/f",
2824                    "/exists.bin",
2825                    { overwrite: true }
2826                );
2827                assert(r.bytesWritten === 3, "wrote 3 bytes");
2828            }
2829        "#;
2830        let cfg = RuntimeConfig::default();
2831        let engine = cfg.engine().expect("engine");
2832        let vfs = Vfs::tempdir().expect("tempdir");
2833        std::fs::write(vfs.root().join("exists.bin"), b"old").expect("seed");
2834        let vfs_root = vfs.root().to_path_buf();
2835        let mut data = StoreData::with_vfs("test-run".into(), vfs);
2836        let mock = Arc::new(MockHttpClient::new(vec![ok_response(200, "new")]));
2837        data.http_client = mock;
2838        let compiled =
2839            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile");
2840        let mut store = cfg.store(&engine, data).expect("store");
2841        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2842        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2843        install_runtime_async(&mut linker, &mut store)
2844            .await
2845            .expect("install");
2846        let inst = linker
2847            .instantiate_async(&mut store, &module)
2848            .await
2849            .expect("instantiate");
2850        dispatch_main_async(&mut store, &inst)
2851            .await
2852            .expect("main ran");
2853        let on_disk = std::fs::read(vfs_root.join("exists.bin")).expect("file written");
2854        assert_eq!(on_disk, b"new", "overwritten with new bytes");
2855    }
2856
2857    #[tokio::test]
2858    async fn download_too_large_traps() {
2859        let source = r#"
2860            import { download } from "submilli:http";
2861            function main(): void {
2862                download(
2863                    "https://example.test/f",
2864                    "/big.bin",
2865                    { maxBytes: 3 }
2866                );
2867            }
2868        "#;
2869        let tmp = tempfile::tempdir().expect("tempdir");
2870        let (_mock, res) =
2871            run_download_with_mock(source, vec![ok_response(200, "hello")], None, tmp.path()).await;
2872        let err = res.expect_err("must trap on too-large");
2873        assert!(
2874            err.contains("too large") || err.contains("limit"),
2875            "expected too-large trap; got: {err}"
2876        );
2877        assert!(
2878            !tmp.path().join("big.bin").exists(),
2879            "no final file should be written when capped",
2880        );
2881        // Best-effort cleanup on error removes .tmp siblings too.
2882        let stragglers: Vec<_> = std::fs::read_dir(tmp.path())
2883            .expect("readdir")
2884            .filter_map(std::result::Result::ok)
2885            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
2886            .collect();
2887        assert!(
2888            stragglers.is_empty(),
2889            "expected no .tmp leftovers, got: {:?}",
2890            stragglers
2891                .iter()
2892                .map(std::fs::DirEntry::file_name)
2893                .collect::<Vec<_>>(),
2894        );
2895    }
2896
2897    #[tokio::test]
2898    async fn download_headers_threaded() {
2899        let source = r#"
2900            import { download, Headers } from "submilli:http";
2901            function main(): void {
2902                const h: Headers = new Map<string, string>();
2903                h.set("Authorization", "Bearer xyz");
2904                download("https://example.test/f", "/out.bin", { headers: h });
2905            }
2906        "#;
2907        let tmp = tempfile::tempdir().expect("tempdir");
2908        let (mock, res) =
2909            run_download_with_mock(source, vec![ok_response(200, "")], None, tmp.path()).await;
2910        res.expect("main ran");
2911        let seen = mock.seen.lock().unwrap();
2912        assert_eq!(seen.len(), 1);
2913        let auth = seen[0]
2914            .headers
2915            .iter()
2916            .find(|(n, _)| n == "Authorization")
2917            .map(|(_, v)| v.as_str());
2918        assert_eq!(auth, Some("Bearer xyz"));
2919    }
2920
2921    #[tokio::test]
2922    async fn download_deny_http_traps() {
2923        struct DenyHttpDownload;
2924        impl SecurityCheck for DenyHttpDownload {
2925            fn check(
2926                &self,
2927                _caller: &str,
2928                capability: &str,
2929                _context: &serde_json::Value,
2930            ) -> CheckOutcome {
2931                if capability == "http.download" {
2932                    CheckOutcome::Deny {
2933                        rule: None,
2934                        reason: "denied http.download in test".into(),
2935                    }
2936                } else {
2937                    CheckOutcome::Allow { rule: None }
2938                }
2939            }
2940        }
2941        let source = r#"
2942            import { download } from "submilli:http";
2943            function main(): void {
2944                download("https://example.test/f", "/out.bin");
2945            }
2946        "#;
2947        let tmp = tempfile::tempdir().expect("tempdir");
2948        let (mock, res) = run_download_with_mock(
2949            source,
2950            vec![ok_response(200, "hello")],
2951            Some(Arc::new(DenyHttpDownload)),
2952            tmp.path(),
2953        )
2954        .await;
2955        let err = res.expect_err("must trap on deny");
2956        assert!(err.contains("permission denied"), "got: {err}");
2957        assert!(err.contains("http.download"), "got: {err}");
2958        assert!(err.contains("caller=main"), "got: {err}");
2959        assert!(
2960            mock.seen.lock().unwrap().is_empty(),
2961            "http_client.send should not have been invoked"
2962        );
2963    }
2964
2965    #[tokio::test]
2966    async fn download_deny_fs_write_traps() {
2967        struct DenyFsWrite;
2968        impl SecurityCheck for DenyFsWrite {
2969            fn check(
2970                &self,
2971                _caller: &str,
2972                capability: &str,
2973                _context: &serde_json::Value,
2974            ) -> CheckOutcome {
2975                if capability == "fs.write" {
2976                    CheckOutcome::Deny {
2977                        rule: None,
2978                        reason: "denied fs.write in test".into(),
2979                    }
2980                } else {
2981                    CheckOutcome::Allow { rule: None }
2982                }
2983            }
2984        }
2985        let source = r#"
2986            import { download } from "submilli:http";
2987            function main(): void {
2988                download("https://example.test/f", "/out.bin");
2989            }
2990        "#;
2991        let tmp = tempfile::tempdir().expect("tempdir");
2992        let (mock, res) = run_download_with_mock(
2993            source,
2994            vec![ok_response(200, "hello")],
2995            Some(Arc::new(DenyFsWrite)),
2996            tmp.path(),
2997        )
2998        .await;
2999        let err = res.expect_err("must trap on deny");
3000        assert!(err.contains("permission denied"), "got: {err}");
3001        assert!(err.contains("fs.write"), "got: {err}");
3002        assert!(
3003            mock.seen.lock().unwrap().is_empty(),
3004            "fs.write check must run before transport"
3005        );
3006    }
3007
3008    #[tokio::test]
3009    async fn download_path_escape_traps() {
3010        let source = r#"
3011            import { download } from "submilli:http";
3012            function main(): void {
3013                download("https://example.test/f", "../etc/passwd");
3014            }
3015        "#;
3016        let tmp = tempfile::tempdir().expect("tempdir");
3017        let (_mock, res) =
3018            run_download_with_mock(source, vec![ok_response(200, "x")], None, tmp.path()).await;
3019        let err = res.expect_err("must trap on path escape");
3020        assert!(
3021            err.contains("path escapes the VFS root"),
3022            "expected sandbox-escape trap; got: {err}"
3023        );
3024    }
3025
3026    /// A VFS root plus a sibling directory outside it, so a test can assert that
3027    /// nothing leaked past the boundary.
3028    fn root_and_outside() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
3029        let td = tempfile::tempdir().expect("tempdir");
3030        let root = td.path().join("root");
3031        let outside = td.path().join("outside");
3032        std::fs::create_dir(&root).expect("mkdir root");
3033        std::fs::create_dir(&outside).expect("mkdir outside");
3034        (td, root, outside)
3035    }
3036
3037    fn dir_is_empty(dir: &std::path::Path) -> bool {
3038        std::fs::read_dir(dir).expect("readdir").next().is_none()
3039    }
3040
3041    #[tokio::test]
3042    #[cfg(unix)]
3043    async fn download_under_an_escaping_link_refuses() {
3044        let source = r#"
3045            import { download } from "submilli:http";
3046            function main(): void {
3047                download("https://example.test/f", "/link/authorized_keys");
3048            }
3049        "#;
3050        let (_td, root, outside) = root_and_outside();
3051        std::os::unix::fs::symlink(&outside, root.join("link")).expect("symlink");
3052        let (_mock, res) =
3053            run_download_with_mock(source, vec![ok_response(200, "pwned")], None, &root).await;
3054        let err = res.expect_err("must refuse a destination behind an escaping link");
3055        assert!(
3056            err.contains("path escapes the VFS root"),
3057            "expected the escape diagnostic; got: {err}"
3058        );
3059        assert!(
3060            dir_is_empty(&outside),
3061            "nothing may be written outside the VFS root",
3062        );
3063    }
3064
3065    #[tokio::test]
3066    #[cfg(unix)]
3067    async fn download_under_an_escaping_link_refuses_even_with_overwrite() {
3068        let source = r#"
3069            import { download } from "submilli:http";
3070            function main(): void {
3071                download(
3072                    "https://example.test/f",
3073                    "/link/authorized_keys",
3074                    { overwrite: true }
3075                );
3076            }
3077        "#;
3078        let (_td, root, outside) = root_and_outside();
3079        std::os::unix::fs::symlink(&outside, root.join("link")).expect("symlink");
3080        std::fs::write(outside.join("authorized_keys"), b"original").expect("seed");
3081        let (_mock, res) =
3082            run_download_with_mock(source, vec![ok_response(200, "pwned")], None, &root).await;
3083        let err = res.expect_err("overwrite must not license an escape");
3084        assert!(
3085            err.contains("path escapes the VFS root"),
3086            "expected the escape diagnostic; got: {err}"
3087        );
3088        assert_eq!(
3089            std::fs::read(outside.join("authorized_keys")).expect("still there"),
3090            b"original",
3091            "the host file must be untouched",
3092        );
3093    }
3094
3095    #[tokio::test]
3096    #[cfg(unix)]
3097    async fn download_through_an_internal_relative_link_succeeds() {
3098        let source = r#"
3099            import { download } from "submilli:http";
3100            function main(): void {
3101                download("https://example.test/f", "/inner/out.bin");
3102            }
3103        "#;
3104        let (_td, root, _outside) = root_and_outside();
3105        std::fs::create_dir(root.join("real")).expect("mkdir real");
3106        std::os::unix::fs::symlink("./real", root.join("inner")).expect("symlink");
3107        let (_mock, res) =
3108            run_download_with_mock(source, vec![ok_response(200, "hello")], None, &root).await;
3109        res.expect("a relative link staying inside the root is traversable");
3110        assert_eq!(
3111            std::fs::read(root.join("real/out.bin")).expect("file written"),
3112            b"hello",
3113        );
3114    }
3115
3116    /// Policy is evaluated before any filesystem work, so a denied download reports the
3117    /// denial rather than whatever the path would have done.
3118    #[tokio::test]
3119    async fn download_denial_precedes_path_resolution() {
3120        struct DenyFsWrite;
3121        impl SecurityCheck for DenyFsWrite {
3122            fn check(
3123                &self,
3124                _caller: &str,
3125                capability: &str,
3126                _context: &serde_json::Value,
3127            ) -> CheckOutcome {
3128                if capability == "fs.write" {
3129                    CheckOutcome::Deny {
3130                        rule: None,
3131                        reason: "denied fs.write in test".into(),
3132                    }
3133                } else {
3134                    CheckOutcome::Allow { rule: None }
3135                }
3136            }
3137        }
3138        let source = r#"
3139            import { download } from "submilli:http";
3140            function main(): void {
3141                download("https://example.test/f", "../etc/passwd");
3142            }
3143        "#;
3144        let (_td, root, _outside) = root_and_outside();
3145        let (_mock, res) = run_download_with_mock(
3146            source,
3147            vec![ok_response(200, "x")],
3148            Some(Arc::new(DenyFsWrite)),
3149            &root,
3150        )
3151        .await;
3152        let err = res.expect_err("must trap");
3153        assert!(
3154            err.contains("permission denied") && err.contains("fs.write"),
3155            "policy must be consulted before the path is resolved; got: {err}"
3156        );
3157        assert!(
3158            !err.contains("path escapes the VFS root"),
3159            "resolution must not preempt the denial; got: {err}"
3160        );
3161    }
3162
3163    #[tokio::test]
3164    async fn download_missing_parent_reports_not_found_not_escape() {
3165        let source = r#"
3166            import { download } from "submilli:http";
3167            function main(): void {
3168                download("https://example.test/f", "/nope/out.bin");
3169            }
3170        "#;
3171        let (_td, root, _outside) = root_and_outside();
3172        let (_mock, res) =
3173            run_download_with_mock(source, vec![ok_response(200, "hello")], None, &root).await;
3174        let err = res.expect_err("must trap on a missing parent");
3175        assert!(
3176            err.contains("parent directory does not exist"),
3177            "a missing parent must not read as an escape; got: {err}"
3178        );
3179        assert!(
3180            !err.contains("path escapes the VFS root"),
3181            "a missing parent must not read as an escape; got: {err}"
3182        );
3183    }
3184
3185    /// The commit goes through the handle the destination resolved against, so a link
3186    /// swapped over the parent while the body streams cannot redirect it.
3187    #[tokio::test]
3188    #[cfg(unix)]
3189    async fn download_commit_refuses_when_the_parent_is_swapped_mid_transfer() {
3190        struct SwapDuringTransfer {
3191            parent: std::path::PathBuf,
3192            outside: std::path::PathBuf,
3193        }
3194        #[async_trait::async_trait]
3195        impl HttpClient for SwapDuringTransfer {
3196            async fn send(&self, _req: &HttpRequest) -> Result<HttpResponse, HttpError> {
3197                Err(HttpError::Other("send unused".into()))
3198            }
3199            async fn download(
3200                &self,
3201                _req: &HttpRequest,
3202                writer: &mut (dyn std::io::Write + Send),
3203            ) -> Result<DownloadMeta, HttpError> {
3204                writer.write_all(b"payload").expect("write body");
3205                std::fs::remove_dir_all(&self.parent).expect("drop the real parent");
3206                std::os::unix::fs::symlink(&self.outside, &self.parent).expect("swap in a link");
3207                Ok(DownloadMeta {
3208                    status: 200,
3209                    status_text: "OK".to_string(),
3210                    headers: Vec::new(),
3211                    final_url: "https://example.test/f".to_string(),
3212                    bytes_written: 7,
3213                })
3214            }
3215        }
3216        let source = r#"
3217            import { download } from "submilli:http";
3218            function main(): void {
3219                download("https://example.test/f", "/a/b/out.bin");
3220            }
3221        "#;
3222        let (_td, root, outside) = root_and_outside();
3223        std::fs::create_dir_all(root.join("a/b")).expect("mkdir a/b");
3224        let client = Arc::new(SwapDuringTransfer {
3225            parent: root.join("a/b"),
3226            outside: outside.clone(),
3227        });
3228        let res = run_download_with_client(source, client, None, &root).await;
3229        let err = res.expect_err("the commit must refuse");
3230        assert!(
3231            err.contains("path escapes the VFS root"),
3232            "expected the escape diagnostic at commit; got: {err}"
3233        );
3234        assert!(
3235            dir_is_empty(&outside),
3236            "the swapped-in link must not receive the download",
3237        );
3238    }
3239
3240    #[tokio::test]
3241    async fn failed_download_settles_received_bytes() {
3242        struct PartialTransfer(usize);
3243        #[async_trait::async_trait]
3244        impl HttpClient for PartialTransfer {
3245            async fn send(&self, _: &HttpRequest) -> Result<HttpResponse, HttpError> {
3246                Err(HttpError::Other("unused".into()))
3247            }
3248            async fn download(
3249                &self,
3250                _: &HttpRequest,
3251                writer: &mut (dyn std::io::Write + Send),
3252            ) -> Result<DownloadMeta, HttpError> {
3253                writer.write_all(&vec![b'x'; self.0]).unwrap();
3254                Err(HttpError::Network("interrupted".into()))
3255            }
3256        }
3257        let source = r#"
3258            import { download } from "submilli:http";
3259            function main(): void {
3260                try { download("https://example.test/f", "/out.bin"); }
3261                catch (e: Error) { assert(e.message.includes("interrupted")); }
3262            }
3263        "#;
3264        let root = tempfile::tempdir().unwrap();
3265        let mut costs = Vec::new();
3266        for n in [0, 128, 256] {
3267            let (result, fuel) =
3268                run_download_measured(source, Arc::new(PartialTransfer(n)), None, root.path())
3269                    .await;
3270            result.unwrap();
3271            assert!(dir_is_empty(root.path()));
3272            costs.push(fuel);
3273        }
3274        assert_eq!(costs[1] - costs[0], 2 * super::fuel::IO.cost(128));
3275        assert_eq!(costs[2] - costs[0], 2 * super::fuel::IO.cost(256));
3276    }
3277
3278    #[tokio::test]
3279    async fn failed_download_leaves_nothing_outside_the_root() {
3280        struct FailingTransfer;
3281        #[async_trait::async_trait]
3282        impl HttpClient for FailingTransfer {
3283            async fn send(&self, _req: &HttpRequest) -> Result<HttpResponse, HttpError> {
3284                Err(HttpError::Network("dns: no such host".into()))
3285            }
3286            async fn download(
3287                &self,
3288                _req: &HttpRequest,
3289                _writer: &mut (dyn std::io::Write + Send),
3290            ) -> Result<DownloadMeta, HttpError> {
3291                Err(HttpError::Network("dns: no such host".into()))
3292            }
3293        }
3294        let source = r#"
3295            import { download } from "submilli:http";
3296            function main(): void {
3297                download("https://example.test/f", "/out.bin");
3298            }
3299        "#;
3300        let (_td, root, _outside) = root_and_outside();
3301        let res = run_download_with_client(source, Arc::new(FailingTransfer), None, &root).await;
3302        assert!(res.is_err(), "must trap on transport failure");
3303        // The destination is inside the root, so an assertion about the *outside* directory
3304        // would hold no matter what the code did. The root is where a straggler can actually
3305        // appear, and it is what this test is for.
3306        assert!(
3307            dir_is_empty(&root),
3308            "a failed transfer must leave no temp file behind",
3309        );
3310    }
3311
3312    #[tokio::test]
3313    async fn download_decompress_gzip() {
3314        use std::io::Write;
3315        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
3316        encoder.write_all(b"hello gzipped").expect("encode");
3317        let gz_bytes = encoder.finish().expect("finish");
3318
3319        let source = r#"
3320            import { download, DownloadResult } from "submilli:http";
3321            function main(): void {
3322                const r: DownloadResult = download(
3323                    "https://example.test/data.txt.gz",
3324                    "/out.txt",
3325                    { decompress: true }
3326                );
3327                assert(r.bytesWritten === 13, "decompressed length");
3328            }
3329        "#;
3330        let response = HttpResponse {
3331            status: 200,
3332            status_text: "OK".into(),
3333            headers: vec![
3334                ("content-encoding".into(), "gzip".into()),
3335                ("content-type".into(), "text/plain".into()),
3336            ],
3337            body: gz_bytes,
3338            final_url: "https://example.test/data.txt.gz".into(),
3339        };
3340        let tmp = tempfile::tempdir().expect("tempdir");
3341        let (_mock, res) = run_download_with_mock(source, vec![response], None, tmp.path()).await;
3342        res.expect("main ran");
3343        let on_disk = std::fs::read(tmp.path().join("out.txt")).expect("file written");
3344        assert_eq!(on_disk, b"hello gzipped");
3345    }
3346
3347    // If `download` buffered via `send`, this test would never terminate — guards the streaming contract.
3348    #[tokio::test]
3349    async fn download_infinite_body_caps_without_buffering() {
3350        struct InfiniteReader;
3351        impl std::io::Read for InfiniteReader {
3352            fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3353                buf.fill(b'a');
3354                Ok(buf.len())
3355            }
3356        }
3357
3358        struct StreamingClient;
3359        #[async_trait::async_trait]
3360        impl HttpClient for StreamingClient {
3361            async fn send(&self, _req: &HttpRequest) -> Result<HttpResponse, HttpError> {
3362                panic!("download must NOT fall back to send")
3363            }
3364            async fn download(
3365                &self,
3366                req: &HttpRequest,
3367                writer: &mut (dyn std::io::Write + Send),
3368            ) -> Result<DownloadMeta, HttpError> {
3369                let bytes_written = stream_to_writer(
3370                    InfiniteReader,
3371                    writer,
3372                    super::transport::Decompression::None,
3373                    req.max_response_size,
3374                )?;
3375                Ok(DownloadMeta {
3376                    status: 200,
3377                    status_text: "OK".into(),
3378                    headers: vec![],
3379                    final_url: req.url.clone(),
3380                    bytes_written,
3381                })
3382            }
3383        }
3384
3385        let source = r#"
3386            import { download } from "submilli:http";
3387            function main(): void {
3388                download(
3389                    "https://example.test/infinite",
3390                    "/never.bin",
3391                    { maxBytes: 1024 }
3392                );
3393            }
3394        "#;
3395        let compiled =
3396            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
3397        let cfg = RuntimeConfig::default();
3398        let engine = cfg.engine().expect("engine");
3399        let tmp = tempfile::tempdir().expect("tempdir");
3400        let vfs = Vfs::external(tmp.path().to_path_buf()).expect("external vfs");
3401        let mut data = StoreData::with_vfs("test-run".into(), vfs);
3402        data.http_client = Arc::new(StreamingClient);
3403        let mut store = cfg.store(&engine, data).expect("store");
3404        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
3405        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
3406        install_runtime_async(&mut linker, &mut store)
3407            .await
3408            .expect("install");
3409        let inst = linker
3410            .instantiate_async(&mut store, &module)
3411            .await
3412            .expect("instantiate");
3413        let err = dispatch_main_async(&mut store, &inst)
3414            .await
3415            .expect_err("must trap on infinite-body cap");
3416        let msg = format!("{err:?}");
3417        assert!(
3418            msg.contains("too large") || msg.contains("limit"),
3419            "expected too-large trap; got: {msg}"
3420        );
3421        assert!(
3422            !tmp.path().join("never.bin").exists(),
3423            "no final file should land when cap aborts the stream",
3424        );
3425    }
3426
3427    // Security check order: http.download fires before fs.write.
3428    #[tokio::test]
3429    async fn download_from_a_script_is_attributed_to_main() {
3430        let recording = Arc::new(RecordingCheck {
3431            seen: Mutex::new(Vec::new()),
3432        });
3433        let source = r#"
3434            import { download } from "submilli:http";
3435            function main(): void {
3436                download("https://example.test/f", "/out.bin");
3437            }
3438        "#;
3439        let tmp = tempfile::tempdir().expect("tempdir");
3440        let (_mock, res) = run_download_with_mock(
3441            source,
3442            vec![ok_response(200, "")],
3443            Some(recording.clone()),
3444            tmp.path(),
3445        )
3446        .await;
3447        res.expect("main ran");
3448        let seen = recording.seen.lock().unwrap().clone();
3449        let download_caps: Vec<&str> = seen
3450            .iter()
3451            .map(|(_, c)| c.as_str())
3452            .filter(|c| *c == "http.download" || *c == "fs.write")
3453            .collect();
3454        assert_eq!(
3455            download_caps,
3456            vec!["http.download", "fs.write"],
3457            "expected http.download then fs.write in order; got: {download_caps:?}"
3458        );
3459        for (caller, capability) in &seen {
3460            assert_eq!(
3461                caller, "main",
3462                "expected caller=main for {capability}; got {caller}"
3463            );
3464        }
3465    }
3466
3467    fn json_response(body: &str) -> HttpResponse {
3468        HttpResponse {
3469            status: 200,
3470            status_text: "OK".to_string(),
3471            headers: vec![("content-type".to_string(), "application/json".to_string())],
3472            body: body.as_bytes().to_vec(),
3473            final_url: "https://example.test/json".to_string(),
3474        }
3475    }
3476
3477    #[tokio::test]
3478    async fn response_json_type_arg_errors() {
3479        let source = r#"
3480            import { get, Response } from "submilli:http";
3481            function main(): void {
3482                const r: Response = get("https://example.test/json");
3483                const o: { k: string } = r.json<{ k: string }>();
3484                assert(o.k === "v", "parsed json field");
3485            }
3486        "#;
3487        let diags = compile_script(source, "test.subm", crate::FileId(0), &[], &[])
3488            .expect_err("Response#json type arguments must error");
3489        assert!(
3490            diags
3491                .iter()
3492                .any(|d| d.message.contains("`r.json` does not take type arguments")),
3493            "expected the type-argument diagnostic; got: {:?}",
3494            diags.iter().map(|d| &d.message).collect::<Vec<_>>(),
3495        );
3496    }
3497
3498    #[tokio::test]
3499    async fn response_json_assignment_to_concrete_type_errors() {
3500        let source = r#"
3501            import { get, Response } from "submilli:http";
3502            function main(): void {
3503                const r: Response = get("https://example.test/json");
3504                const o: { k: string } = r.json();
3505            }
3506        "#;
3507        let diags = compile_script(source, "test.subm", crate::FileId(0), &[], &[])
3508            .expect_err("assigning unknown to concrete type must error");
3509        assert!(
3510            diags.iter().any(|d| d.message.contains("got `unknown`")),
3511            "expected the `unknown` assignment diagnostic; got: {:?}",
3512            diags.iter().map(|d| &d.message).collect::<Vec<_>>(),
3513        );
3514    }
3515
3516    #[tokio::test]
3517    async fn response_json_as_cast() {
3518        // `r.json()` returns `unknown`; `as T` performs normal runtime validation.
3519        let source = r#"
3520            import { get, Response } from "submilli:http";
3521            function main(): void {
3522                const r: Response = get("https://example.test/json");
3523                const o = r.json() as { k: string };
3524                assert(o.k === "v", "parsed via `r.json() as T`");
3525            }
3526        "#;
3527        run_with_mock(source, vec![json_response(r#"{"k":"v"}"#)]).await;
3528    }
3529
3530    // SUB-386: member access on a library-typed value must resolve without the
3531    // user also importing the interface name. These import only `get` and never
3532    // name `Response` — the type flows entirely from the return type.
3533
3534    #[tokio::test]
3535    async fn importless_response_property_access() {
3536        let source = r#"
3537            import { get } from "submilli:http";
3538            function main(): void {
3539                const r = get("https://example.test/u");
3540                assert(r.status === 200, "status is 200");
3541                assert(r.body === "hello", "body decoded");
3542                assert(r.ok, "ok for 2xx");
3543            }
3544        "#;
3545        run_with_mock(source, vec![ok_response(200, "hello")]).await;
3546    }
3547
3548    #[tokio::test]
3549    async fn importless_response_method_call() {
3550        let source = r#"
3551            import { get } from "submilli:http";
3552            function main(): void {
3553                const r = get("https://example.test/t");
3554                const s: string = r.toString();
3555                assert(s === "Response(200 OK, https://example.test/)", s);
3556                r.throwForStatus();
3557            }
3558        "#;
3559        run_with_mock(source, vec![ok_response(200, "")]).await;
3560    }
3561
3562    #[tokio::test]
3563    async fn importless_response_json() {
3564        // The `json()` rewrite must also fire without a `Response` import.
3565        let source = r#"
3566            import { get } from "submilli:http";
3567            function main(): void {
3568                const r = get("https://example.test/json");
3569                const o = r.json() as { k: string };
3570                assert(o.k === "v", "parsed json importlessly");
3571            }
3572        "#;
3573        run_with_mock(source, vec![json_response(r#"{"k":"v"}"#)]).await;
3574    }
3575
3576    #[tokio::test]
3577    async fn importless_headers_alias_member_access() {
3578        // The library `Headers` alias (→ prelude `Map`) resolves structurally
3579        // without importing `Headers`; `r.headers.get(...)` reads through it.
3580        let source = r#"
3581            import { get } from "submilli:http";
3582            function main(): void {
3583                const r = get("https://example.test/u");
3584                const ct = r.headers.get("content-type");
3585                assert(ct === "text/plain", "header read through importless alias");
3586            }
3587        "#;
3588        run_with_mock(source, vec![ok_response(200, "hi")]).await;
3589    }
3590
3591    /// Follows one scripted redirect through the request's guard, as a compliant
3592    /// custom transport must, and records whether the hop was allowed.
3593    struct RedirectingClient {
3594        hop_method: &'static str,
3595        hop_url: &'static str,
3596        method_rewritten: bool,
3597        sent_hop: Mutex<bool>,
3598    }
3599
3600    impl RedirectingClient {
3601        fn follow(&self, req: &HttpRequest) -> Result<(), HttpError> {
3602            let guard = req
3603                .redirect_guard
3604                .as_ref()
3605                .ok_or_else(|| HttpError::Other("request has no redirect guard".into()))?;
3606            let url = url::Url::parse(self.hop_url).unwrap();
3607            let body_len = if self.method_rewritten {
3608                0
3609            } else {
3610                req.body.len() as u64
3611            };
3612            guard
3613                .authorize(&super::RedirectHop {
3614                    method: self.hop_method,
3615                    url: &url,
3616                    method_rewritten: self.method_rewritten,
3617                    body_len,
3618                })
3619                .map_err(HttpError::PermissionDenied)?;
3620            *self.sent_hop.lock().unwrap() = true;
3621            Ok(())
3622        }
3623    }
3624
3625    #[async_trait::async_trait]
3626    impl HttpClient for RedirectingClient {
3627        async fn send(&self, req: &HttpRequest) -> Result<HttpResponse, HttpError> {
3628            self.follow(req)?;
3629            Ok(ok_response(200, "redirected"))
3630        }
3631
3632        async fn download(
3633            &self,
3634            req: &HttpRequest,
3635            writer: &mut (dyn std::io::Write + Send),
3636        ) -> Result<DownloadMeta, HttpError> {
3637            self.follow(req)?;
3638            writer.write_all(b"redirected").unwrap();
3639            Ok(DownloadMeta {
3640                status: 200,
3641                status_text: "OK".into(),
3642                headers: vec![],
3643                final_url: self.hop_url.into(),
3644                bytes_written: 10,
3645            })
3646        }
3647    }
3648
3649    /// Records every check and denies anything aimed at `evil.test`.
3650    #[derive(Default)]
3651    struct DenyEvilHost {
3652        seen: Mutex<Vec<(String, String, serde_json::Value)>>,
3653    }
3654
3655    impl SecurityCheck for DenyEvilHost {
3656        fn check(
3657            &self,
3658            caller: &str,
3659            capability: &str,
3660            context: &serde_json::Value,
3661        ) -> CheckOutcome {
3662            self.seen.lock().unwrap().push((
3663                caller.to_string(),
3664                capability.to_string(),
3665                context.clone(),
3666            ));
3667            if context["host"] == "evil.test" {
3668                CheckOutcome::Deny {
3669                    rule: None,
3670                    reason: "evil.test is not allowed".into(),
3671                }
3672            } else {
3673                CheckOutcome::Allow { rule: None }
3674            }
3675        }
3676    }
3677
3678    async fn run_redirect(
3679        source: &str,
3680        hop_method: &'static str,
3681        hop_url: &'static str,
3682        method_rewritten: bool,
3683    ) -> (
3684        Result<(), String>,
3685        bool,
3686        Vec<(String, String, serde_json::Value)>,
3687    ) {
3688        let tmp = tempfile::tempdir().expect("tempdir");
3689        let client = Arc::new(RedirectingClient {
3690            hop_method,
3691            hop_url,
3692            method_rewritten,
3693            sent_hop: Mutex::new(false),
3694        });
3695        let policy = Arc::new(DenyEvilHost::default());
3696        let result =
3697            run_download_with_client(source, client.clone(), Some(policy.clone()), tmp.path())
3698                .await;
3699        let sent = *client.sent_hop.lock().unwrap();
3700        let seen = policy.seen.lock().unwrap().clone();
3701        (result, sent, seen)
3702    }
3703
3704    #[tokio::test]
3705    async fn redirect_hops_are_checked_for_the_original_caller() {
3706        let source = r#"
3707            import { post } from "submilli:http";
3708            function main(): void {
3709                let caught = "";
3710                try {
3711                    post("https://example.test/start", "hello");
3712                } catch (e: PermissionDeniedError) {
3713                    caught = e.caller + " " + e.capability + ": " + e.reason;
3714                }
3715                assert(caught === "main http.post: evil.test is not allowed", caught);
3716            }
3717        "#;
3718        let (result, sent, seen) =
3719            run_redirect(source, "POST", "https://evil.test/collect", false).await;
3720        result.expect("denial is catchable");
3721        assert!(!sent, "a denied hop must not be sent");
3722        assert_eq!(seen.len(), 2);
3723        assert_eq!(
3724            seen[1],
3725            (
3726                "main".to_string(),
3727                "http.post".to_string(),
3728                serde_json::json!({
3729                    "host": "evil.test",
3730                    "path": "/collect",
3731                    "body_size": 5,
3732                    "timeout_ms": super::DEFAULT_TIMEOUT_MS,
3733                })
3734            )
3735        );
3736    }
3737
3738    #[tokio::test]
3739    async fn rewritten_redirect_hops_are_checked_as_get_on_host_and_path() {
3740        let source = r#"
3741            import { post } from "submilli:http";
3742            function main(): void {
3743                post("https://example.test/start", "hello");
3744            }
3745        "#;
3746        let (result, sent, seen) =
3747            run_redirect(source, "GET", "https://example.test/result", true).await;
3748        result.expect("allowed hop");
3749        assert!(sent);
3750        assert_eq!(
3751            seen[1],
3752            (
3753                "main".to_string(),
3754                "http.get".to_string(),
3755                serde_json::json!({ "host": "example.test", "path": "/result" })
3756            )
3757        );
3758    }
3759
3760    #[tokio::test]
3761    async fn download_redirect_hops_are_checked_before_anything_is_written() {
3762        let source = r#"
3763            import { download } from "submilli:http";
3764            function main(): void {
3765                download("https://example.test/f", "/out.bin");
3766            }
3767        "#;
3768        let (result, sent, seen) = run_redirect(source, "GET", "https://evil.test/f", false).await;
3769        let error = result.expect_err("denied hop");
3770        assert!(error.contains("permission denied"), "{error}");
3771        assert!(!sent);
3772        let capabilities: Vec<&str> = seen.iter().map(|(_, cap, _)| cap.as_str()).collect();
3773        assert_eq!(capabilities, ["http.download", "fs.write", "http.download"]);
3774        assert_eq!(
3775            seen[2].2,
3776            serde_json::json!({
3777                "host": "evil.test",
3778                "url_path": "/f",
3779                "vfs_path": "/out.bin",
3780                "max_bytes": 50 * 1024 * 1024,
3781                "overwrite": false,
3782                "decompress": false,
3783            })
3784        );
3785    }
3786
3787    /// Rebuilds the request the way a custom proxy might, dropping fields it
3788    /// does not know about.
3789    struct RebuildingAuthProxy;
3790
3791    #[async_trait::async_trait]
3792    impl crate::stdlib::http::AuthProxy for RebuildingAuthProxy {
3793        async fn transform(
3794            &self,
3795            req: HttpRequest,
3796            _caller: &str,
3797        ) -> Result<HttpRequest, crate::stdlib::http::AuthProxyError> {
3798            Ok(HttpRequest {
3799                redirect_guard: None,
3800                ..req
3801            })
3802        }
3803    }
3804
3805    #[tokio::test]
3806    async fn an_auth_proxy_cannot_drop_the_redirect_guard() {
3807        let source = r#"
3808            import { get } from "submilli:http";
3809            function main(): void {
3810                get("https://example.test/start");
3811            }
3812        "#;
3813        let compiled = crate::compile_script(source, "test.subm", crate::FileId(0), &[], &[])
3814            .expect("compile clean");
3815        let cfg = RuntimeConfig::default();
3816        let engine = cfg.engine().expect("engine");
3817        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
3818        let client = Arc::new(RedirectingClient {
3819            hop_method: "GET",
3820            hop_url: "https://evil.test/collect",
3821            method_rewritten: false,
3822            sent_hop: Mutex::new(false),
3823        });
3824        data.http_client = client.clone();
3825        data.auth_proxy = Arc::new(RebuildingAuthProxy);
3826        data.security_check = Arc::new(DenyEvilHost::default());
3827        let mut store = cfg.store(&engine, data).expect("store");
3828        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
3829        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
3830        install_runtime_async(&mut linker, &mut store)
3831            .await
3832            .expect("install");
3833        let inst = linker
3834            .instantiate_async(&mut store, &module)
3835            .await
3836            .expect("instantiate");
3837        let error = dispatch_main_async(&mut store, &inst)
3838            .await
3839            .expect_err("the hop is denied");
3840        assert!(
3841            format!("{error:?}").contains("permission denied"),
3842            "{error:?}"
3843        );
3844        assert!(!*client.sent_hop.lock().unwrap());
3845    }
3846
3847    #[tokio::test]
3848    async fn fully_qualified_hosts_are_checked_without_their_trailing_dot() {
3849        let initial = r#"
3850            import { get } from "submilli:http";
3851            function main(): void {
3852                let caught = "";
3853                try {
3854                    get("https://evil.test./x");
3855                } catch (e: PermissionDeniedError) {
3856                    caught = e.reason;
3857                }
3858                assert(caught === "evil.test is not allowed", caught);
3859            }
3860        "#;
3861        let (result, sent, seen) =
3862            run_redirect(initial, "GET", "https://example.test/unused", false).await;
3863        result.expect("initial request denied");
3864        assert!(!sent);
3865        assert_eq!(seen[0].2["host"], "evil.test");
3866
3867        let hop = r#"
3868            import { get } from "submilli:http";
3869            function main(): void {
3870                get("https://example.test/start");
3871            }
3872        "#;
3873        // Two dots: every trailing dot is dropped, not just one.
3874        let (result, sent, seen) =
3875            run_redirect(hop, "GET", "https://evil.test../collect", false).await;
3876        assert!(
3877            result
3878                .expect_err("hop denied")
3879                .contains("permission denied")
3880        );
3881        assert!(!sent);
3882        assert_eq!(seen[1].2["host"], "evil.test");
3883    }
3884}