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