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