1use 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
59const DEFAULT_TIMEOUT_MS: u64 = 30_000;
62const DOWNLOAD_TIMEOUT_MS: u64 = 60_000;
63
64const TO_JSON_SLOT: usize = 1;
66
67const 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
75const 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
83fn 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), nullable_object_field(&intr), i32_field(), f64_field(), string_field(&intr), string_field(&intr), ],
99 )
100}
101
102fn 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(), string_field(&intr), f64_field(), string_field(&intr), string_field(&intr), f64_field(), ],
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 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 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 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
257enum RequestBody {
263 Empty,
264 Text(Vec<u8>),
266 Json(Vec<u8>),
268 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
289async 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
319fn 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
331fn 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
337fn 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
359async 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 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 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 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 req.redirect_guard = Some(guard);
433
434 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
460fn 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
477fn 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
494fn 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
512fn 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
537fn 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
543fn 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
549fn 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
573fn 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#[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
612fn 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
657fn 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
673async 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 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 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 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 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
817async 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 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#[derive(serde::Serialize, serde::Deserialize)]
891struct Downloaded {
892 meta: DownloadMeta,
893 duration_ms: f64,
894}
895
896enum 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
929async 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
967fn 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
1000const 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
1032pub(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 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 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 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 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 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 #[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 #[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 #[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 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 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 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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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 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 #[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 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 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}