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