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