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