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