Skip to main content

submilli_engine/stdlib/http/
mod.rs

1//! `submilli:http` — agent-facing HTTP client library.
2//!
3//! Pure Rust host functions registered directly under the package name.
4//! `Response` and `DownloadResult` are host-built backing structs the guest
5//! holds opaquely and reads through the registered getters; `Headers` maps are
6//! real prelude `Map<string, string>`s built and consumed host-side. The
7//! embedder-facing transport traits live in [`transport`], the SSRF policy in
8//! [`policy`].
9
10use crate::runtime::effects;
11use crate::runtime::host::{
12    abi_arg, abi_result, read_boxed_number, read_string_arg, read_uint8_array_arg,
13    register_host_fn, register_host_value_async, write_submilli_string_struct,
14};
15pub(crate) mod declaration;
16pub mod policy;
17mod redirect_guard;
18pub mod transport;
19mod transport_policy;
20#[cfg(test)]
21mod transport_policy_tests;
22
23use wasmtime::{
24    Caller, FuncType, HeapType, Linker, RefType, Rooted, StructRef, StructType, Val, ValType,
25};
26
27use crate::runtime::StoreData;
28use crate::runtime::call_log::{Payload, Side, mask_headers, mask_url, record_payload};
29use crate::runtime::filesystem::{Download, DownloadOptions as FilesystemDownloadOptions};
30use crate::runtime::fuel;
31use crate::runtime::intrinsic_types::{IntrinsicTypes, build_intrinsic_types, intrinsic_types};
32use crate::runtime::metrics::{HttpMetric, MetricsSink};
33use crate::runtime::prelude::collection::{is_a, object_field, unbox_bool};
34use crate::runtime::prelude::map;
35use crate::runtime::prelude::vtable::dispatch_vtable_slot;
36use crate::stdlib::abi::{
37    self, HostView, backing_receiver, backing_struct, f64_field, i32_field, install_field_getters,
38    nullable_object_field, string_field,
39};
40use crate::stdlib::dot_segments::refuse_dot_segments;
41use crate::stdlib::fs::backend_error;
42use crate::stdlib::shared::{check_and_approve_call, require_writable};
43use redirect_guard::{
44    CapabilityGuard, DownloadTarget, GuardedRequest, RequestForm, host_and_path, request_check,
45};
46use transport::{DownloadMeta, DownloadProgress, http_failure_outcome};
47
48pub const MODULE_NAME: &str = "submilli:http";
49
50pub use declaration::package_declaration;
51pub use policy::NetworkPolicy;
52pub use transport::{
53    AuthProxy, AuthProxyError, EgressAt, HttpClient, HttpError, HttpRequest, HttpResponse,
54    NoopAuthProxy, RecordedRequest, RedirectDenied, RedirectGuard, RedirectHop, ReqwestHttpClient,
55    default_auth_proxy, default_http_client, describe_error_chain, is_policy_refusal,
56};
57pub use transport_policy::{HttpTransportPolicy, TransportPolicyError};
58
59/// Verb-form helpers' per-request timeout; `download` defaults to
60/// [`DOWNLOAD_TIMEOUT_MS`] instead (downloads are usually larger).
61const DEFAULT_TIMEOUT_MS: u64 = 30_000;
62const DOWNLOAD_TIMEOUT_MS: u64 = 60_000;
63
64/// `toJson` is slot 1 of the four-slot `$VTable`.
65const TO_JSON_SLOT: usize = 1;
66
67// `$ResponseBacking` field indices (0 is the vtable).
68const R_BODY: usize = 1;
69const R_HEADERS: usize = 2;
70const R_OK: usize = 3;
71const R_STATUS: usize = 4;
72const R_STATUS_TEXT: usize = 5;
73const R_URL: usize = 6;
74
75// `$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.clone()], [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    // `json<T>` is generic: after the receiver comes the `$types`
1087    // environment every generic call passes, holding the `$TypeDescriptor`
1088    // for `T`. The result travels in the erased `T` slot.
1089    let types = ValType::Ref(RefType::new(
1090        false,
1091        HeapType::ConcreteArray(intr.object_fields.clone()),
1092    ));
1093    let erased = ValType::Ref(RefType::new(
1094        true,
1095        HeapType::ConcreteStruct(intr.object.clone()),
1096    ));
1097    register_host_value_async(
1098        linker,
1099        MODULE_NAME,
1100        declaration::response_json(),
1101        FuncType::new(engine, [receiver, types], [erased]),
1102        |caller, params| {
1103            Box::pin(async move {
1104                let st = backing_receiver(caller, abi_arg(params, 0)?)?;
1105                let body_val = st.field(&mut *caller, R_BODY)?;
1106                let body = read_string_arg(caller, &body_val, "Response (body)")?;
1107                let descriptor = abi::read_type_argument(caller, abi_arg(params, 1)?, 0)?;
1108                let value = crate::runtime::json::parse_json_as_unknown(
1109                    caller,
1110                    &body,
1111                    "r.json(): cannot parse the body",
1112                )?;
1113                // The message names the type, never the body or a key in it:
1114                // the body is whatever the server sent.
1115                if descriptor.accepts(caller, value).await? {
1116                    return Ok(value);
1117                }
1118                Err(crate::runtime::host::type_error(format!(
1119                    "r.json(): the body does not match `{}`",
1120                    descriptor.name
1121                )))
1122            })
1123        },
1124    )?;
1125
1126    Ok(())
1127}
1128
1129fn read_response_status_line(
1130    caller: &mut Caller<'_, StoreData>,
1131    st: &Rooted<StructRef>,
1132) -> wasmtime::Result<(i64, String, String)> {
1133    let Val::F64(bits) = st.field(&mut *caller, R_STATUS)? else {
1134        wasmtime::bail!("Response: status is not a number");
1135    };
1136    let status = f64::from_bits(bits) as i64;
1137    let status_text_val = st.field(&mut *caller, R_STATUS_TEXT)?;
1138    let status_text = read_string_arg(caller, &status_text_val, "Response (statusText)")?;
1139    let url_val = st.field(&mut *caller, R_URL)?;
1140    let url = read_string_arg(caller, &url_val, "Response (url)")?;
1141    Ok((status, status_text, url))
1142}
1143
1144fn install_download_result_members(
1145    linker: &mut Linker<StoreData>,
1146    engine: &wasmtime::Engine,
1147    intr: &IntrinsicTypes,
1148    receiver: ValType,
1149) -> wasmtime::Result<()> {
1150    let string = ValType::Ref(RefType::new(
1151        false,
1152        HeapType::ConcreteStruct(intr.string.clone()),
1153    ));
1154    install_field_getters(linker, engine, &receiver, &DOWNLOAD_RESULT_VIEW)?;
1155
1156    let result_key = crate::mangle::package_symbol(MODULE_NAME, "DownloadResult");
1157    register_host_fn(
1158        linker,
1159        MODULE_NAME,
1160        crate::mangle::extend(&result_key, "toString"),
1161        FuncType::new(engine, [receiver], [string]),
1162        |caller, params, results| {
1163            let st = backing_receiver(caller, abi_arg(params, 0)?)?;
1164            let Val::F64(status_bits) = st.field(&mut *caller, D_STATUS)? else {
1165                wasmtime::bail!("DownloadResult: status is not a number");
1166            };
1167            let Val::F64(bytes_bits) = st.field(&mut *caller, D_BYTES_WRITTEN)? else {
1168                wasmtime::bail!("DownloadResult: bytesWritten is not a number");
1169            };
1170            let path_val = st.field(&mut *caller, D_PATH)?;
1171            let path = read_string_arg(caller, &path_val, "DownloadResult (path)")?;
1172            let status = f64::from_bits(status_bits) as i64;
1173            let bytes_written = f64::from_bits(bytes_bits) as i64;
1174            let text = format!("Download({status}, {bytes_written} bytes -> {path})");
1175            let out = write_submilli_string_struct(caller, &text)?;
1176            *abi_result(results, 0)? = Val::AnyRef(Some(out.to_anyref()));
1177            Ok(())
1178        },
1179    )?;
1180
1181    Ok(())
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186    use std::collections::VecDeque;
1187    use std::sync::{Arc, Mutex};
1188
1189    use crate::compile_script;
1190    use crate::runtime::security::{CheckOutcome, SecurityCheck};
1191    use crate::runtime::{
1192        RuntimeConfig, StoreData, Vfs, dispatch_main_async, install_runtime_async,
1193    };
1194
1195    use super::transport::{
1196        DownloadMeta, HttpClient, HttpError, HttpRequest, HttpResponse, detect_decompression,
1197        stream_to_writer,
1198    };
1199
1200    struct MockHttpClient {
1201        scripted: Mutex<VecDeque<HttpResponse>>,
1202        seen: Mutex<Vec<HttpRequest>>,
1203    }
1204
1205    impl MockHttpClient {
1206        fn new(scripted: Vec<HttpResponse>) -> Self {
1207            Self {
1208                scripted: Mutex::new(scripted.into()),
1209                seen: Mutex::new(Vec::new()),
1210            }
1211        }
1212    }
1213
1214    #[async_trait::async_trait]
1215    impl HttpClient for MockHttpClient {
1216        async fn send(&self, req: &HttpRequest) -> Result<HttpResponse, HttpError> {
1217            self.seen.lock().unwrap().push(req.clone());
1218            self.scripted
1219                .lock()
1220                .unwrap()
1221                .pop_front()
1222                .ok_or_else(|| HttpError::Other("mock: scripted queue empty".into()))
1223        }
1224
1225        // Uses `stream_to_writer` so `max_response_size` enforcement matches the production path.
1226        async fn download(
1227            &self,
1228            req: &HttpRequest,
1229            writer: &mut (dyn std::io::Write + Send),
1230        ) -> Result<DownloadMeta, HttpError> {
1231            self.seen.lock().unwrap().push(req.clone());
1232            let resp = self
1233                .scripted
1234                .lock()
1235                .unwrap()
1236                .pop_front()
1237                .ok_or_else(|| HttpError::Other("mock: scripted queue empty".into()))?;
1238            let kind = detect_decompression(&resp.headers, &req.url, req.decompress);
1239            let cursor = std::io::Cursor::new(resp.body);
1240            let bytes_written = stream_to_writer(cursor, writer, kind, req.max_response_size)?;
1241            Ok(DownloadMeta {
1242                status: resp.status,
1243                status_text: resp.status_text,
1244                headers: resp.headers,
1245                final_url: resp.final_url,
1246                bytes_written,
1247            })
1248        }
1249    }
1250
1251    struct DenyAllHttp;
1252    impl SecurityCheck for DenyAllHttp {
1253        fn check(
1254            &self,
1255            _caller: &str,
1256            capability: &str,
1257            _context: &serde_json::Value,
1258        ) -> CheckOutcome {
1259            if capability.starts_with("http.") {
1260                CheckOutcome::Deny {
1261                    rule: None,
1262                    reason: format!("denied {capability} in test"),
1263                }
1264            } else {
1265                CheckOutcome::Allow { rule: None }
1266            }
1267        }
1268    }
1269
1270    async fn run_with_mock(source: &str, scripted: Vec<HttpResponse>) -> Arc<MockHttpClient> {
1271        let compiled = crate::compile_script(source, "test.subm", crate::FileId(0), &[], &[])
1272            .expect("compile clean");
1273        let cfg = RuntimeConfig::default();
1274        let engine = cfg.engine().expect("engine");
1275        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1276        data.install_type_info(compiled.type_info.clone());
1277        let mock = Arc::new(MockHttpClient::new(scripted));
1278        data.http_client = mock.clone();
1279        let mut store = cfg.store(&engine, data).expect("store");
1280        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1281        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1282        install_runtime_async(&mut linker, &mut store)
1283            .await
1284            .expect("install");
1285        let inst = linker
1286            .instantiate_async(&mut store, &module)
1287            .await
1288            .expect("instantiate");
1289        dispatch_main_async(&mut store, &inst)
1290            .await
1291            .expect("main ran without trap");
1292        mock
1293    }
1294
1295    #[tokio::test]
1296    async fn response_marshalling_checks_fuel_and_uses_normal_error_handling() {
1297        let config = RuntimeConfig::default();
1298        let engine = config.engine().unwrap();
1299        let mut store = config
1300            .store_async(&engine, StoreData::with_vfs("test-run".into(), Vfs::none()))
1301            .unwrap();
1302        let mut linker = wasmtime::Linker::new(&engine);
1303        install_runtime_async(&mut linker, &mut store)
1304            .await
1305            .unwrap();
1306        let probe = crate::runtime::host::host_value_async(
1307            &mut store,
1308            wasmtime::FuncType::new(
1309                &engine,
1310                [wasmtime::ValType::I32, wasmtime::ValType::I32],
1311                [wasmtime::ValType::I32],
1312            ),
1313            |caller, params| {
1314                Box::new(async move {
1315                    let depleted = params[0].i32().unwrap() != 0;
1316                    let invalid = params[1].i32().unwrap() != 0;
1317                    if depleted {
1318                        caller.set_fuel(0)?;
1319                    }
1320                    let mut response = ok_response(200, "café");
1321                    response.headers = vec![
1322                        ("a".into(), "first".into()),
1323                        ("a".into(), "last".into()),
1324                        ("b".into(), "second".into()),
1325                    ];
1326                    if invalid {
1327                        response.body = vec![0xFF];
1328                    }
1329                    let response = super::settle_native_response(caller, Ok(response), "GET")?;
1330                    let response = super::write_response(caller, response)?;
1331                    let response = crate::runtime::prelude::iterator::as_struct(
1332                        caller, &response, "response",
1333                    )?;
1334                    let body = response.field(&mut *caller, 1)?;
1335                    assert_eq!(
1336                        super::read_string_arg(caller, &body, "response body")?,
1337                        "café"
1338                    );
1339                    let headers = response.field(&mut *caller, 2)?;
1340                    assert_eq!(
1341                        super::map::string_entries(caller, &headers)?,
1342                        vec![("a".into(), "last".into()), ("b".into(), "second".into())]
1343                    );
1344                    Ok(wasmtime::Val::I32(1))
1345                })
1346            },
1347        );
1348        for (depleted, invalid) in [(1, 0), (0, 1), (0, 0)] {
1349            store.set_fuel(1_000_000).unwrap();
1350            let result = probe
1351                .call_async(
1352                    &mut store,
1353                    &[wasmtime::Val::I32(depleted), wasmtime::Val::I32(invalid)],
1354                    &mut [wasmtime::Val::I32(0)],
1355                )
1356                .await;
1357            if depleted != 0 {
1358                assert_eq!(
1359                    result.unwrap_err().downcast_ref::<wasmtime::Trap>(),
1360                    Some(&wasmtime::Trap::OutOfFuel)
1361                );
1362            } else if invalid != 0 {
1363                let error = result.unwrap_err();
1364                assert!(error.is::<wasmtime::ThrownException>());
1365                let original = crate::runtime::exec::uncaught_error(&mut store, error);
1366                assert!(original.to_string().contains("response body is not UTF-8"));
1367            } else {
1368                result.unwrap();
1369            }
1370            assert!(!store.data().settling_host_result);
1371        }
1372    }
1373
1374    fn ok_response(status: u16, body: &str) -> HttpResponse {
1375        HttpResponse {
1376            status,
1377            status_text: "OK".to_string(),
1378            headers: vec![("content-type".to_string(), "text/plain".to_string())],
1379            body: body.as_bytes().to_vec(),
1380            final_url: "https://example.test/".to_string(),
1381        }
1382    }
1383
1384    #[tokio::test]
1385    async fn narrowed_host_carriers() {
1386        let source = r#"
1387import { get, download, Response, DownloadResult } from "submilli:http";
1388import { info } from "submilli:fs";
1389
1390class Parent { value: unknown = null; reset(value: unknown): void { this.value = value; } }
1391function rejects(read: () => void): void {
1392 let caught = false;
1393 try { read(); } catch (e) { caught = e instanceof TypeError; }
1394 assert(caught, "unrelated carrier must throw TypeError");
1395}
1396
1397class ResponseField extends Parent { value: Response | null = null; }
1398class DownloadField extends Parent { value: DownloadResult | null = null; }
1399function main(): void {
1400 const r = new ResponseField(); r.reset(get("https://example.test/"));
1401 assert(r.value!.status === 200, "Response");
1402 const d = new DownloadField(); d.reset(download("https://example.test/file", "/out.txt"));
1403 assert(d.value!.bytesWritten === 5, "DownloadResult");
1404 r.reset(d.value); rejects(() => { const v = r.value; });
1405 d.reset(info()); rejects(() => { const v = d.value; });
1406}
1407"#;
1408        let tmp = tempfile::tempdir().unwrap();
1409        let (_, result) = run_download_with_mock(
1410            source,
1411            vec![ok_response(200, "hello"), ok_response(200, "hello")],
1412            None,
1413            tmp.path(),
1414        )
1415        .await;
1416        result.expect("host guards");
1417    }
1418
1419    #[tokio::test]
1420    async fn responses_and_downloads_stringify_their_properties() {
1421        let source = r#"
1422import { get, download } from "submilli:http";
1423function main(): void {
1424 const r = JSON.stringify(get("https://example.test/"));
1425 assert(r === '{"body":"hello","headers":{"content-type":"text/plain"},"ok":true,"status":200,"statusText":"OK","url":"https://example.test/"}', r!);
1426 const d = JSON.parse(JSON.stringify(download("https://example.test/file", "/out.txt"))!) as { bytesWritten: number; path: string; status: number };
1427 assert(d.bytesWritten === 5 && d.path === "/out.txt" && d.status === 200, JSON.stringify(d)!);
1428}
1429"#;
1430        let tmp = tempfile::tempdir().unwrap();
1431        let (_, result) = run_download_with_mock(
1432            source,
1433            vec![ok_response(200, "hello"), ok_response(200, "hello")],
1434            None,
1435            tmp.path(),
1436        )
1437        .await;
1438        result.expect("host values stringify");
1439    }
1440
1441    #[tokio::test]
1442    async fn get_status_and_body_roundtrip() {
1443        let source = r#"
1444            import { get, Response } from "submilli:http";
1445            function main(): void {
1446                const r: Response = get("https://example.test/u");
1447                assert(r.status === 200, "status is 200");
1448                assert(r.body === "hello", "body decoded");
1449                assert(r.ok, "ok for 2xx");
1450            }
1451        "#;
1452        let mock = run_with_mock(source, vec![ok_response(200, "hello")]).await;
1453        let seen = mock.seen.lock().unwrap();
1454        assert_eq!(seen.len(), 1, "exactly one request");
1455        assert_eq!(seen[0].method, "GET");
1456        assert_eq!(seen[0].url, "https://example.test/u");
1457    }
1458
1459    /// Records the caller it's handed and injects a marker header, so a test can
1460    /// assert both the caller-threading and that injection reaches the wire.
1461    struct RecordingAuthProxy {
1462        callers: Mutex<Vec<String>>,
1463    }
1464    #[async_trait::async_trait]
1465    impl super::transport::AuthProxy for RecordingAuthProxy {
1466        async fn transform(
1467            &self,
1468            mut req: HttpRequest,
1469            caller: &str,
1470        ) -> Result<HttpRequest, super::transport::AuthProxyError> {
1471            self.callers.lock().unwrap().push(caller.to_string());
1472            req.headers
1473                .push(("x-injected".to_string(), "yes".to_string()));
1474            Ok(req)
1475        }
1476    }
1477
1478    /// Runs `source` as code owned by `owner` (`None` for `main`) and returns the callers the
1479    /// auth proxy was told, plus whether the injected header reached the wire.
1480    async fn auth_proxy_callers_for(owner: Option<&str>, source: &str) -> (Vec<String>, bool) {
1481        let compiled = match owner {
1482            Some(package) => crate::compile::compile_script_owned_by(
1483                package,
1484                source,
1485                "test.subm",
1486                crate::FileId(0),
1487                &[],
1488                &[],
1489            ),
1490            None => compile_script(source, "test.subm", crate::FileId(0), &[], &[]),
1491        }
1492        .expect("compile clean");
1493        let cfg = RuntimeConfig::default();
1494        let engine = cfg.engine().expect("engine");
1495        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1496        let mock = Arc::new(MockHttpClient::new(vec![ok_response(200, "hi")]));
1497        let proxy = Arc::new(RecordingAuthProxy {
1498            callers: Mutex::new(Vec::new()),
1499        });
1500        data.http_client = mock.clone();
1501        data.auth_proxy = proxy.clone();
1502        let mut store = cfg.store(&engine, data).expect("store");
1503        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1504        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1505        install_runtime_async(&mut linker, &mut store)
1506            .await
1507            .expect("install");
1508        let inst = linker
1509            .instantiate_async(&mut store, &module)
1510            .await
1511            .expect("instantiate");
1512        dispatch_main_async(&mut store, &inst)
1513            .await
1514            .expect("main ran without trap");
1515        let callers = proxy.callers.lock().unwrap().clone();
1516        let injected = mock.seen.lock().unwrap()[0]
1517            .headers
1518            .iter()
1519            .any(|(k, v)| k == "x-injected" && v == "yes");
1520        (callers, injected)
1521    }
1522
1523    const REQUESTS_A_URL: &str = r#"
1524        import { get, Response } from "submilli:http";
1525        function main(): number {
1526            const r: Response = get("https://example.test/u");
1527            return r.status;
1528        }
1529    "#;
1530
1531    /// Credential injection is scoped to `main` and withheld from libraries
1532    /// (`submilli-shared`'s `BlueprintAuthProxy` gates on `caller != MAIN_PACKAGE`), so the
1533    /// identity it selects on must be the running code's — not the top of a stack that only
1534    /// package *export wrappers* push to.
1535    ///
1536    /// This is package-owned code running with nothing pushed, the same condition as the
1537    /// exported class methods the report names: they carry no identity wrapper, so the stack
1538    /// still reads `main` while the package's own code runs. Attributed to `main`, that code
1539    /// would be handed the operator's credentials.
1540    #[tokio::test]
1541    async fn a_package_making_a_request_is_never_attributed_to_main() {
1542        let (callers, _injected) =
1543            auth_proxy_callers_for(Some("@pied-piper/sdk"), REQUESTS_A_URL).await;
1544        assert_eq!(
1545            callers,
1546            vec!["@pied-piper/sdk".to_string()],
1547            "package code must not borrow main's identity at the auth proxy",
1548        );
1549    }
1550
1551    /// The mirror of the above: over-correcting here would silently strip the operator's own
1552    /// credentials, which fails as an auth error far from its cause.
1553    #[tokio::test]
1554    async fn mains_own_request_still_gets_injection() {
1555        let (callers, injected) = auth_proxy_callers_for(None, REQUESTS_A_URL).await;
1556        assert_eq!(callers, vec!["main".to_string()]);
1557        assert!(injected, "main's own request must still be injected");
1558    }
1559
1560    /// R7, the inverse direction, asserted deliberately rather than discovered: when a package
1561    /// invokes `main`-authored code — here a `toJson` reached through the package's
1562    /// `JSON.stringify` — the innermost frame is `main`'s, so the request is `main`'s and is
1563    /// injected. That follows from reading identity off the running code, and it is safe:
1564    /// the code is `main`'s own, `main` chose to hand it over, and the package cannot read the
1565    /// injected header. What R3 forbids is the reverse, covered by the test above.
1566    #[tokio::test]
1567    async fn main_authored_code_invoked_by_a_package_is_still_main() {
1568        let (lib_bytes, lib_decl, lib_type_info) = crate::codegen::tests::compile_package_modules(
1569            "test:wrap",
1570            &[(
1571                "lib",
1572                r#"
1573                /**
1574                 * Pass-through JSON encoder.
1575                 * @param value Value to encode.
1576                 * @returns `value` as JSON.
1577                 */
1578                export function passthrough(value: unknown): string | undefined {
1579                    return JSON.stringify(value);
1580                }
1581                "#,
1582            )],
1583            &[],
1584        );
1585        let cfg = RuntimeConfig::default();
1586        let engine = cfg.engine().expect("engine");
1587        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1588        data.install_type_info(lib_type_info);
1589        let mock = Arc::new(MockHttpClient::new(vec![ok_response(200, "hi")]));
1590        let proxy = Arc::new(RecordingAuthProxy {
1591            callers: Mutex::new(Vec::new()),
1592        });
1593        data.http_client = mock.clone();
1594        data.auth_proxy = proxy.clone();
1595        let mut store = cfg.store(&engine, data).expect("store");
1596        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1597        install_runtime_async(&mut linker, &mut store)
1598            .await
1599            .expect("install");
1600        let lib_module = wasmtime::Module::new(&engine, &lib_bytes).expect("library module");
1601        let lib_inst = linker
1602            .instantiate_async(&mut store, &lib_module)
1603            .await
1604            .expect("instantiate library");
1605        linker
1606            .instance(&mut store, "test:wrap", lib_inst)
1607            .expect("register library instance");
1608        let public_name = crate::mangle::package_symbol("test:wrap", "passthrough");
1609        let func = lib_inst
1610            .get_func(&mut store, public_name.as_str())
1611            .expect("library public export");
1612        linker
1613            .define(&mut store, "test:wrap", "passthrough", func)
1614            .expect("plain package import alias");
1615
1616        let consumer = compile_script(
1617            r#"
1618            import { passthrough } from "test:wrap";
1619            import { get, Response } from "submilli:http";
1620
1621            class Pinger {
1622                hit: number;
1623                constructor() { this.hit = 0; }
1624                toJson(): string {
1625                    const r: Response = get("https://example.test/u");
1626                    this.hit = r.status;
1627                    return "\"ok\"";
1628                }
1629            }
1630
1631            function main(): number {
1632                const p = new Pinger();
1633                const _ = passthrough(p);
1634                return p.hit;
1635            }
1636            "#,
1637            "consumer.subm",
1638            crate::FileId(0),
1639            &[&lib_decl],
1640            &[],
1641        )
1642        .expect("consumer compiles");
1643        store
1644            .data_mut()
1645            .install_type_info(consumer.type_info.clone());
1646        let consumer_module = wasmtime::Module::new(&engine, &consumer.wasm).expect("module");
1647        let inst = linker
1648            .instantiate_async(&mut store, &consumer_module)
1649            .await
1650            .expect("instantiate consumer");
1651        dispatch_main_async(&mut store, &inst)
1652            .await
1653            .expect("main ran without trap");
1654
1655        assert_eq!(
1656            proxy.callers.lock().unwrap().as_slice(),
1657            &["main".to_string()],
1658            "main-authored code stays main's wherever a package invokes it",
1659        );
1660    }
1661
1662    #[tokio::test]
1663    async fn auth_proxy_sees_main_caller_and_injects_to_wire() {
1664        let source = r#"
1665            import { get, Response } from "submilli:http";
1666            function main(): number {
1667                const r: Response = get("https://example.test/u");
1668                return r.status;
1669            }
1670        "#;
1671        let compiled =
1672            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
1673        let cfg = RuntimeConfig::default();
1674        let engine = cfg.engine().expect("engine");
1675        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1676        let mock = Arc::new(MockHttpClient::new(vec![ok_response(200, "hi")]));
1677        let proxy = Arc::new(RecordingAuthProxy {
1678            callers: Mutex::new(Vec::new()),
1679        });
1680        data.http_client = mock.clone();
1681        data.auth_proxy = proxy.clone();
1682        let mut store = cfg.store(&engine, data).expect("store");
1683        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1684        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1685        install_runtime_async(&mut linker, &mut store)
1686            .await
1687            .expect("install");
1688        let inst = linker
1689            .instantiate_async(&mut store, &module)
1690            .await
1691            .expect("instantiate");
1692        dispatch_main_async(&mut store, &inst)
1693            .await
1694            .expect("main ran without trap");
1695
1696        assert_eq!(
1697            proxy.callers.lock().unwrap().as_slice(),
1698            &["main".to_string()]
1699        );
1700        let seen = mock.seen.lock().unwrap();
1701        assert!(
1702            seen[0]
1703                .headers
1704                .iter()
1705                .any(|(k, v)| k == "x-injected" && v == "yes"),
1706            "injected header reached the outbound request"
1707        );
1708    }
1709
1710    #[tokio::test]
1711    async fn response_ok_false_for_non_2xx() {
1712        let source = r#"
1713            import { get, Response } from "submilli:http";
1714            function main(): void {
1715                const r: Response = get("https://example.test/x");
1716                assert(!r.ok, "300 is not ok");
1717                assert(r.status === 300, "status preserved");
1718            }
1719        "#;
1720        run_with_mock(source, vec![ok_response(300, "")]).await;
1721    }
1722
1723    #[tokio::test]
1724    async fn deny_policy_blocks_http_get() {
1725        let source = r#"
1726            import { get } from "submilli:http";
1727            function main(): void {
1728                get("https://example.test/y");
1729            }
1730        "#;
1731        let compiled =
1732            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
1733        let cfg = RuntimeConfig::default();
1734        let engine = cfg.engine().expect("engine");
1735        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1736        data.security_check = Arc::new(DenyAllHttp);
1737        // Mock client never invoked — the security check denies first.
1738        data.http_client = Arc::new(MockHttpClient::new(vec![ok_response(200, "")]));
1739        let mut store = cfg.store(&engine, data).expect("store");
1740        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1741        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1742        install_runtime_async(&mut linker, &mut store)
1743            .await
1744            .expect("install");
1745        let inst = linker
1746            .instantiate_async(&mut store, &module)
1747            .await
1748            .expect("instantiate");
1749        let err = dispatch_main_async(&mut store, &inst)
1750            .await
1751            .expect_err("must trap on deny");
1752        let msg = format!("{err:?}");
1753        assert!(
1754            msg.contains("permission denied"),
1755            "expected deny trap; got: {msg}"
1756        );
1757        assert!(
1758            msg.contains("http.get"),
1759            "expected http.get capability in trap; got: {msg}"
1760        );
1761        assert!(
1762            msg.contains("caller=main"),
1763            "expected caller=main in trap; got: {msg}"
1764        );
1765    }
1766
1767    #[tokio::test]
1768    async fn verb_matrix_method_passthrough() {
1769        let source = r#"
1770            import { post, put, patch, delete, head, options, Response } from "submilli:http";
1771            function main(): void {
1772                const a: Response = post("https://example.test/a");
1773                const b: Response = put("https://example.test/b");
1774                const c: Response = patch("https://example.test/c");
1775                const d: Response = delete("https://example.test/d");
1776                const e: Response = head("https://example.test/e");
1777                const f: Response = options("https://example.test/f");
1778                assert(a.status === 200, "post status");
1779                assert(b.status === 200, "put status");
1780                assert(c.status === 200, "patch status");
1781                assert(d.status === 200, "delete status");
1782                assert(e.status === 200, "head status");
1783                assert(f.status === 200, "options status");
1784            }
1785        "#;
1786        let mock = run_with_mock(
1787            source,
1788            vec![
1789                ok_response(200, ""),
1790                ok_response(200, ""),
1791                ok_response(200, ""),
1792                ok_response(200, ""),
1793                ok_response(200, ""),
1794                ok_response(200, ""),
1795            ],
1796        )
1797        .await;
1798        let seen = mock.seen.lock().unwrap();
1799        assert_eq!(seen.len(), 6);
1800        assert_eq!(seen[0].method, "POST");
1801        assert_eq!(seen[1].method, "PUT");
1802        assert_eq!(seen[2].method, "PATCH");
1803        assert_eq!(seen[3].method, "DELETE");
1804        assert_eq!(seen[4].method, "HEAD");
1805        assert_eq!(seen[5].method, "OPTIONS");
1806    }
1807
1808    struct VerbContextCheck {
1809        capability: String,
1810        method: Option<String>,
1811    }
1812
1813    impl SecurityCheck for VerbContextCheck {
1814        fn check(
1815            &self,
1816            caller: &str,
1817            capability: &str,
1818            context: &serde_json::Value,
1819        ) -> CheckOutcome {
1820            assert_eq!(caller, "main");
1821            assert_eq!(capability, self.capability);
1822            let mut expected = serde_json::json!({
1823                "host": "example.test",
1824                "path": "/resource",
1825                "body_size": 0,
1826                "timeout_ms": super::DEFAULT_TIMEOUT_MS,
1827            });
1828            if let Some(method) = &self.method {
1829                expected["method"] = method.as_str().into();
1830            }
1831            assert_eq!(context, &expected);
1832            CheckOutcome::Allow { rule: None }
1833        }
1834    }
1835
1836    #[tokio::test]
1837    async fn verb_functions_check_their_verb_and_request_checks_its_method() {
1838        let tmp = tempfile::tempdir().expect("tempdir");
1839        for verb in ["get", "post", "put", "patch", "delete", "head", "options"] {
1840            let upper = verb.to_ascii_uppercase();
1841            for (call, check) in [
1842                (
1843                    format!("{verb}(\"https://example.test/resource\")"),
1844                    VerbContextCheck {
1845                        capability: format!("http.{verb}"),
1846                        method: None,
1847                    },
1848                ),
1849                (
1850                    format!("request(\"{verb}\", \"https://example.test/resource\")"),
1851                    VerbContextCheck {
1852                        capability: "http.request".to_string(),
1853                        method: Some(upper.clone()),
1854                    },
1855                ),
1856            ] {
1857                let source = format!(
1858                    "import {{ {verb}, request }} from \"submilli:http\";\n\
1859                     function main(): void {{ {call}; }}"
1860                );
1861                let (mock, result) = run_download_with_mock(
1862                    &source,
1863                    vec![ok_response(200, "")],
1864                    Some(Arc::new(check)),
1865                    tmp.path(),
1866                )
1867                .await;
1868                result.expect("request allowed");
1869                let seen = mock.seen.lock().unwrap();
1870                assert_eq!(seen.len(), 1);
1871                assert_eq!(seen[0].method, upper);
1872            }
1873        }
1874    }
1875
1876    #[tokio::test]
1877    async fn generic_request_checks_its_capability_before_transport() {
1878        let tmp = tempfile::tempdir().expect("tempdir");
1879        let source = r#"
1880            import { request } from "submilli:http";
1881            function main(): void {
1882                request("pOsT", "https://example.test/resource");
1883            }
1884        "#;
1885        let (mock, result) =
1886            run_download_with_mock(source, vec![], Some(Arc::new(DenyAllHttp)), tmp.path()).await;
1887        let error = result.expect_err("request denied");
1888        assert!(error.contains("http.request"), "{error}");
1889        assert!(mock.seen.lock().unwrap().is_empty());
1890    }
1891
1892    struct RecordingCheck {
1893        seen: Mutex<Vec<(String, String)>>,
1894    }
1895    impl SecurityCheck for RecordingCheck {
1896        fn check(
1897            &self,
1898            caller: &str,
1899            capability: &str,
1900            _context: &serde_json::Value,
1901        ) -> CheckOutcome {
1902            self.seen
1903                .lock()
1904                .unwrap()
1905                .push((caller.to_string(), capability.to_string()));
1906            CheckOutcome::Allow { rule: None }
1907        }
1908    }
1909
1910    #[tokio::test]
1911    async fn a_script_is_attributed_to_main() {
1912        let source = r#"
1913            import { get, Response } from "submilli:http";
1914            function main(): void {
1915                const _r: Response = get("https://example.test/c");
1916            }
1917        "#;
1918        let recording = Arc::new(RecordingCheck {
1919            seen: Mutex::new(Vec::new()),
1920        });
1921        let compiled = crate::compile_script(source, "test.subm", crate::FileId(0), &[], &[])
1922            .expect("compile clean");
1923        let cfg = RuntimeConfig::default();
1924        let engine = cfg.engine().expect("engine");
1925        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1926        data.security_check = recording.clone();
1927        data.http_client = Arc::new(MockHttpClient::new(vec![ok_response(200, "")]));
1928        let mut store = cfg.store(&engine, data).expect("store");
1929        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1930        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1931        install_runtime_async(&mut linker, &mut store)
1932            .await
1933            .expect("install");
1934        let inst = linker
1935            .instantiate_async(&mut store, &module)
1936            .await
1937            .expect("instantiate");
1938        dispatch_main_async(&mut store, &inst)
1939            .await
1940            .expect("main ran");
1941
1942        let seen = recording.seen.lock().unwrap().clone();
1943        assert!(
1944            !seen.is_empty(),
1945            "RecordingCheck should have captured at least one http.* call"
1946        );
1947        for (caller, capability) in &seen {
1948            assert_eq!(
1949                caller, "main",
1950                "expected caller=main for capability {capability}; got {caller}"
1951            );
1952        }
1953    }
1954
1955    #[tokio::test]
1956    async fn a_package_is_attributed_to_the_package() {
1957        let source = r#"
1958            import { get, Response } from "submilli:http";
1959            function main(): void {
1960                const _r: Response = get("https://example.test/c2");
1961            }
1962        "#;
1963        let recording = Arc::new(RecordingCheck {
1964            seen: Mutex::new(Vec::new()),
1965        });
1966        // Compiled under the package name so the identity rides the wasm frame the runtime
1967        // reads, rather than being declared out-of-band.
1968        let compiled = crate::compile::compile_script_owned_by(
1969            "submilli:foo",
1970            source,
1971            "test.subm",
1972            crate::FileId(0),
1973            &[],
1974            &[],
1975        )
1976        .expect("compile clean");
1977        let cfg = RuntimeConfig::default();
1978        let engine = cfg.engine().expect("engine");
1979        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1980        data.security_check = recording.clone();
1981        data.http_client = Arc::new(MockHttpClient::new(vec![ok_response(200, "")]));
1982        let mut store = cfg.store(&engine, data).expect("store");
1983        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1984        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1985        install_runtime_async(&mut linker, &mut store)
1986            .await
1987            .expect("install");
1988        let inst = linker
1989            .instantiate_async(&mut store, &module)
1990            .await
1991            .expect("instantiate");
1992        dispatch_main_async(&mut store, &inst)
1993            .await
1994            .expect("main ran");
1995
1996        let seen = recording.seen.lock().unwrap().clone();
1997        assert!(
1998            !seen.is_empty(),
1999            "RecordingCheck should have captured calls"
2000        );
2001        for (caller, capability) in &seen {
2002            assert_eq!(
2003                caller, "submilli:foo",
2004                "expected caller=submilli:foo for capability {capability}; got {caller}"
2005            );
2006        }
2007    }
2008
2009    #[tokio::test]
2010    async fn headers_roundtrip_to_client() {
2011        let source = r#"
2012            import { get, Response } from "submilli:http";
2013            function main(): void {
2014                const h = new Map<string, string>();
2015                h.set("Authorization", "Bearer xyz");
2016                h.set("X-Foo", "bar");
2017                const r: Response = get("https://example.test/h", h);
2018                assert(r.status === 200, "status");
2019            }
2020        "#;
2021        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2022        let seen = mock.seen.lock().unwrap();
2023        assert_eq!(seen.len(), 1);
2024        // Map iteration is insertion-ordered so the
2025        // emitted header sequence is stable; membership check is
2026        // still sufficient for this regression.
2027        let names: Vec<&str> = seen[0].headers.iter().map(|(n, _)| n.as_str()).collect();
2028        assert!(names.contains(&"Authorization"), "auth header sent");
2029        assert!(names.contains(&"X-Foo"), "x-foo header sent");
2030        let auth_value = seen[0]
2031            .headers
2032            .iter()
2033            .find(|(n, _)| n == "Authorization")
2034            .map_or("", |(_, v)| v.as_str());
2035        assert_eq!(auth_value, "Bearer xyz");
2036    }
2037
2038    #[tokio::test]
2039    async fn headers_default_null_sends_no_headers() {
2040        let source = r#"
2041            import { get, Response } from "submilli:http";
2042            function main(): void {
2043                const r: Response = get("https://example.test/n");
2044                assert(r.status === 200, "status");
2045            }
2046        "#;
2047        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2048        let seen = mock.seen.lock().unwrap();
2049        assert_eq!(seen.len(), 1);
2050        assert!(seen[0].headers.is_empty(), "no headers when omitted");
2051    }
2052
2053    #[tokio::test]
2054    async fn response_to_string_format() {
2055        let source = r#"
2056            import { get, Response } from "submilli:http";
2057            function main(): void {
2058                const r: Response = get("https://example.test/t");
2059                const s: string = r.toString();
2060                assert(s === "Response(200 OK, https://example.test/)", s);
2061            }
2062        "#;
2063        run_with_mock(source, vec![ok_response(200, "")]).await;
2064    }
2065
2066    #[tokio::test]
2067    async fn throw_for_status_no_op_when_ok() {
2068        let source = r#"
2069            import { get, Response } from "submilli:http";
2070            function main(): void {
2071                const r: Response = get("https://example.test/ok");
2072                r.throwForStatus();
2073                assert(r.ok, "still alive");
2074            }
2075        "#;
2076        run_with_mock(source, vec![ok_response(200, "")]).await;
2077    }
2078
2079    #[tokio::test]
2080    async fn throw_for_status_traps_on_4xx() {
2081        let source = r#"
2082            import { get, Response } from "submilli:http";
2083            function main(): void {
2084                const r: Response = get("https://example.test/bad");
2085                r.throwForStatus();
2086            }
2087        "#;
2088        let compiled = crate::compile_script(source, "test.subm", crate::FileId(0), &[], &[])
2089            .expect("compile clean");
2090        let cfg = RuntimeConfig::default();
2091        let engine = cfg.engine().expect("engine");
2092        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
2093        let response_404 = HttpResponse {
2094            status: 404,
2095            status_text: "Not Found".to_string(),
2096            headers: vec![],
2097            body: vec![],
2098            final_url: "https://example.test/bad".to_string(),
2099        };
2100        data.http_client = Arc::new(MockHttpClient::new(vec![response_404]));
2101        let mut store = cfg.store(&engine, data).expect("store");
2102        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2103        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2104        install_runtime_async(&mut linker, &mut store)
2105            .await
2106            .expect("install");
2107        let inst = linker
2108            .instantiate_async(&mut store, &module)
2109            .await
2110            .expect("instantiate");
2111        let err = dispatch_main_async(&mut store, &inst)
2112            .await
2113            .expect_err("must trap on throwForStatus");
2114        let msg = format!("{err:?}");
2115        assert!(
2116            msg.contains("404") && msg.contains("Not Found"),
2117            "expected formatted HTTP error in trap; got: {msg}"
2118        );
2119    }
2120
2121    #[tokio::test]
2122    async fn request_runtime_verb_passthrough() {
2123        let source = r#"
2124            import { request, Response } from "submilli:http";
2125            function main(): void {
2126                const r: Response = request("post", "https://example.test/q");
2127                assert(r.status === 201, "status 201");
2128            }
2129        "#;
2130        let mock = run_with_mock(source, vec![ok_response(201, "")]).await;
2131        let seen = mock.seen.lock().unwrap();
2132        assert_eq!(seen.len(), 1);
2133        // Host fn upper-cases the method before storing it on the
2134        // `HttpRequest`.
2135        assert_eq!(seen[0].method, "POST");
2136    }
2137
2138    #[tokio::test]
2139    async fn internal_http_setup_failure_bypasses_catch_and_cleans_download() {
2140        struct BrokenSetup;
2141        #[async_trait::async_trait]
2142        impl HttpClient for BrokenSetup {
2143            async fn send(&self, _: &HttpRequest) -> Result<HttpResponse, HttpError> {
2144                Err(HttpError::Internal("injected setup failure".into()))
2145            }
2146            async fn download(
2147                &self,
2148                _: &HttpRequest,
2149                _: &mut (dyn std::io::Write + Send),
2150            ) -> Result<DownloadMeta, HttpError> {
2151                Err(HttpError::Internal("injected setup failure".into()))
2152            }
2153        }
2154        for operation in [
2155            "get(\"https://example.com/\");",
2156            "download(\"https://example.com/\", \"/payload\");",
2157        ] {
2158            let source = format!(
2159                r#"
2160                import {{ get, download }} from "submilli:http";
2161                function main(): void {{
2162                    try {{ {operation} }} catch (error) {{ return; }}
2163                }}
2164            "#
2165            );
2166            let compiled = compile_script(&source, "test.ts", crate::FileId(0), &[], &[]).unwrap();
2167            let cfg = RuntimeConfig::default();
2168            let engine = cfg.engine().unwrap();
2169            let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().unwrap());
2170            data.http_client = Arc::new(BrokenSetup);
2171            let mut store = cfg.store(&engine, data).unwrap();
2172            let module = wasmtime::Module::new(&engine, &compiled.wasm).unwrap();
2173            let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2174            install_runtime_async(&mut linker, &mut store)
2175                .await
2176                .unwrap();
2177            let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
2178            let error = dispatch_main_async(&mut store, &instance)
2179                .await
2180                .unwrap_err();
2181            assert!(
2182                format!("{error:?}").contains("injected setup failure"),
2183                "{error:?}"
2184            );
2185            assert_eq!(
2186                store.data().vfs.dir().unwrap().entries().unwrap().count(),
2187                0
2188            );
2189        }
2190    }
2191
2192    #[tokio::test]
2193    async fn download_into_a_read_only_mount_is_refused_before_any_request() {
2194        struct CountingClient(std::sync::atomic::AtomicUsize);
2195        #[async_trait::async_trait]
2196        impl HttpClient for CountingClient {
2197            async fn send(&self, _: &HttpRequest) -> Result<HttpResponse, HttpError> {
2198                self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2199                Err(HttpError::Network("unexpected".into()))
2200            }
2201            async fn download(
2202                &self,
2203                _: &HttpRequest,
2204                _: &mut (dyn std::io::Write + Send),
2205            ) -> Result<DownloadMeta, HttpError> {
2206                self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2207                Err(HttpError::Network("unexpected".into()))
2208            }
2209        }
2210        let source = r#"
2211            import { download } from "submilli:http";
2212            function main(): string {
2213                let mountPoint = "allowed";
2214                try { download("https://example.com/", "/rw", { overwrite: true }); }
2215                catch (e) { mountPoint = String(e).includes("mount point") ? "refused" : String(e); }
2216                try { download("https://example.com/", "/ro/payload"); }
2217                catch (e: PermissionDeniedError) { return e.capability + "|" + mountPoint; }
2218                return "allowed";
2219            }
2220        "#;
2221        let volume = tempfile::tempdir().unwrap();
2222        let writable = tempfile::tempdir().unwrap();
2223        let vfs = Vfs::tempdir()
2224            .unwrap()
2225            .with_mount(crate::runtime::vfs::MountSpec {
2226                guest_path: "/ro".into(),
2227                host: volume.path().to_path_buf(),
2228                volume: "ro".into(),
2229                access: crate::runtime::vfs::Access::ReadOnly,
2230                quota: None,
2231            })
2232            .unwrap()
2233            .with_mount(crate::runtime::vfs::MountSpec {
2234                guest_path: "/rw".into(),
2235                host: writable.path().to_path_buf(),
2236                volume: "rw".into(),
2237                access: crate::runtime::vfs::Access::ReadWrite,
2238                quota: None,
2239            })
2240            .unwrap();
2241        let compiled = compile_script(source, "test.ts", crate::FileId(0), &[], &[]).unwrap();
2242        let cfg = RuntimeConfig::default();
2243        let engine = cfg.engine().unwrap();
2244        let client = Arc::new(CountingClient(std::sync::atomic::AtomicUsize::new(0)));
2245        let mut data = StoreData::with_vfs("test-run".into(), vfs);
2246        data.http_client = client.clone();
2247        let mut store = cfg.store(&engine, data).unwrap();
2248        let module = wasmtime::Module::new(&engine, &compiled.wasm).unwrap();
2249        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2250        install_runtime_async(&mut linker, &mut store)
2251            .await
2252            .unwrap();
2253        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
2254        let value = dispatch_main_async(&mut store, &instance).await.unwrap();
2255        assert!(
2256            format!("{value:?}").contains("fs.write|refused"),
2257            "{value:?}"
2258        );
2259        assert_eq!(client.0.load(std::sync::atomic::Ordering::SeqCst), 0);
2260        assert_eq!(std::fs::read_dir(volume.path()).unwrap().count(), 0);
2261        assert_eq!(std::fs::read_dir(writable.path()).unwrap().count(), 0);
2262    }
2263
2264    #[tokio::test]
2265    async fn network_error_traps_with_message() {
2266        struct FailingClient;
2267        #[async_trait::async_trait]
2268        impl HttpClient for FailingClient {
2269            async fn send(&self, _req: &HttpRequest) -> Result<HttpResponse, HttpError> {
2270                Err(HttpError::Network("dns: no such host".into()))
2271            }
2272            async fn download(
2273                &self,
2274                _req: &HttpRequest,
2275                _writer: &mut (dyn std::io::Write + Send),
2276            ) -> Result<DownloadMeta, HttpError> {
2277                Err(HttpError::Network("dns: no such host".into()))
2278            }
2279        }
2280        let source = r#"
2281            import { get } from "submilli:http";
2282            function main(): void {
2283                get("https://example.test/z");
2284            }
2285        "#;
2286        let compiled =
2287            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
2288        let cfg = RuntimeConfig::default();
2289        let engine = cfg.engine().expect("engine");
2290        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
2291        data.http_client = Arc::new(FailingClient);
2292        let mut store = cfg.store(&engine, data).expect("store");
2293        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2294        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2295        install_runtime_async(&mut linker, &mut store)
2296            .await
2297            .expect("install");
2298        let inst = linker
2299            .instantiate_async(&mut store, &module)
2300            .await
2301            .expect("instantiate");
2302        let err = dispatch_main_async(&mut store, &inst)
2303            .await
2304            .expect_err("must trap on network error");
2305        let msg = format!("{err:?}");
2306        assert!(
2307            msg.contains("dns: no such host"),
2308            "expected network error message in trap; got: {msg}"
2309        );
2310    }
2311
2312    #[tokio::test]
2313    async fn post_with_string_body_defaults_ct() {
2314        let source = r#"
2315            import { post, Response } from "submilli:http";
2316            function main(): void {
2317                const r: Response = post("https://example.test/p", "hello");
2318                assert(r.status === 200, "status round-tripped");
2319            }
2320        "#;
2321        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2322        let seen = mock.seen.lock().unwrap();
2323        assert_eq!(seen.len(), 1);
2324        assert_eq!(seen[0].method, "POST");
2325        assert_eq!(seen[0].body, b"hello");
2326        let ct = seen[0]
2327            .headers
2328            .iter()
2329            .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2330            .map(|(_, v)| v.as_str());
2331        assert_eq!(
2332            ct,
2333            Some("text/plain; charset=utf-8"),
2334            "string body must default Content-Type for string body"
2335        );
2336    }
2337
2338    #[tokio::test]
2339    async fn post_with_uint8_body_no_ct_default() {
2340        let source = r#"
2341            import { post, Response } from "submilli:http";
2342            function main(): void {
2343                const r: Response = post(
2344                    "https://example.test/p",
2345                    new Uint8Array([1, 2, 3])
2346                );
2347                assert(r.status === 200, "status round-tripped");
2348            }
2349        "#;
2350        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2351        let seen = mock.seen.lock().unwrap();
2352        assert_eq!(seen.len(), 1);
2353        assert_eq!(seen[0].body, vec![1u8, 2, 3]);
2354        let ct_present = seen[0]
2355            .headers
2356            .iter()
2357            .any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
2358        assert!(
2359            !ct_present,
2360            "binary body must NOT default Content-Type for Uint8Array body; saw headers={:?}",
2361            seen[0].headers
2362        );
2363    }
2364
2365    #[tokio::test]
2366    async fn post_user_ct_wins_over_default() {
2367        let source = r#"
2368            import { post, Response, Headers } from "submilli:http";
2369            function main(): void {
2370                const h: Headers = new Map<string, string>();
2371                h.set("Content-Type", "application/json");
2372                const r: Response = post(
2373                    "https://example.test/p",
2374                    "{\"k\":1}",
2375                    h
2376                );
2377                assert(r.status === 200, "status round-tripped");
2378            }
2379        "#;
2380        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2381        let seen = mock.seen.lock().unwrap();
2382        assert_eq!(seen.len(), 1);
2383        assert_eq!(seen[0].body, br#"{"k":1}"#);
2384        let cts: Vec<&str> = seen[0]
2385            .headers
2386            .iter()
2387            .filter(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2388            .map(|(_, v)| v.as_str())
2389            .collect();
2390        assert_eq!(
2391            cts,
2392            vec!["application/json"],
2393            "user Content-Type must win and not be duplicated"
2394        );
2395    }
2396
2397    #[tokio::test]
2398    async fn null_body_is_json_and_undefined_body_is_empty() {
2399        let source = r#"
2400            import { post, request } from "submilli:http";
2401            function main(): void {
2402                post("https://example.test/p", null);
2403                request("POST", "https://example.test/p", null);
2404                post("https://example.test/p", undefined);
2405            }
2406        "#;
2407        let mock = run_with_mock(source, vec![ok_response(200, ""); 3]).await;
2408        let seen = mock.seen.lock().unwrap();
2409        assert_eq!(seen.len(), 3);
2410        for req in seen.iter().take(2) {
2411            assert_eq!(req.body, b"null");
2412            let content_types: Vec<&str> = req
2413                .headers
2414                .iter()
2415                .filter(|(name, _)| name.eq_ignore_ascii_case("content-type"))
2416                .map(|(_, value)| value.as_str())
2417                .collect();
2418            assert_eq!(content_types, vec!["application/json"]);
2419        }
2420        let absent = &seen[2];
2421        assert!(absent.body.is_empty());
2422        assert!(
2423            !absent
2424                .headers
2425                .iter()
2426                .any(|(name, _)| { name.eq_ignore_ascii_case("content-type") })
2427        );
2428    }
2429
2430    #[tokio::test]
2431    async fn post_undefined_body_sends_empty() {
2432        let source = r#"
2433            import { post, Response } from "submilli:http";
2434            function main(): void {
2435                const r: Response = post("https://example.test/p", undefined);
2436                assert(r.status === 200, "status round-tripped");
2437                const r2: Response = post("https://example.test/p");
2438                assert(r2.status === 200, "omitted body round-tripped");
2439            }
2440        "#;
2441        let mock = run_with_mock(source, vec![ok_response(200, ""), ok_response(200, "")]).await;
2442        let seen = mock.seen.lock().unwrap();
2443        assert_eq!(seen.len(), 2);
2444        for req in seen.iter() {
2445            assert!(
2446                req.body.is_empty(),
2447                "undefined body must wire as zero bytes"
2448            );
2449            let ct_present = req
2450                .headers
2451                .iter()
2452                .any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
2453            assert!(
2454                !ct_present,
2455                "undefined body must not trigger CT default; saw headers={:?}",
2456                req.headers
2457            );
2458        }
2459    }
2460
2461    #[tokio::test]
2462    async fn post_lowercase_user_ct_blocks_default() {
2463        let source = r#"
2464            import { post, Response, Headers } from "submilli:http";
2465            function main(): void {
2466                const h: Headers = new Map<string, string>();
2467                h.set("content-type", "application/xml");
2468                const r: Response = post("https://example.test/p", "<x/>", h);
2469                assert(r.status === 200, "status round-tripped");
2470            }
2471        "#;
2472        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2473        let seen = mock.seen.lock().unwrap();
2474        assert_eq!(seen.len(), 1);
2475        let cts: Vec<&str> = seen[0]
2476            .headers
2477            .iter()
2478            .filter(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2479            .map(|(_, v)| v.as_str())
2480            .collect();
2481        assert_eq!(cts, vec!["application/xml"]);
2482    }
2483
2484    #[tokio::test]
2485    async fn post_object_body_json_ct() {
2486        let source = r#"
2487            import { post, Response } from "submilli:http";
2488            function main(): void {
2489                const r: Response = post("https://example.test/p", { name: "alice" });
2490                assert(r.status === 200, "status round-tripped");
2491            }
2492        "#;
2493        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2494        let seen = mock.seen.lock().unwrap();
2495        assert_eq!(seen.len(), 1);
2496        assert_eq!(seen[0].body, br#"{"name":"alice"}"#);
2497        let ct = seen[0]
2498            .headers
2499            .iter()
2500            .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2501            .map(|(_, v)| v.as_str());
2502        assert_eq!(
2503            ct,
2504            Some("application/json"),
2505            "object body must default Content-Type to application/json"
2506        );
2507    }
2508
2509    #[tokio::test]
2510    async fn post_array_body_json_ct() {
2511        let source = r#"
2512            import { post, Response } from "submilli:http";
2513            function main(): void {
2514                const r: Response = post("https://example.test/p", [1, 2, 3]);
2515                assert(r.status === 200, "status round-tripped");
2516            }
2517        "#;
2518        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2519        let seen = mock.seen.lock().unwrap();
2520        assert_eq!(seen.len(), 1);
2521        assert_eq!(seen[0].body, b"[1,2,3]");
2522        let ct = seen[0]
2523            .headers
2524            .iter()
2525            .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2526            .map(|(_, v)| v.as_str());
2527        assert_eq!(
2528            ct,
2529            Some("application/json"),
2530            "array body must default Content-Type to application/json"
2531        );
2532    }
2533
2534    #[tokio::test]
2535    async fn post_object_body_user_ct_wins() {
2536        let source = r#"
2537            import { post, Response, Headers } from "submilli:http";
2538            function main(): void {
2539                const h: Headers = new Map<string, string>();
2540                h.set("Content-Type", "application/vnd.custom");
2541                const r: Response = post("https://example.test/p", { name: "alice" }, h);
2542                assert(r.status === 200, "status round-tripped");
2543            }
2544        "#;
2545        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2546        let seen = mock.seen.lock().unwrap();
2547        assert_eq!(seen.len(), 1);
2548        assert_eq!(seen[0].body, br#"{"name":"alice"}"#);
2549        let cts: Vec<&str> = seen[0]
2550            .headers
2551            .iter()
2552            .filter(|(k, _)| k.eq_ignore_ascii_case("content-type"))
2553            .map(|(_, v)| v.as_str())
2554            .collect();
2555        assert_eq!(
2556            cts,
2557            vec!["application/vnd.custom"],
2558            "user Content-Type must win over the application/json default"
2559        );
2560    }
2561
2562    #[tokio::test]
2563    async fn request_form_with_body() {
2564        let source = r#"
2565            import { request, Response } from "submilli:http";
2566            function main(): void {
2567                const r: Response = request("POST", "https://example.test/p", "abc");
2568                assert(r.status === 200, "status round-tripped");
2569            }
2570        "#;
2571        let mock = run_with_mock(source, vec![ok_response(200, "")]).await;
2572        let seen = mock.seen.lock().unwrap();
2573        assert_eq!(seen.len(), 1);
2574        assert_eq!(seen[0].method, "POST");
2575        assert_eq!(seen[0].body, b"abc");
2576    }
2577
2578    async fn run_download_with_mock(
2579        source: &str,
2580        scripted: Vec<HttpResponse>,
2581        security: Option<Arc<dyn SecurityCheck>>,
2582        vfs_root: &std::path::Path,
2583    ) -> (Arc<MockHttpClient>, Result<(), String>) {
2584        let mock = Arc::new(MockHttpClient::new(scripted));
2585        let res = run_download_with_client(source, mock.clone(), security, vfs_root).await;
2586        (mock, res)
2587    }
2588
2589    async fn run_download_with_client(
2590        source: &str,
2591        client: Arc<dyn HttpClient>,
2592        security: Option<Arc<dyn SecurityCheck>>,
2593        vfs_root: &std::path::Path,
2594    ) -> Result<(), String> {
2595        run_download_measured(source, client, security, vfs_root)
2596            .await
2597            .0
2598    }
2599
2600    async fn run_download_measured(
2601        source: &str,
2602        client: Arc<dyn HttpClient>,
2603        security: Option<Arc<dyn SecurityCheck>>,
2604        vfs_root: &std::path::Path,
2605    ) -> (Result<(), String>, u64) {
2606        run_download_measured_at(source, client, security, vfs_root, "/").await
2607    }
2608
2609    async fn run_download_at(
2610        source: &str,
2611        client: Arc<dyn HttpClient>,
2612        security: Option<Arc<dyn SecurityCheck>>,
2613        vfs_root: &std::path::Path,
2614        cwd: &str,
2615    ) -> Result<(), String> {
2616        run_download_measured_at(source, client, security, vfs_root, cwd)
2617            .await
2618            .0
2619    }
2620
2621    async fn run_download_measured_at(
2622        source: &str,
2623        client: Arc<dyn HttpClient>,
2624        security: Option<Arc<dyn SecurityCheck>>,
2625        vfs_root: &std::path::Path,
2626        cwd: &str,
2627    ) -> (Result<(), String>, u64) {
2628        let compiled =
2629            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
2630        let cfg = RuntimeConfig::default();
2631        let engine = cfg.engine().expect("engine");
2632        let vfs = Vfs::external(vfs_root.to_path_buf())
2633            .expect("external vfs")
2634            .with_cwd(cwd)
2635            .expect("cwd");
2636        let mut data = StoreData::with_vfs("test-run".into(), vfs);
2637        data.http_client = client;
2638        if let Some(sec) = security {
2639            data.security_check = sec;
2640        }
2641        let mut store = cfg.store(&engine, data).expect("store");
2642        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2643        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2644        install_runtime_async(&mut linker, &mut store)
2645            .await
2646            .expect("install");
2647        let inst = linker
2648            .instantiate_async(&mut store, &module)
2649            .await
2650            .expect("instantiate");
2651        let result = dispatch_main_async(&mut store, &inst)
2652            .await
2653            .map(|_| ())
2654            .map_err(|e| format!("{e:?}"));
2655        (result, store.data().host_fuel)
2656    }
2657
2658    #[test]
2659    fn download_options_cannot_exceed_operator_limits() {
2660        for value in [-1.0, 0.5, f64::NAN, f64::INFINITY, 129.0] {
2661            assert!(super::download_limit(value, 128, "maxBytes").is_err());
2662        }
2663        assert_eq!(super::download_limit(0.0, 128, "maxBytes").unwrap(), 0);
2664        assert_eq!(super::download_limit(128.0, 128, "maxBytes").unwrap(), 128);
2665    }
2666
2667    #[tokio::test]
2668    async fn download_over_limit_options_throw_before_request() {
2669        let source = r#"
2670            import { download } from "submilli:http";
2671            function main(): void {
2672                let caught = 0;
2673                try { download("https://example.test/f", "/out", {maxBytes: 52428801}); }
2674                catch (e: RangeError) { caught += 1; }
2675                try { download("https://example.test/f", "/out", {timeout: 60001}); }
2676                catch (e: RangeError) { caught += 1; }
2677                assert(caught === 2);
2678            }
2679        "#;
2680        let root = tempfile::tempdir().unwrap();
2681        let (mock, result) = run_download_with_mock(source, vec![], None, root.path()).await;
2682        result.unwrap();
2683        assert!(mock.seen.lock().unwrap().is_empty());
2684        assert!(dir_is_empty(root.path()));
2685    }
2686
2687    struct CwdPolicy;
2688    impl SecurityCheck for CwdPolicy {
2689        fn check(&self, _: &str, _: &str, _: &serde_json::Value) -> CheckOutcome {
2690            CheckOutcome::Deny {
2691                rule: None,
2692                reason: "missing cwd".into(),
2693            }
2694        }
2695        fn check_with_cwd(
2696            &self,
2697            _: &str,
2698            capability: &str,
2699            context: &serde_json::Value,
2700            cwd: &str,
2701        ) -> CheckOutcome {
2702            let field = if capability == "http.download" {
2703                "vfs_path"
2704            } else {
2705                "path"
2706            };
2707            let path = context
2708                .get(field)
2709                .and_then(serde_json::Value::as_str)
2710                .unwrap_or("");
2711            if crate::runtime::fs::guest_normalize(cwd, path)
2712                .is_ok_and(|path| path == "/notes/out.bin")
2713            {
2714                CheckOutcome::Allow { rule: None }
2715            } else {
2716                CheckOutcome::Deny {
2717                    rule: None,
2718                    reason: "outside notes".into(),
2719                }
2720            }
2721        }
2722    }
2723    #[tokio::test]
2724    async fn download_uses_cwd_for_policy_and_io() {
2725        let tmp = tempfile::tempdir().unwrap();
2726        let mock = Arc::new(MockHttpClient::new(vec![ok_response(200, "hello")]));
2727        run_download_at(r#"import { download } from "submilli:http"; function main(): void { download("https://example.test/file", "out.bin"); }"#,
2728            mock, Some(Arc::new(CwdPolicy)), tmp.path(), "/notes").await.unwrap();
2729        assert_eq!(
2730            std::fs::read(tmp.path().join("notes/out.bin")).unwrap(),
2731            b"hello"
2732        );
2733        assert!(!tmp.path().join("out.bin").exists());
2734    }
2735
2736    #[tokio::test]
2737    async fn download_basic_writes_file_and_returns_meta() {
2738        let source = r#"
2739            import { download, DownloadResult } from "submilli:http";
2740            function main(): void {
2741                const r: DownloadResult = download(
2742                    "https://example.test/file.bin",
2743                    "/out.bin"
2744                );
2745                assert(r.status === 200, "status 200");
2746                assert(r.bytesWritten === 5, "wrote 5 bytes");
2747                assert(r.path === "/out.bin", "path echoed");
2748                assert(r.contentType === "text/plain", "content type extracted");
2749            }
2750        "#;
2751        let tmp = tempfile::tempdir().expect("tempdir");
2752        let (mock, res) =
2753            run_download_with_mock(source, vec![ok_response(200, "hello")], None, tmp.path()).await;
2754        res.expect("main ran");
2755        let on_disk = std::fs::read(tmp.path().join("out.bin")).expect("file written");
2756        assert_eq!(on_disk, b"hello");
2757        let seen = mock.seen.lock().unwrap();
2758        assert_eq!(seen.len(), 1);
2759        assert_eq!(seen[0].method, "GET");
2760        assert_eq!(seen[0].url, "https://example.test/file.bin");
2761    }
2762
2763    #[tokio::test]
2764    async fn download_refuses_when_file_exists_default() {
2765        let source = r#"
2766            import { download } from "submilli:http";
2767            function main(): void {
2768                download("https://example.test/f", "/exists.bin");
2769            }
2770        "#;
2771        let cfg = RuntimeConfig::default();
2772        let engine = cfg.engine().expect("engine");
2773        let vfs = Vfs::tempdir().expect("tempdir");
2774        std::fs::write(vfs.root().join("exists.bin"), b"old").expect("seed");
2775        let mut data = StoreData::with_vfs("test-run".into(), vfs);
2776        let mock = Arc::new(MockHttpClient::new(vec![ok_response(200, "hello")]));
2777        data.http_client = mock;
2778        let compiled =
2779            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile");
2780        let mut store = cfg.store(&engine, data).expect("store");
2781        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2782        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2783        install_runtime_async(&mut linker, &mut store)
2784            .await
2785            .expect("install");
2786        let inst = linker
2787            .instantiate_async(&mut store, &module)
2788            .await
2789            .expect("instantiate");
2790        let err = dispatch_main_async(&mut store, &inst)
2791            .await
2792            .expect_err("must trap on existing file");
2793        let msg = format!("{err:?}");
2794        assert!(
2795            msg.contains("file exists"),
2796            "expected file-exists trap; got: {msg}"
2797        );
2798        assert!(
2799            msg.contains("overwrite: true"),
2800            "expected actionable hint; got: {msg}"
2801        );
2802    }
2803
2804    #[tokio::test]
2805    async fn download_overwrites_when_true() {
2806        let source = r#"
2807            import { download, DownloadResult } from "submilli:http";
2808            function main(): void {
2809                const r: DownloadResult = download(
2810                    "https://example.test/f",
2811                    "/exists.bin",
2812                    { overwrite: true }
2813                );
2814                assert(r.bytesWritten === 3, "wrote 3 bytes");
2815            }
2816        "#;
2817        let cfg = RuntimeConfig::default();
2818        let engine = cfg.engine().expect("engine");
2819        let vfs = Vfs::tempdir().expect("tempdir");
2820        std::fs::write(vfs.root().join("exists.bin"), b"old").expect("seed");
2821        let vfs_root = vfs.root().to_path_buf();
2822        let mut data = StoreData::with_vfs("test-run".into(), vfs);
2823        let mock = Arc::new(MockHttpClient::new(vec![ok_response(200, "new")]));
2824        data.http_client = mock;
2825        let compiled =
2826            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile");
2827        let mut store = cfg.store(&engine, data).expect("store");
2828        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2829        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2830        install_runtime_async(&mut linker, &mut store)
2831            .await
2832            .expect("install");
2833        let inst = linker
2834            .instantiate_async(&mut store, &module)
2835            .await
2836            .expect("instantiate");
2837        dispatch_main_async(&mut store, &inst)
2838            .await
2839            .expect("main ran");
2840        let on_disk = std::fs::read(vfs_root.join("exists.bin")).expect("file written");
2841        assert_eq!(on_disk, b"new", "overwritten with new bytes");
2842    }
2843
2844    #[tokio::test]
2845    async fn download_too_large_traps() {
2846        let source = r#"
2847            import { download } from "submilli:http";
2848            function main(): void {
2849                download(
2850                    "https://example.test/f",
2851                    "/big.bin",
2852                    { maxBytes: 3 }
2853                );
2854            }
2855        "#;
2856        let tmp = tempfile::tempdir().expect("tempdir");
2857        let (_mock, res) =
2858            run_download_with_mock(source, vec![ok_response(200, "hello")], None, tmp.path()).await;
2859        let err = res.expect_err("must trap on too-large");
2860        assert!(
2861            err.contains("too large") || err.contains("limit"),
2862            "expected too-large trap; got: {err}"
2863        );
2864        assert!(
2865            !tmp.path().join("big.bin").exists(),
2866            "no final file should be written when capped",
2867        );
2868        // Best-effort cleanup on error removes .tmp siblings too.
2869        let stragglers: Vec<_> = std::fs::read_dir(tmp.path())
2870            .expect("readdir")
2871            .filter_map(std::result::Result::ok)
2872            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
2873            .collect();
2874        assert!(
2875            stragglers.is_empty(),
2876            "expected no .tmp leftovers, got: {:?}",
2877            stragglers
2878                .iter()
2879                .map(std::fs::DirEntry::file_name)
2880                .collect::<Vec<_>>(),
2881        );
2882    }
2883
2884    #[tokio::test]
2885    async fn download_headers_threaded() {
2886        let source = r#"
2887            import { download, Headers } from "submilli:http";
2888            function main(): void {
2889                const h: Headers = new Map<string, string>();
2890                h.set("Authorization", "Bearer xyz");
2891                download("https://example.test/f", "/out.bin", { headers: h });
2892            }
2893        "#;
2894        let tmp = tempfile::tempdir().expect("tempdir");
2895        let (mock, res) =
2896            run_download_with_mock(source, vec![ok_response(200, "")], None, tmp.path()).await;
2897        res.expect("main ran");
2898        let seen = mock.seen.lock().unwrap();
2899        assert_eq!(seen.len(), 1);
2900        let auth = seen[0]
2901            .headers
2902            .iter()
2903            .find(|(n, _)| n == "Authorization")
2904            .map(|(_, v)| v.as_str());
2905        assert_eq!(auth, Some("Bearer xyz"));
2906    }
2907
2908    #[tokio::test]
2909    async fn download_deny_http_traps() {
2910        struct DenyHttpDownload;
2911        impl SecurityCheck for DenyHttpDownload {
2912            fn check(
2913                &self,
2914                _caller: &str,
2915                capability: &str,
2916                _context: &serde_json::Value,
2917            ) -> CheckOutcome {
2918                if capability == "http.download" {
2919                    CheckOutcome::Deny {
2920                        rule: None,
2921                        reason: "denied http.download in test".into(),
2922                    }
2923                } else {
2924                    CheckOutcome::Allow { rule: None }
2925                }
2926            }
2927        }
2928        let source = r#"
2929            import { download } from "submilli:http";
2930            function main(): void {
2931                download("https://example.test/f", "/out.bin");
2932            }
2933        "#;
2934        let tmp = tempfile::tempdir().expect("tempdir");
2935        let (mock, res) = run_download_with_mock(
2936            source,
2937            vec![ok_response(200, "hello")],
2938            Some(Arc::new(DenyHttpDownload)),
2939            tmp.path(),
2940        )
2941        .await;
2942        let err = res.expect_err("must trap on deny");
2943        assert!(err.contains("permission denied"), "got: {err}");
2944        assert!(err.contains("http.download"), "got: {err}");
2945        assert!(err.contains("caller=main"), "got: {err}");
2946        assert!(
2947            mock.seen.lock().unwrap().is_empty(),
2948            "http_client.send should not have been invoked"
2949        );
2950    }
2951
2952    #[tokio::test]
2953    async fn download_deny_fs_write_traps() {
2954        struct DenyFsWrite;
2955        impl SecurityCheck for DenyFsWrite {
2956            fn check(
2957                &self,
2958                _caller: &str,
2959                capability: &str,
2960                _context: &serde_json::Value,
2961            ) -> CheckOutcome {
2962                if capability == "fs.write" {
2963                    CheckOutcome::Deny {
2964                        rule: None,
2965                        reason: "denied fs.write in test".into(),
2966                    }
2967                } else {
2968                    CheckOutcome::Allow { rule: None }
2969                }
2970            }
2971        }
2972        let source = r#"
2973            import { download } from "submilli:http";
2974            function main(): void {
2975                download("https://example.test/f", "/out.bin");
2976            }
2977        "#;
2978        let tmp = tempfile::tempdir().expect("tempdir");
2979        let (mock, res) = run_download_with_mock(
2980            source,
2981            vec![ok_response(200, "hello")],
2982            Some(Arc::new(DenyFsWrite)),
2983            tmp.path(),
2984        )
2985        .await;
2986        let err = res.expect_err("must trap on deny");
2987        assert!(err.contains("permission denied"), "got: {err}");
2988        assert!(err.contains("fs.write"), "got: {err}");
2989        assert!(
2990            mock.seen.lock().unwrap().is_empty(),
2991            "fs.write check must run before transport"
2992        );
2993    }
2994
2995    #[tokio::test]
2996    async fn download_path_escape_traps() {
2997        let source = r#"
2998            import { download } from "submilli:http";
2999            function main(): void {
3000                download("https://example.test/f", "../etc/passwd");
3001            }
3002        "#;
3003        let tmp = tempfile::tempdir().expect("tempdir");
3004        let (_mock, res) =
3005            run_download_with_mock(source, vec![ok_response(200, "x")], None, tmp.path()).await;
3006        let err = res.expect_err("must trap on path escape");
3007        assert!(
3008            err.contains("path escapes the VFS root"),
3009            "expected sandbox-escape trap; got: {err}"
3010        );
3011    }
3012
3013    /// A VFS root plus a sibling directory outside it, so a test can assert that
3014    /// nothing leaked past the boundary.
3015    fn root_and_outside() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
3016        let td = tempfile::tempdir().expect("tempdir");
3017        let root = td.path().join("root");
3018        let outside = td.path().join("outside");
3019        std::fs::create_dir(&root).expect("mkdir root");
3020        std::fs::create_dir(&outside).expect("mkdir outside");
3021        (td, root, outside)
3022    }
3023
3024    fn dir_is_empty(dir: &std::path::Path) -> bool {
3025        std::fs::read_dir(dir).expect("readdir").next().is_none()
3026    }
3027
3028    #[tokio::test]
3029    #[cfg(unix)]
3030    async fn download_under_an_escaping_link_refuses() {
3031        let source = r#"
3032            import { download } from "submilli:http";
3033            function main(): void {
3034                download("https://example.test/f", "/link/authorized_keys");
3035            }
3036        "#;
3037        let (_td, root, outside) = root_and_outside();
3038        std::os::unix::fs::symlink(&outside, root.join("link")).expect("symlink");
3039        let (_mock, res) =
3040            run_download_with_mock(source, vec![ok_response(200, "pwned")], None, &root).await;
3041        let err = res.expect_err("must refuse a destination behind an escaping link");
3042        assert!(
3043            err.contains("path escapes the VFS root"),
3044            "expected the escape diagnostic; got: {err}"
3045        );
3046        assert!(
3047            dir_is_empty(&outside),
3048            "nothing may be written outside the VFS root",
3049        );
3050    }
3051
3052    #[tokio::test]
3053    #[cfg(unix)]
3054    async fn download_under_an_escaping_link_refuses_even_with_overwrite() {
3055        let source = r#"
3056            import { download } from "submilli:http";
3057            function main(): void {
3058                download(
3059                    "https://example.test/f",
3060                    "/link/authorized_keys",
3061                    { overwrite: true }
3062                );
3063            }
3064        "#;
3065        let (_td, root, outside) = root_and_outside();
3066        std::os::unix::fs::symlink(&outside, root.join("link")).expect("symlink");
3067        std::fs::write(outside.join("authorized_keys"), b"original").expect("seed");
3068        let (_mock, res) =
3069            run_download_with_mock(source, vec![ok_response(200, "pwned")], None, &root).await;
3070        let err = res.expect_err("overwrite must not license an escape");
3071        assert!(
3072            err.contains("path escapes the VFS root"),
3073            "expected the escape diagnostic; got: {err}"
3074        );
3075        assert_eq!(
3076            std::fs::read(outside.join("authorized_keys")).expect("still there"),
3077            b"original",
3078            "the host file must be untouched",
3079        );
3080    }
3081
3082    #[tokio::test]
3083    #[cfg(unix)]
3084    async fn download_through_an_internal_relative_link_succeeds() {
3085        let source = r#"
3086            import { download } from "submilli:http";
3087            function main(): void {
3088                download("https://example.test/f", "/inner/out.bin");
3089            }
3090        "#;
3091        let (_td, root, _outside) = root_and_outside();
3092        std::fs::create_dir(root.join("real")).expect("mkdir real");
3093        std::os::unix::fs::symlink("./real", root.join("inner")).expect("symlink");
3094        let (_mock, res) =
3095            run_download_with_mock(source, vec![ok_response(200, "hello")], None, &root).await;
3096        res.expect("a relative link staying inside the root is traversable");
3097        assert_eq!(
3098            std::fs::read(root.join("real/out.bin")).expect("file written"),
3099            b"hello",
3100        );
3101    }
3102
3103    /// Policy is evaluated before any filesystem work, so a denied download reports the
3104    /// denial rather than whatever the path would have done.
3105    #[tokio::test]
3106    async fn download_denial_precedes_path_resolution() {
3107        struct DenyFsWrite;
3108        impl SecurityCheck for DenyFsWrite {
3109            fn check(
3110                &self,
3111                _caller: &str,
3112                capability: &str,
3113                _context: &serde_json::Value,
3114            ) -> CheckOutcome {
3115                if capability == "fs.write" {
3116                    CheckOutcome::Deny {
3117                        rule: None,
3118                        reason: "denied fs.write in test".into(),
3119                    }
3120                } else {
3121                    CheckOutcome::Allow { rule: None }
3122                }
3123            }
3124        }
3125        let source = r#"
3126            import { download } from "submilli:http";
3127            function main(): void {
3128                download("https://example.test/f", "../etc/passwd");
3129            }
3130        "#;
3131        let (_td, root, _outside) = root_and_outside();
3132        let (_mock, res) = run_download_with_mock(
3133            source,
3134            vec![ok_response(200, "x")],
3135            Some(Arc::new(DenyFsWrite)),
3136            &root,
3137        )
3138        .await;
3139        let err = res.expect_err("must trap");
3140        assert!(
3141            err.contains("permission denied") && err.contains("fs.write"),
3142            "policy must be consulted before the path is resolved; got: {err}"
3143        );
3144        assert!(
3145            !err.contains("path escapes the VFS root"),
3146            "resolution must not preempt the denial; got: {err}"
3147        );
3148    }
3149
3150    #[tokio::test]
3151    async fn download_missing_parent_reports_not_found_not_escape() {
3152        let source = r#"
3153            import { download } from "submilli:http";
3154            function main(): void {
3155                download("https://example.test/f", "/nope/out.bin");
3156            }
3157        "#;
3158        let (_td, root, _outside) = root_and_outside();
3159        let (_mock, res) =
3160            run_download_with_mock(source, vec![ok_response(200, "hello")], None, &root).await;
3161        let err = res.expect_err("must trap on a missing parent");
3162        assert!(
3163            err.contains("parent directory does not exist"),
3164            "a missing parent must not read as an escape; got: {err}"
3165        );
3166        assert!(
3167            !err.contains("path escapes the VFS root"),
3168            "a missing parent must not read as an escape; got: {err}"
3169        );
3170    }
3171
3172    /// The commit goes through the handle the destination resolved against, so a link
3173    /// swapped over the parent while the body streams cannot redirect it.
3174    #[tokio::test]
3175    #[cfg(unix)]
3176    async fn download_commit_refuses_when_the_parent_is_swapped_mid_transfer() {
3177        struct SwapDuringTransfer {
3178            parent: std::path::PathBuf,
3179            outside: std::path::PathBuf,
3180        }
3181        #[async_trait::async_trait]
3182        impl HttpClient for SwapDuringTransfer {
3183            async fn send(&self, _req: &HttpRequest) -> Result<HttpResponse, HttpError> {
3184                Err(HttpError::Other("send unused".into()))
3185            }
3186            async fn download(
3187                &self,
3188                _req: &HttpRequest,
3189                writer: &mut (dyn std::io::Write + Send),
3190            ) -> Result<DownloadMeta, HttpError> {
3191                writer.write_all(b"payload").expect("write body");
3192                std::fs::remove_dir_all(&self.parent).expect("drop the real parent");
3193                std::os::unix::fs::symlink(&self.outside, &self.parent).expect("swap in a link");
3194                Ok(DownloadMeta {
3195                    status: 200,
3196                    status_text: "OK".to_string(),
3197                    headers: Vec::new(),
3198                    final_url: "https://example.test/f".to_string(),
3199                    bytes_written: 7,
3200                })
3201            }
3202        }
3203        let source = r#"
3204            import { download } from "submilli:http";
3205            function main(): void {
3206                download("https://example.test/f", "/a/b/out.bin");
3207            }
3208        "#;
3209        let (_td, root, outside) = root_and_outside();
3210        std::fs::create_dir_all(root.join("a/b")).expect("mkdir a/b");
3211        let client = Arc::new(SwapDuringTransfer {
3212            parent: root.join("a/b"),
3213            outside: outside.clone(),
3214        });
3215        let res = run_download_with_client(source, client, None, &root).await;
3216        let err = res.expect_err("the commit must refuse");
3217        assert!(
3218            err.contains("path escapes the VFS root"),
3219            "expected the escape diagnostic at commit; got: {err}"
3220        );
3221        assert!(
3222            dir_is_empty(&outside),
3223            "the swapped-in link must not receive the download",
3224        );
3225    }
3226
3227    #[tokio::test]
3228    async fn failed_download_settles_received_bytes() {
3229        struct PartialTransfer(usize);
3230        #[async_trait::async_trait]
3231        impl HttpClient for PartialTransfer {
3232            async fn send(&self, _: &HttpRequest) -> Result<HttpResponse, HttpError> {
3233                Err(HttpError::Other("unused".into()))
3234            }
3235            async fn download(
3236                &self,
3237                _: &HttpRequest,
3238                writer: &mut (dyn std::io::Write + Send),
3239            ) -> Result<DownloadMeta, HttpError> {
3240                writer.write_all(&vec![b'x'; self.0]).unwrap();
3241                Err(HttpError::Network("interrupted".into()))
3242            }
3243        }
3244        let source = r#"
3245            import { download } from "submilli:http";
3246            function main(): void {
3247                try { download("https://example.test/f", "/out.bin"); }
3248                catch (e: Error) { assert(e.message.includes("interrupted")); }
3249            }
3250        "#;
3251        let root = tempfile::tempdir().unwrap();
3252        let mut costs = Vec::new();
3253        for n in [0, 128, 256] {
3254            let (result, fuel) =
3255                run_download_measured(source, Arc::new(PartialTransfer(n)), None, root.path())
3256                    .await;
3257            result.unwrap();
3258            assert!(dir_is_empty(root.path()));
3259            costs.push(fuel);
3260        }
3261        assert_eq!(costs[1] - costs[0], 2 * super::fuel::IO.cost(128));
3262        assert_eq!(costs[2] - costs[0], 2 * super::fuel::IO.cost(256));
3263    }
3264
3265    #[tokio::test]
3266    async fn failed_download_leaves_nothing_outside_the_root() {
3267        struct FailingTransfer;
3268        #[async_trait::async_trait]
3269        impl HttpClient for FailingTransfer {
3270            async fn send(&self, _req: &HttpRequest) -> Result<HttpResponse, HttpError> {
3271                Err(HttpError::Network("dns: no such host".into()))
3272            }
3273            async fn download(
3274                &self,
3275                _req: &HttpRequest,
3276                _writer: &mut (dyn std::io::Write + Send),
3277            ) -> Result<DownloadMeta, HttpError> {
3278                Err(HttpError::Network("dns: no such host".into()))
3279            }
3280        }
3281        let source = r#"
3282            import { download } from "submilli:http";
3283            function main(): void {
3284                download("https://example.test/f", "/out.bin");
3285            }
3286        "#;
3287        let (_td, root, _outside) = root_and_outside();
3288        let res = run_download_with_client(source, Arc::new(FailingTransfer), None, &root).await;
3289        assert!(res.is_err(), "must trap on transport failure");
3290        // The destination is inside the root, so an assertion about the *outside* directory
3291        // would hold no matter what the code did. The root is where a straggler can actually
3292        // appear, and it is what this test is for.
3293        assert!(
3294            dir_is_empty(&root),
3295            "a failed transfer must leave no temp file behind",
3296        );
3297    }
3298
3299    #[tokio::test]
3300    async fn download_decompress_gzip() {
3301        use std::io::Write;
3302        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
3303        encoder.write_all(b"hello gzipped").expect("encode");
3304        let gz_bytes = encoder.finish().expect("finish");
3305
3306        let source = r#"
3307            import { download, DownloadResult } from "submilli:http";
3308            function main(): void {
3309                const r: DownloadResult = download(
3310                    "https://example.test/data.txt.gz",
3311                    "/out.txt",
3312                    { decompress: true }
3313                );
3314                assert(r.bytesWritten === 13, "decompressed length");
3315            }
3316        "#;
3317        let response = HttpResponse {
3318            status: 200,
3319            status_text: "OK".into(),
3320            headers: vec![
3321                ("content-encoding".into(), "gzip".into()),
3322                ("content-type".into(), "text/plain".into()),
3323            ],
3324            body: gz_bytes,
3325            final_url: "https://example.test/data.txt.gz".into(),
3326        };
3327        let tmp = tempfile::tempdir().expect("tempdir");
3328        let (_mock, res) = run_download_with_mock(source, vec![response], None, tmp.path()).await;
3329        res.expect("main ran");
3330        let on_disk = std::fs::read(tmp.path().join("out.txt")).expect("file written");
3331        assert_eq!(on_disk, b"hello gzipped");
3332    }
3333
3334    // If `download` buffered via `send`, this test would never terminate — guards the streaming contract.
3335    #[tokio::test]
3336    async fn download_infinite_body_caps_without_buffering() {
3337        struct InfiniteReader;
3338        impl std::io::Read for InfiniteReader {
3339            fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3340                buf.fill(b'a');
3341                Ok(buf.len())
3342            }
3343        }
3344
3345        struct StreamingClient;
3346        #[async_trait::async_trait]
3347        impl HttpClient for StreamingClient {
3348            async fn send(&self, _req: &HttpRequest) -> Result<HttpResponse, HttpError> {
3349                panic!("download must NOT fall back to send")
3350            }
3351            async fn download(
3352                &self,
3353                req: &HttpRequest,
3354                writer: &mut (dyn std::io::Write + Send),
3355            ) -> Result<DownloadMeta, HttpError> {
3356                let bytes_written = stream_to_writer(
3357                    InfiniteReader,
3358                    writer,
3359                    super::transport::Decompression::None,
3360                    req.max_response_size,
3361                )?;
3362                Ok(DownloadMeta {
3363                    status: 200,
3364                    status_text: "OK".into(),
3365                    headers: vec![],
3366                    final_url: req.url.clone(),
3367                    bytes_written,
3368                })
3369            }
3370        }
3371
3372        let source = r#"
3373            import { download } from "submilli:http";
3374            function main(): void {
3375                download(
3376                    "https://example.test/infinite",
3377                    "/never.bin",
3378                    { maxBytes: 1024 }
3379                );
3380            }
3381        "#;
3382        let compiled =
3383            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
3384        let cfg = RuntimeConfig::default();
3385        let engine = cfg.engine().expect("engine");
3386        let tmp = tempfile::tempdir().expect("tempdir");
3387        let vfs = Vfs::external(tmp.path().to_path_buf()).expect("external vfs");
3388        let mut data = StoreData::with_vfs("test-run".into(), vfs);
3389        data.http_client = Arc::new(StreamingClient);
3390        let mut store = cfg.store(&engine, data).expect("store");
3391        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
3392        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
3393        install_runtime_async(&mut linker, &mut store)
3394            .await
3395            .expect("install");
3396        let inst = linker
3397            .instantiate_async(&mut store, &module)
3398            .await
3399            .expect("instantiate");
3400        let err = dispatch_main_async(&mut store, &inst)
3401            .await
3402            .expect_err("must trap on infinite-body cap");
3403        let msg = format!("{err:?}");
3404        assert!(
3405            msg.contains("too large") || msg.contains("limit"),
3406            "expected too-large trap; got: {msg}"
3407        );
3408        assert!(
3409            !tmp.path().join("never.bin").exists(),
3410            "no final file should land when cap aborts the stream",
3411        );
3412    }
3413
3414    // Security check order: http.download fires before fs.write.
3415    #[tokio::test]
3416    async fn download_from_a_script_is_attributed_to_main() {
3417        let recording = Arc::new(RecordingCheck {
3418            seen: Mutex::new(Vec::new()),
3419        });
3420        let source = r#"
3421            import { download } from "submilli:http";
3422            function main(): void {
3423                download("https://example.test/f", "/out.bin");
3424            }
3425        "#;
3426        let tmp = tempfile::tempdir().expect("tempdir");
3427        let (_mock, res) = run_download_with_mock(
3428            source,
3429            vec![ok_response(200, "")],
3430            Some(recording.clone()),
3431            tmp.path(),
3432        )
3433        .await;
3434        res.expect("main ran");
3435        let seen = recording.seen.lock().unwrap().clone();
3436        let download_caps: Vec<&str> = seen
3437            .iter()
3438            .map(|(_, c)| c.as_str())
3439            .filter(|c| *c == "http.download" || *c == "fs.write")
3440            .collect();
3441        assert_eq!(
3442            download_caps,
3443            vec!["http.download", "fs.write"],
3444            "expected http.download then fs.write in order; got: {download_caps:?}"
3445        );
3446        for (caller, capability) in &seen {
3447            assert_eq!(
3448                caller, "main",
3449                "expected caller=main for {capability}; got {caller}"
3450            );
3451        }
3452    }
3453
3454    fn json_response(body: &str) -> HttpResponse {
3455        HttpResponse {
3456            status: 200,
3457            status_text: "OK".to_string(),
3458            headers: vec![("content-type".to_string(), "application/json".to_string())],
3459            body: body.as_bytes().to_vec(),
3460            final_url: "https://example.test/json".to_string(),
3461        }
3462    }
3463
3464    #[tokio::test]
3465    async fn response_json_checks_the_body_against_its_type_argument() {
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<{ k: string }>();
3471                assert(o.k === "v", "parsed and checked json field");
3472            }
3473        "#;
3474        run_with_mock(source, vec![json_response(r#"{"k":"v"}"#)]).await;
3475    }
3476
3477    #[tokio::test]
3478    async fn response_json_takes_its_type_from_the_expected_type() {
3479        let source = r#"
3480            import { get, Response } from "submilli:http";
3481            function main(): void {
3482                const r: Response = get("https://example.test/json");
3483                const o: { k: string } = r.json();
3484                assert(o.k === "v", "checked through the expected type");
3485                try {
3486                    const n: { k: number } = r.json();
3487                    assert(false, "a mismatch must throw");
3488                } catch (e: TypeError) {
3489                    assert(
3490                        e.message === "r.json(): the body does not match `{ k: number }`",
3491                        e.message
3492                    );
3493                }
3494            }
3495        "#;
3496        run_with_mock(source, vec![json_response(r#"{"k":"v"}"#)]).await;
3497    }
3498
3499    #[tokio::test]
3500    async fn response_json_of_a_body_that_is_not_json_is_a_syntax_error() {
3501        let source = r#"
3502            import { get, Response } from "submilli:http";
3503            function main(): void {
3504                const r: Response = get("https://example.test/json");
3505                try {
3506                    r.json<{ k: string }>();
3507                    assert(false, "a non-JSON body must throw");
3508                } catch (e: SyntaxError) {
3509                    assert(e.message.startsWith("r.json(): cannot parse the body: "), e.message);
3510                }
3511            }
3512        "#;
3513        run_with_mock(source, vec![json_response("not json")]).await;
3514    }
3515
3516    /// SUB-2084: a mismatch names the type, never a key the server chose,
3517    /// so remote content does not reach an error message.
3518    #[tokio::test]
3519    async fn a_mismatched_response_json_never_names_a_key_of_the_body() {
3520        let source = r#"
3521            import { get, Response } from "submilli:http";
3522            function main(): void {
3523                const r: Response = get("https://example.test/json");
3524                try {
3525                    r.json<Record<string, number>>();
3526                    assert(false, "a mismatch must throw");
3527                } catch (e: TypeError) {
3528                    assert(!e.message.includes("sk-live"), e.message);
3529                    assert(
3530                        e.message === "r.json(): the body does not match `{ [key: string]: number }`",
3531                        e.message
3532                    );
3533                }
3534            }
3535        "#;
3536        run_with_mock(source, vec![json_response(r#"{"sk-live-SECRET":"high"}"#)]).await;
3537    }
3538
3539    /// A number past the `number` range is a `RangeError`, a primitive `T`
3540    /// travels through the erased slot, and the expected type of a `return`
3541    /// or an argument supplies `T` too.
3542    #[tokio::test]
3543    async fn response_json_reads_primitives_and_takes_t_from_returns_and_arguments() {
3544        let source = r#"
3545            import { get, Response } from "submilli:http";
3546            interface Pair { k: string }
3547            function read(r: Response): Pair { return r.json(); }
3548            function show(p: Pair): string { return p.k; }
3549            function main(): void {
3550                const r: Response = get("https://example.test/json");
3551                assert(read(r).k === "v", "return position");
3552                assert(show(r.json()) === "v", "argument position");
3553                const n = get("https://example.test/n").json<number>();
3554                assert(n === 7, "a primitive T");
3555                try {
3556                    get("https://example.test/big").json<number>();
3557                    assert(false, "an out-of-range number must throw");
3558                } catch (e: RangeError) {
3559                    assert(e.message.startsWith("r.json(): cannot parse the body: "), e.message);
3560                }
3561            }
3562        "#;
3563        run_with_mock(
3564            source,
3565            vec![
3566                json_response(r#"{"k":"v"}"#),
3567                json_response("7"),
3568                json_response("1e400"),
3569            ],
3570        )
3571        .await;
3572    }
3573
3574    /// A generic class's `T` comes from its instance context, not a call's
3575    /// type arguments, and forwards into `r.json<T>()` all the same.
3576    #[tokio::test]
3577    async fn a_generic_class_forwards_its_type_to_response_json() {
3578        let source = r#"
3579            import { get, Response } from "submilli:http";
3580            class Loader<T> { load(r: Response): T { return r.json<T>(); } }
3581            function main(): void {
3582                const r: Response = get("https://example.test/json");
3583                const o = new Loader<{ k: string }>().load(r);
3584                assert(o.k === "v", "class-forwarded type checked");
3585                try {
3586                    new Loader<number>().load(r);
3587                    assert(false, "a mismatch must throw");
3588                } catch (e: TypeError) {
3589                    assert(e.message === "r.json(): the body does not match `number`", e.message);
3590                }
3591            }
3592        "#;
3593        run_with_mock(source, vec![json_response(r#"{"k":"v"}"#)]).await;
3594    }
3595
3596    #[tokio::test]
3597    async fn a_generic_forwards_its_type_to_response_json() {
3598        let source = r#"
3599            import { get, Response } from "submilli:http";
3600            function load<T>(r: Response): T { return r.json<T>(); }
3601            function main(): void {
3602                const r: Response = get("https://example.test/json");
3603                const o = load<{ k: string }>(r);
3604                assert(o.k === "v", "forwarded type checked");
3605                try {
3606                    load<number[]>(r);
3607                    assert(false, "a forwarded mismatch must throw");
3608                } catch (e: TypeError) {
3609                    assert(e.message === "r.json(): the body does not match `number[]`", e.message);
3610                }
3611            }
3612        "#;
3613        run_with_mock(source, vec![json_response(r#"{"k":"v"}"#)]).await;
3614    }
3615
3616    #[tokio::test]
3617    async fn response_json_as_cast() {
3618        // A bare `r.json()` returns `unknown`; `as T` performs normal runtime validation.
3619        let source = r#"
3620            import { get, Response } from "submilli:http";
3621            function main(): void {
3622                const r: Response = get("https://example.test/json");
3623                const o = r.json() as { k: string };
3624                assert(o.k === "v", "parsed via `r.json() as T`");
3625            }
3626        "#;
3627        run_with_mock(source, vec![json_response(r#"{"k":"v"}"#)]).await;
3628    }
3629
3630    // SUB-386: member access on a library-typed value must resolve without the
3631    // user also importing the interface name. These import only `get` and never
3632    // name `Response` — the type flows entirely from the return type.
3633
3634    #[tokio::test]
3635    async fn importless_response_property_access() {
3636        let source = r#"
3637            import { get } from "submilli:http";
3638            function main(): void {
3639                const r = get("https://example.test/u");
3640                assert(r.status === 200, "status is 200");
3641                assert(r.body === "hello", "body decoded");
3642                assert(r.ok, "ok for 2xx");
3643            }
3644        "#;
3645        run_with_mock(source, vec![ok_response(200, "hello")]).await;
3646    }
3647
3648    #[tokio::test]
3649    async fn importless_response_method_call() {
3650        let source = r#"
3651            import { get } from "submilli:http";
3652            function main(): void {
3653                const r = get("https://example.test/t");
3654                const s: string = r.toString();
3655                assert(s === "Response(200 OK, https://example.test/)", s);
3656                r.throwForStatus();
3657            }
3658        "#;
3659        run_with_mock(source, vec![ok_response(200, "")]).await;
3660    }
3661
3662    #[tokio::test]
3663    async fn importless_response_json() {
3664        // `json()` resolves without a `Response` import.
3665        let source = r#"
3666            import { get } from "submilli:http";
3667            function main(): void {
3668                const r = get("https://example.test/json");
3669                const o = r.json() as { k: string };
3670                assert(o.k === "v", "parsed json importlessly");
3671            }
3672        "#;
3673        run_with_mock(source, vec![json_response(r#"{"k":"v"}"#)]).await;
3674    }
3675
3676    #[tokio::test]
3677    async fn importless_headers_alias_member_access() {
3678        // The library `Headers` alias (→ prelude `Map`) resolves structurally
3679        // without importing `Headers`; `r.headers.get(...)` reads through it.
3680        let source = r#"
3681            import { get } from "submilli:http";
3682            function main(): void {
3683                const r = get("https://example.test/u");
3684                const ct = r.headers.get("content-type");
3685                assert(ct === "text/plain", "header read through importless alias");
3686            }
3687        "#;
3688        run_with_mock(source, vec![ok_response(200, "hi")]).await;
3689    }
3690
3691    /// Follows one scripted redirect through the request's guard, as a compliant
3692    /// custom transport must, and records whether the hop was allowed.
3693    struct RedirectingClient {
3694        hop_method: &'static str,
3695        hop_url: &'static str,
3696        method_rewritten: bool,
3697        sent_hop: Mutex<bool>,
3698    }
3699
3700    impl RedirectingClient {
3701        fn follow(&self, req: &HttpRequest) -> Result<(), HttpError> {
3702            let guard = req
3703                .redirect_guard
3704                .as_ref()
3705                .ok_or_else(|| HttpError::Other("request has no redirect guard".into()))?;
3706            let url = url::Url::parse(self.hop_url).unwrap();
3707            let body_len = if self.method_rewritten {
3708                0
3709            } else {
3710                req.body.len() as u64
3711            };
3712            guard
3713                .authorize(&super::RedirectHop {
3714                    method: self.hop_method,
3715                    url: &url,
3716                    method_rewritten: self.method_rewritten,
3717                    body_len,
3718                })
3719                .map_err(HttpError::PermissionDenied)?;
3720            *self.sent_hop.lock().unwrap() = true;
3721            Ok(())
3722        }
3723    }
3724
3725    #[async_trait::async_trait]
3726    impl HttpClient for RedirectingClient {
3727        async fn send(&self, req: &HttpRequest) -> Result<HttpResponse, HttpError> {
3728            self.follow(req)?;
3729            Ok(ok_response(200, "redirected"))
3730        }
3731
3732        async fn download(
3733            &self,
3734            req: &HttpRequest,
3735            writer: &mut (dyn std::io::Write + Send),
3736        ) -> Result<DownloadMeta, HttpError> {
3737            self.follow(req)?;
3738            writer.write_all(b"redirected").unwrap();
3739            Ok(DownloadMeta {
3740                status: 200,
3741                status_text: "OK".into(),
3742                headers: vec![],
3743                final_url: self.hop_url.into(),
3744                bytes_written: 10,
3745            })
3746        }
3747    }
3748
3749    /// Records every check and denies anything aimed at `evil.test`.
3750    #[derive(Default)]
3751    struct DenyEvilHost {
3752        seen: Mutex<Vec<(String, String, serde_json::Value)>>,
3753    }
3754
3755    impl SecurityCheck for DenyEvilHost {
3756        fn check(
3757            &self,
3758            caller: &str,
3759            capability: &str,
3760            context: &serde_json::Value,
3761        ) -> CheckOutcome {
3762            self.seen.lock().unwrap().push((
3763                caller.to_string(),
3764                capability.to_string(),
3765                context.clone(),
3766            ));
3767            if context["host"] == "evil.test" {
3768                CheckOutcome::Deny {
3769                    rule: None,
3770                    reason: "evil.test is not allowed".into(),
3771                }
3772            } else {
3773                CheckOutcome::Allow { rule: None }
3774            }
3775        }
3776    }
3777
3778    async fn run_redirect(
3779        source: &str,
3780        hop_method: &'static str,
3781        hop_url: &'static str,
3782        method_rewritten: bool,
3783    ) -> (
3784        Result<(), String>,
3785        bool,
3786        Vec<(String, String, serde_json::Value)>,
3787    ) {
3788        let tmp = tempfile::tempdir().expect("tempdir");
3789        let client = Arc::new(RedirectingClient {
3790            hop_method,
3791            hop_url,
3792            method_rewritten,
3793            sent_hop: Mutex::new(false),
3794        });
3795        let policy = Arc::new(DenyEvilHost::default());
3796        let result =
3797            run_download_with_client(source, client.clone(), Some(policy.clone()), tmp.path())
3798                .await;
3799        let sent = *client.sent_hop.lock().unwrap();
3800        let seen = policy.seen.lock().unwrap().clone();
3801        (result, sent, seen)
3802    }
3803
3804    #[tokio::test]
3805    async fn redirect_hops_are_checked_for_the_original_caller() {
3806        let source = r#"
3807            import { post } from "submilli:http";
3808            function main(): void {
3809                let caught = "";
3810                try {
3811                    post("https://example.test/start", "hello");
3812                } catch (e: PermissionDeniedError) {
3813                    caught = e.caller + " " + e.capability + ": " + e.reason;
3814                }
3815                assert(caught === "main http.post: evil.test is not allowed", caught);
3816            }
3817        "#;
3818        let (result, sent, seen) =
3819            run_redirect(source, "POST", "https://evil.test/collect", false).await;
3820        result.expect("denial is catchable");
3821        assert!(!sent, "a denied hop must not be sent");
3822        assert_eq!(seen.len(), 2);
3823        assert_eq!(
3824            seen[1],
3825            (
3826                "main".to_string(),
3827                "http.post".to_string(),
3828                serde_json::json!({
3829                    "host": "evil.test",
3830                    "path": "/collect",
3831                    "body_size": 5,
3832                    "timeout_ms": super::DEFAULT_TIMEOUT_MS,
3833                })
3834            )
3835        );
3836    }
3837
3838    #[tokio::test]
3839    async fn rewritten_redirect_hops_are_checked_as_get_on_host_and_path() {
3840        let source = r#"
3841            import { post } from "submilli:http";
3842            function main(): void {
3843                post("https://example.test/start", "hello");
3844            }
3845        "#;
3846        let (result, sent, seen) =
3847            run_redirect(source, "GET", "https://example.test/result", true).await;
3848        result.expect("allowed hop");
3849        assert!(sent);
3850        assert_eq!(
3851            seen[1],
3852            (
3853                "main".to_string(),
3854                "http.get".to_string(),
3855                serde_json::json!({ "host": "example.test", "path": "/result" })
3856            )
3857        );
3858    }
3859
3860    #[tokio::test]
3861    async fn download_redirect_hops_are_checked_before_anything_is_written() {
3862        let source = r#"
3863            import { download } from "submilli:http";
3864            function main(): void {
3865                download("https://example.test/f", "/out.bin");
3866            }
3867        "#;
3868        let (result, sent, seen) = run_redirect(source, "GET", "https://evil.test/f", false).await;
3869        let error = result.expect_err("denied hop");
3870        assert!(error.contains("permission denied"), "{error}");
3871        assert!(!sent);
3872        let capabilities: Vec<&str> = seen.iter().map(|(_, cap, _)| cap.as_str()).collect();
3873        assert_eq!(capabilities, ["http.download", "fs.write", "http.download"]);
3874        assert_eq!(
3875            seen[2].2,
3876            serde_json::json!({
3877                "host": "evil.test",
3878                "url_path": "/f",
3879                "vfs_path": "/out.bin",
3880                "max_bytes": 50 * 1024 * 1024,
3881                "overwrite": false,
3882                "decompress": false,
3883            })
3884        );
3885    }
3886
3887    /// Rebuilds the request the way a custom proxy might, dropping fields it
3888    /// does not know about.
3889    struct RebuildingAuthProxy;
3890
3891    #[async_trait::async_trait]
3892    impl crate::stdlib::http::AuthProxy for RebuildingAuthProxy {
3893        async fn transform(
3894            &self,
3895            req: HttpRequest,
3896            _caller: &str,
3897        ) -> Result<HttpRequest, crate::stdlib::http::AuthProxyError> {
3898            Ok(HttpRequest {
3899                redirect_guard: None,
3900                ..req
3901            })
3902        }
3903    }
3904
3905    #[tokio::test]
3906    async fn an_auth_proxy_cannot_drop_the_redirect_guard() {
3907        let source = r#"
3908            import { get } from "submilli:http";
3909            function main(): void {
3910                get("https://example.test/start");
3911            }
3912        "#;
3913        let compiled = crate::compile_script(source, "test.subm", crate::FileId(0), &[], &[])
3914            .expect("compile clean");
3915        let cfg = RuntimeConfig::default();
3916        let engine = cfg.engine().expect("engine");
3917        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
3918        let client = Arc::new(RedirectingClient {
3919            hop_method: "GET",
3920            hop_url: "https://evil.test/collect",
3921            method_rewritten: false,
3922            sent_hop: Mutex::new(false),
3923        });
3924        data.http_client = client.clone();
3925        data.auth_proxy = Arc::new(RebuildingAuthProxy);
3926        data.security_check = Arc::new(DenyEvilHost::default());
3927        let mut store = cfg.store(&engine, data).expect("store");
3928        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
3929        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
3930        install_runtime_async(&mut linker, &mut store)
3931            .await
3932            .expect("install");
3933        let inst = linker
3934            .instantiate_async(&mut store, &module)
3935            .await
3936            .expect("instantiate");
3937        let error = dispatch_main_async(&mut store, &inst)
3938            .await
3939            .expect_err("the hop is denied");
3940        assert!(
3941            format!("{error:?}").contains("permission denied"),
3942            "{error:?}"
3943        );
3944        assert!(!*client.sent_hop.lock().unwrap());
3945    }
3946
3947    #[tokio::test]
3948    async fn fully_qualified_hosts_are_checked_without_their_trailing_dot() {
3949        let initial = r#"
3950            import { get } from "submilli:http";
3951            function main(): void {
3952                let caught = "";
3953                try {
3954                    get("https://evil.test./x");
3955                } catch (e: PermissionDeniedError) {
3956                    caught = e.reason;
3957                }
3958                assert(caught === "evil.test is not allowed", caught);
3959            }
3960        "#;
3961        let (result, sent, seen) =
3962            run_redirect(initial, "GET", "https://example.test/unused", false).await;
3963        result.expect("initial request denied");
3964        assert!(!sent);
3965        assert_eq!(seen[0].2["host"], "evil.test");
3966
3967        let hop = r#"
3968            import { get } from "submilli:http";
3969            function main(): void {
3970                get("https://example.test/start");
3971            }
3972        "#;
3973        // Two dots: every trailing dot is dropped, not just one.
3974        let (result, sent, seen) =
3975            run_redirect(hop, "GET", "https://evil.test../collect", false).await;
3976        assert!(
3977            result
3978                .expect_err("hop denied")
3979                .contains("permission denied")
3980        );
3981        assert!(!sent);
3982        assert_eq!(seen[1].2["host"], "evil.test");
3983    }
3984}