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