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