Skip to main content

submilli_engine/stdlib/http/
mod.rs

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