1use crate::runtime::host::{abi_arg, abi_result};
10use std::collections::BTreeMap;
11
12use wasmtime::{Caller, FuncType, HeapType, Linker, RefType, StructType, Val, ValType};
13
14use crate::runtime::StoreData;
15use crate::runtime::fuel;
16use crate::runtime::host::{
17 read_boxed_number, read_string_arg, register_host_fn, register_host_fn_async, type_error,
18 write_boxed_number_struct, write_submilli_string_struct,
19};
20use crate::runtime::intrinsic_types::build_intrinsic_types;
21use crate::runtime::prelude::map;
22use crate::stdlib::abi::{
23 self, backing_struct, install_field_getters, nullable_object_field, string_field,
24};
25use crate::stdlib::dot_segments::refuse_dot_segments_in_path;
26use crate::{
27 Dispatch, PackageDeclaration, Param, PropertySig, Span, Type, TypeKind, TypeSymbol, ValueKind,
28 ValueSymbol,
29};
30
31pub const MODULE_NAME: &str = "submilli:url";
32
33const COMPONENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
36 .remove(b'-')
37 .remove(b'.')
38 .remove(b'_')
39 .remove(b'~');
40
41pub fn package_declaration() -> PackageDeclaration {
42 let mut defs = PackageDeclaration::with_package(MODULE_NAME);
43
44 defs.types.insert(
45 "Query".to_string(),
46 TypeSymbol {
47 name: "Query".to_string(),
48 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Query"),
49 declaration_span: Span::at(crate::FileId::URL),
50 kind: TypeKind::Alias {
51 generics: Vec::new(),
52 ty: Type::InterfaceRef {
53 mangled: crate::mangle::prelude("Map"),
54 package: crate::Package::prelude(),
55 name: "Map".to_string(),
56 args: vec![Type::String, Type::String],
57 },
58 doc: url_doc(
59 "/** Query-string parameter map. `Map<string, string>` with the URL-shaped role baked into the name — same semantics, more readable diagnostics. */",
60 ),
61 },
62 },
63 );
64
65 insert_string_to_string_fn(
66 &mut defs,
67 "encodeComponent",
68 "/**\n * Percent-encode `s` per RFC 3986 (UTF-8 then `%HH` for every byte that isn't an ASCII alphanumeric or one of `-`, `.`, `_`, `~`). It encodes and does not validate: `encodeComponent(\"..\")` is `..`. `submilli:http` and `build` refuse a path with a `.` or `..` segment, so check a value you put in a path when you want a clearer error than that refusal.\n * @param s The component to encode.\n */",
69 );
70 insert_string_to_string_fn(
71 &mut defs,
72 "decodeComponent",
73 "/**\n * Percent-decode `s`. Traps on invalid `%HH` escapes or non-UTF-8 byte sequences.\n * @param s The component to decode.\n */",
74 );
75
76 defs.values.insert(
77 "encodeQuery".to_string(),
78 ValueSymbol {
79 name: "encodeQuery".to_string(),
80 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "encodeQuery"),
81 declaration_span: Span::at(crate::FileId::URL),
82 kind: ValueKind::Function {
83 generics: Vec::new(),
84 params: vec![Param::new("query", query_type())],
85 ret: Type::String,
86 type_predicate: None,
87 doc: url_doc(
88 "/**\n * Serialise a `Query` (`Map<string, string>`) to a URL-encoded query string (`k=v&k=v`). Pairs are emitted in the map's insertion order.\n * @param query Key-value pairs to encode.\n */",
89 ),
90 },
91 },
92 );
93 defs.values.insert(
94 "decodeQuery".to_string(),
95 ValueSymbol {
96 name: "decodeQuery".to_string(),
97 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "decodeQuery"),
98 declaration_span: Span::at(crate::FileId::URL),
99 kind: ValueKind::Function {
100 generics: Vec::new(),
101 params: vec![Param::new("s", Type::String)],
102 ret: query_type(),
103 type_predicate: None,
104 doc: url_doc(
105 "/**\n * Parse a URL-encoded query string into a `Query` (`Map<string, string>`). For multi-value parameters (`?tag=a&tag=b`), the last value wins.\n * @param s The query string (without leading `?`).\n */",
106 ),
107 },
108 },
109 );
110
111 let url_ref = Type::InterfaceRef {
112 mangled: crate::mangle::package_symbol(MODULE_NAME, "URL"),
113 package: crate::Package(MODULE_NAME.to_string()),
114 name: "URL".to_string(),
115 args: Vec::new(),
116 };
117 let optional_number = Type::Union(vec![Type::Number, Type::Undefined]);
118 let optional_string = Type::Union(vec![Type::String, Type::Undefined]);
119 let mut properties = BTreeMap::new();
120 insert_url_property(
121 &mut properties,
122 "protocol",
123 Type::String,
124 "/** Scheme, e.g. `\"https\"`. Always lower-case. */",
125 );
126 insert_url_property(
127 &mut properties,
128 "host",
129 Type::String,
130 "/** Host name, e.g. `\"api.acme.com\"`. Lower-case, without the port or a trailing dot: `\"https://api.acme.com./\"` gives `\"api.acme.com\"`. A host that would be empty or invalid without its trailing dots, such as `\".\"`, keeps them. */",
131 );
132 insert_url_property(
133 &mut properties,
134 "port",
135 optional_number,
136 "/** Port number, or `undefined` when the URL omits one. Common scheme defaults (80 for http, 443 for https) are NOT applied. */",
137 );
138 insert_url_property(
139 &mut properties,
140 "path",
141 Type::String,
142 "/** Path component, including the leading `/`. Empty string when absent. */",
143 );
144 insert_url_property(
145 &mut properties,
146 "query",
147 query_type(),
148 "/** Query parameters as a `Query` (`Map<string, string>`). Empty map when no `?` segment is present. Multi-value parameters keep the last occurrence. */",
149 );
150 insert_url_property(
151 &mut properties,
152 "fragment",
153 optional_string,
154 "/** Fragment string (without the `#` prefix), or `undefined` when the URL has no `#` segment. */",
155 );
156 defs.types.insert(
157 "URL".to_string(),
158 TypeSymbol {
159 name: "URL".to_string(),
160 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "URL"),
161 declaration_span: Span::at(crate::FileId::URL),
162 kind: TypeKind::Interface { index: None,
163 generics: Vec::new(),
164 methods: BTreeMap::new(),
165 properties,
166 dispatch: Dispatch::Direct,
167 doc: url_doc("/** Parsed URL parts — the return type of `parse`. Constructed only by `parse`; user code reads fields via property access. */"),
168 },
169 },
170 );
171
172 defs.values.insert(
173 "parse".to_string(),
174 ValueSymbol {
175 name: "parse".to_string(),
176 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "parse"),
177 declaration_span: Span::at(crate::FileId::URL),
178 kind: ValueKind::Function {
179 generics: Vec::new(),
180 params: vec![Param::new("url", Type::String)],
181 ret: url_ref,
182 type_predicate: None,
183 doc: url_doc(
184 "/**\n * Parse an absolute URL string into its parts. Throws a catchable `TypeError` on malformed input (matches Rust's `url::Url::parse`).\n * @param url Absolute URL (e.g. `\"https://api.acme.com:8443/v1/orders?status=paid#section\"`).\n */",
185 ),
186 },
187 },
188 );
189
190 let optional_number_param = Type::Union(vec![Type::Number, Type::Undefined]);
192 let optional_string_param = Type::Union(vec![Type::String, Type::Undefined]);
193 defs.values.insert(
194 "build".to_string(),
195 ValueSymbol {
196 name: "build".to_string(),
197 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "build"),
198 declaration_span: Span::at(crate::FileId::URL),
199 kind: ValueKind::Function {
200 generics: Vec::new(),
201 params: vec![
202 Param::new("protocol", Type::String),
203 Param::new("host", Type::String),
204 Param::new("port", optional_number_param),
205 Param::new("path", Type::String),
206 Param::new("query", query_type()),
207 Param::with_default(
208 "fragment",
209 optional_string_param,
210 crate::DefaultValue::Undefined,
211 ),
212 ],
213 ret: Type::String,
214 type_predicate: None,
215 doc: url_doc(
216 "/**\n * Serialise URL parts to an absolute URL string. Inverse of `parse`.\n * @param protocol Scheme (e.g. `\"https\"`), without `:`.\n * @param host Host name (e.g. `\"api.acme.com\"`), optionally with userinfo or a port. A `/`, `\\`, `?` or `#` in it throws `TypeError`; pass those parts as their own arguments.\n * @param port Port number, or `undefined` to omit the `:port` segment.\n * @param path Path component (typically starts with `/`). A `.` or `..` segment, in any spelling such as `%2E%2E`, throws `TypeError`.\n * @param query Query parameters as a `Query` (`Map<string, string>`). Empty map omits the `?` segment.\n * @param fragment Fragment string (without `#`); omit it or pass `undefined` for none.\n */",
217 ),
218 },
219 },
220 );
221
222 defs
223}
224
225fn url_doc(literal: &str) -> Option<crate::DocComment> {
226 crate::doc(crate::FileId::URL, literal)
227}
228
229fn insert_string_to_string_fn(defs: &mut PackageDeclaration, name: &str, doc: &str) {
230 defs.values.insert(
231 name.to_string(),
232 ValueSymbol {
233 name: name.to_string(),
234 mangled_name: crate::mangle::package_symbol(MODULE_NAME, name),
235 declaration_span: Span::at(crate::FileId::URL),
236 kind: ValueKind::Function {
237 generics: Vec::new(),
238 params: vec![Param::new("s", Type::String)],
239 ret: Type::String,
240 type_predicate: None,
241 doc: url_doc(doc),
242 },
243 },
244 );
245}
246
247fn query_type() -> Type {
248 Type::Alias {
249 mangled: crate::mangle::package_symbol(MODULE_NAME, "Query"),
250 package: crate::Package(MODULE_NAME.to_string()),
251 name: "Query".to_string(),
252 args: Vec::new(),
253 ty: Box::new(Type::InterfaceRef {
254 mangled: crate::mangle::prelude("Map"),
255 package: crate::Package::prelude(),
256 name: "Map".to_string(),
257 args: vec![Type::String, Type::String],
258 }),
259 }
260}
261
262fn insert_url_property(
263 properties: &mut BTreeMap<String, PropertySig>,
264 name: &str,
265 ty: Type,
266 doc: &str,
267) {
268 properties.insert(
269 name.to_string(),
270 PropertySig {
271 ty,
272 readonly: true,
273 intrinsic: false,
274 optional: false,
275 doc: url_doc(doc),
276 },
277 );
278}
279
280const F_PROTOCOL: usize = 1;
282const F_HOST: usize = 2;
283const F_PORT: usize = 3;
284const F_PATH: usize = 4;
285const F_QUERY: usize = 5;
286const F_FRAGMENT: usize = 6;
287
288pub(crate) fn host_without_trailing_dots(url: &url::Url) -> &str {
297 let host = url.host_str().unwrap_or("");
298 let stripped = host.trim_end_matches('.');
299 if stripped.len() == host.len() || names_host(url.scheme(), stripped) {
300 stripped
301 } else {
302 host
303 }
304}
305
306fn names_host(scheme: &str, host: &str) -> bool {
308 url::Url::parse(&format!("{scheme}://{host}/")).is_ok_and(|url| url.host_str() == Some(host))
309}
310
311struct UrlParts {
314 protocol: String,
315 host: String,
316 port: Option<u16>,
317 path: String,
318 query: Vec<(String, String)>,
319 fragment: Option<String>,
320}
321
322fn url_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
325 let intr = build_intrinsic_types(engine)?;
326 backing_struct(
327 engine,
328 &intr,
329 vec![
330 string_field(&intr), string_field(&intr), nullable_object_field(&intr), string_field(&intr), nullable_object_field(&intr), nullable_object_field(&intr), ],
337 )
338}
339
340impl UrlParts {
341 async fn write(self, caller: &mut Caller<'_, StoreData>) -> wasmtime::Result<Val> {
342 let protocol = write_submilli_string_struct(caller, &self.protocol)?.to_anyref();
343 let host = write_submilli_string_struct(caller, &self.host)?.to_anyref();
344 let port = match self.port {
345 Some(p) => Val::AnyRef(Some(
346 write_boxed_number_struct(caller, f64::from(p))?.to_anyref(),
347 )),
348 None => crate::runtime::prelude::undefined::value(caller)?,
349 };
350 let path = write_submilli_string_struct(caller, &self.path)?.to_anyref();
351 let query = map::string_map_from_pairs(caller, &self.query).await?;
352 let fragment = match &self.fragment {
353 Some(f) => Val::AnyRef(Some(write_submilli_string_struct(caller, f)?.to_anyref())),
354 None => crate::runtime::prelude::undefined::value(caller)?,
355 };
356 let ty = url_backing_struct(caller.engine())?;
357 abi::new_backing(
358 caller,
359 ty,
360 &[
361 Val::AnyRef(Some(protocol)),
362 Val::AnyRef(Some(host)),
363 port,
364 Val::AnyRef(Some(path)),
365 query,
366 fragment,
367 ],
368 )
369 }
370}
371
372pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
373 let engine = linker.engine().clone();
374 let intr = build_intrinsic_types(&engine)?;
375 let string = ValType::Ref(RefType::new(
376 false,
377 HeapType::ConcreteStruct(intr.string.clone()),
378 ));
379 let object = ValType::Ref(RefType::new(
380 false,
381 HeapType::ConcreteStruct(intr.object.clone()),
382 ));
383 let nullable_object = ValType::Ref(RefType::new(
384 true,
385 HeapType::ConcreteStruct(intr.object.clone()),
386 ));
387
388 let string_to_string = FuncType::new(&engine, [string.clone()], [string.clone()]);
389 register_host_fn(
390 linker,
391 MODULE_NAME,
392 crate::mangle::package_symbol(MODULE_NAME, "encodeComponent"),
393 string_to_string.clone(),
394 true,
395 |caller, params, results| {
396 let s = read_string_arg(&mut *caller, abi_arg(params, 0)?, "url.encodeComponent")?;
397 fuel::charge(&mut *caller, fuel::SCAN, s.len() as u64)?;
398 let encoded = encode_component(&s);
399 let st = write_submilli_string_struct(caller, &encoded)?;
400 *abi_result(results, 0)? = Val::AnyRef(Some(st.to_anyref()));
401 Ok(())
402 },
403 )?;
404
405 register_host_fn(
406 linker,
407 MODULE_NAME,
408 crate::mangle::package_symbol(MODULE_NAME, "decodeComponent"),
409 string_to_string,
410 true,
411 |caller, params, results| {
412 let s = read_string_arg(&mut *caller, abi_arg(params, 0)?, "url.decodeComponent")?;
413 fuel::charge(&mut *caller, fuel::SCAN, s.len() as u64)?;
414 let decoded = percent_encoding::percent_decode_str(&s)
415 .decode_utf8()
416 .map_err(|e| {
417 type_error(format!(
418 "url.decodeComponent: invalid UTF-8 in decoded bytes: {e}"
419 ))
420 })?
421 .into_owned();
422 let st = write_submilli_string_struct(caller, &decoded)?;
423 *abi_result(results, 0)? = Val::AnyRef(Some(st.to_anyref()));
424 Ok(())
425 },
426 )?;
427
428 register_host_fn(
429 linker,
430 MODULE_NAME,
431 crate::mangle::package_symbol(MODULE_NAME, "encodeQuery"),
432 FuncType::new(&engine, [nullable_object.clone()], [string.clone()]),
433 true,
434 |caller, params, results| {
435 let pairs = map::string_entries(caller, abi_arg(params, 0)?)?;
436 fuel::charge(&mut *caller, fuel::SCAN, pairs_len(&pairs))?;
437 let st = write_submilli_string_struct(caller, &encode_query(&pairs))?;
438 *abi_result(results, 0)? = Val::AnyRef(Some(st.to_anyref()));
439 Ok(())
440 },
441 )?;
442
443 register_host_fn_async(
444 linker,
445 MODULE_NAME,
446 crate::mangle::package_symbol(MODULE_NAME, "decodeQuery"),
447 FuncType::new(&engine, [string.clone()], [nullable_object.clone()]),
448 true,
449 |caller, params, results| {
450 Box::pin(async move {
451 let s = read_string_arg(&mut *caller, abi_arg(params, 0)?, "url.decodeQuery")?;
452 fuel::charge(&mut *caller, fuel::SCAN, s.len() as u64)?;
453 let pairs =
454 decode_query(&s).map_err(|e| type_error(format!("url.decodeQuery: {e}")))?;
455 *abi_result(results, 0)? = map::string_map_from_pairs(caller, &pairs).await?;
456 Ok(())
457 })
458 },
459 )?;
460
461 register_host_fn_async(
462 linker,
463 MODULE_NAME,
464 crate::mangle::package_symbol(MODULE_NAME, "parse"),
465 FuncType::new(&engine, [string.clone()], [nullable_object.clone()]),
466 true,
467 |caller, params, results| {
468 Box::pin(async move {
469 let s = read_string_arg(&mut *caller, abi_arg(params, 0)?, "url.parse")?;
470 fuel::charge(&mut *caller, fuel::PARSE, s.len() as u64)?;
471 let parsed =
472 url::Url::parse(&s).map_err(|e| type_error(format!("url.parse: {e}")))?;
473 let query = match parsed.query() {
474 Some(qs) => decode_query(qs)
475 .map_err(|e| type_error(format!("url.parse: query: {e}")))?,
476 None => Vec::new(),
477 };
478 let parts = UrlParts {
479 protocol: parsed.scheme().to_string(),
480 host: host_without_trailing_dots(&parsed).to_string(),
481 port: parsed.port(),
482 path: parsed.path().to_string(),
483 query,
484 fragment: parsed.fragment().map(str::to_string),
485 };
486 *abi_result(results, 0)? = parts.write(caller).await?;
487 Ok(())
488 })
489 },
490 )?;
491
492 register_host_fn(
493 linker,
494 MODULE_NAME,
495 crate::mangle::package_symbol(MODULE_NAME, "build"),
496 FuncType::new(
497 &engine,
498 [
499 string.clone(), string.clone(), nullable_object.clone(), string.clone(), nullable_object.clone(), nullable_object, ],
506 [string],
507 ),
508 true,
509 |caller, params, results| {
510 let protocol =
511 read_string_arg(&mut *caller, abi_arg(params, 0)?, "url.build (protocol)")?;
512 let host = read_string_arg(&mut *caller, abi_arg(params, 1)?, "url.build (host)")?;
513 let port = read_optional_number(caller, abi_arg(params, 2)?, "url.build (port)")?;
514 let path = read_string_arg(&mut *caller, abi_arg(params, 3)?, "url.build (path)")?;
515 let query = map::string_entries(caller, abi_arg(params, 4)?)?;
516 let fragment = abi_arg(params, 5)?;
517 let fragment = if crate::runtime::prelude::undefined::is_undefined(caller, fragment)? {
518 None
519 } else {
520 Some(read_string_arg(
521 &mut *caller,
522 fragment,
523 "url.build (fragment)",
524 )?)
525 };
526 refuse_dot_segments_in_path(&path)
528 .map_err(|refusal| refusal.into_error("url.build"))?;
529 fuel::charge(
530 &mut *caller,
531 fuel::PARSE,
532 (protocol.len() + host.len() + path.len()) as u64 + pairs_len(&query),
533 )?;
534 let serialised = build_url(&protocol, &host, port, &path, &query, fragment.as_deref())
535 .map_err(|e| type_error(format!("url.build: {e}")))?;
536 let st = write_submilli_string_struct(caller, &serialised)?;
537 *abi_result(results, 0)? = Val::AnyRef(Some(st.to_anyref()));
538 Ok(())
539 },
540 )?;
541
542 install_url_getters(linker, &engine, &intr, object)?;
543 Ok(())
544}
545
546fn install_url_getters(
549 linker: &mut Linker<StoreData>,
550 engine: &wasmtime::Engine,
551 intr: &crate::runtime::intrinsic_types::IntrinsicTypes,
552 receiver: ValType,
553) -> wasmtime::Result<()> {
554 let string = ValType::Ref(RefType::new(
555 false,
556 HeapType::ConcreteStruct(intr.string.clone()),
557 ));
558 let nullable_object = ValType::Ref(RefType::new(
559 true,
560 HeapType::ConcreteStruct(intr.object.clone()),
561 ));
562 install_field_getters(
563 linker,
564 MODULE_NAME,
565 "URL",
566 engine,
567 &receiver,
568 &[
569 ("protocol", F_PROTOCOL, string.clone()),
570 ("host", F_HOST, string.clone()),
571 ("port", F_PORT, nullable_object.clone()),
572 ("path", F_PATH, string),
573 ("query", F_QUERY, nullable_object.clone()),
574 ("fragment", F_FRAGMENT, nullable_object),
575 ],
576 )
577}
578
579fn read_optional_number(
581 caller: &mut Caller<'_, StoreData>,
582 val: &Val,
583 name: &str,
584) -> wasmtime::Result<Option<u16>> {
585 if crate::runtime::prelude::undefined::is_undefined(caller, val)? {
586 return Ok(None);
587 }
588 let n = read_boxed_number(caller, val, name)?;
589 if n < 0.0 || n > f64::from(u16::MAX) || n.fract() != 0.0 {
590 return Err(type_error(format!("{name}: {n} is not a valid port")));
591 }
592 Ok(Some(n as u16))
593}
594
595fn build_url(
596 protocol: &str,
597 host: &str,
598 port: Option<u16>,
599 path: &str,
600 query_kv: &[(String, String)],
601 fragment: Option<&str>,
602) -> Result<String, String> {
603 if !is_scheme(protocol) {
606 return Err(format!(
607 "protocol {protocol:?} is not a scheme such as \"https\""
608 ));
609 }
610 if let Some(delimiter) = host.chars().find(|c| matches!(c, '/' | '\\' | '?' | '#')) {
611 return Err(format!(
612 "host {host:?} has {delimiter:?}; pass the path, query and fragment as their own arguments"
613 ));
614 }
615 let base = format!("{protocol}://{host}");
616 let mut url = url::Url::parse(&base).map_err(|e| e.to_string())?;
617 if port.is_some() {
618 url.set_port(port)
619 .map_err(|()| "invalid port for scheme".to_string())?;
620 }
621 if !path.is_empty() {
622 url.set_path(path);
623 }
624 if query_kv.is_empty() {
625 url.set_query(None);
626 } else {
627 let mut pairs = url.query_pairs_mut();
628 for (k, v) in query_kv {
629 pairs.append_pair(k, v);
630 }
631 drop(pairs);
632 }
633 url.set_fragment(fragment);
634 Ok(url.to_string())
635}
636
637pub(crate) fn is_scheme(scheme: &str) -> bool {
639 let mut chars = scheme.chars();
640 chars.next().is_some_and(|c| c.is_ascii_alphabetic())
641 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
642}
643
644fn pairs_len(pairs: &[(String, String)]) -> u64 {
646 pairs
647 .iter()
648 .map(|(key, value)| (key.len() + value.len()) as u64)
649 .sum()
650}
651
652fn decode_query(s: &str) -> Result<Vec<(String, String)>, String> {
653 let mut out = Vec::new();
654 for pair in s.split('&') {
655 if pair.is_empty() {
656 continue;
657 }
658 let (k_raw, v_raw) = match pair.find('=') {
659 Some(eq) => (&pair[..eq], &pair[eq + 1..]),
660 None => (pair, ""),
661 };
662 let k = percent_encoding::percent_decode_str(k_raw)
663 .decode_utf8()
664 .map_err(|e| format!("invalid UTF-8 in key: {e}"))?
665 .into_owned();
666 let v = percent_encoding::percent_decode_str(v_raw)
667 .decode_utf8()
668 .map_err(|e| format!("invalid UTF-8 in value: {e}"))?
669 .into_owned();
670 out.push((k, v));
671 }
672 Ok(out)
673}
674
675fn encode_component(component: &str) -> String {
679 percent_encoding::utf8_percent_encode(component, COMPONENT).to_string()
680}
681
682fn encode_query(pairs: &[(String, String)]) -> String {
683 let mut out = String::new();
684 for (i, (k, v)) in pairs.iter().enumerate() {
685 if i > 0 {
686 out.push('&');
687 }
688 out.push_str(&percent_encoding::utf8_percent_encode(k, COMPONENT).to_string());
689 out.push('=');
690 out.push_str(&percent_encoding::utf8_percent_encode(v, COMPONENT).to_string());
691 }
692 out
693}
694
695#[cfg(test)]
696mod tests {
697 use std::sync::Arc;
698
699 use crate::compile_script;
700 use crate::runtime::security::{CheckOutcome, SecurityCheck};
701 use crate::runtime::{
702 RuntimeConfig, StoreData, Vfs, dispatch_main_async, install_runtime_async,
703 };
704
705 struct RecordingCheck {
706 seen: std::sync::Mutex<Vec<(String, String)>>,
707 }
708 impl SecurityCheck for RecordingCheck {
709 fn check(
710 &self,
711 caller: &str,
712 capability: &str,
713 _context: &serde_json::Value,
714 ) -> CheckOutcome {
715 self.seen
716 .lock()
717 .unwrap()
718 .push((caller.to_string(), capability.to_string()));
719 CheckOutcome::Allow { rule: None }
720 }
721 }
722
723 #[tokio::test]
726 async fn url_call_leaves_caller_attribution_intact() {
727 let source = r#"
728 import { parse } from "submilli:url";
729 import { writeText } from "submilli:fs";
730 function main(): void {
731 const _u = parse("https://example.com/path?a=1");
732 writeText("/x.txt", "hi");
733 }
734 "#;
735 let recording = Arc::new(RecordingCheck {
736 seen: std::sync::Mutex::new(Vec::new()),
737 });
738 let compiled =
739 compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
740 let cfg = RuntimeConfig::default();
741 let engine = cfg.engine().expect("engine");
742 let mut data = StoreData::with_vfs(Vfs::tempdir().expect("tempdir"));
743 data.security_check = recording.clone();
744 let mut store = cfg.store(&engine, data).expect("store");
745 let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
746 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
747 install_runtime_async(&mut linker, &mut store)
748 .await
749 .expect("install");
750 let inst = linker
751 .instantiate_async(&mut store, &module)
752 .await
753 .expect("instantiate");
754 dispatch_main_async(&mut store, &inst)
755 .await
756 .expect("main ran");
757
758 let seen = recording.seen.lock().unwrap().clone();
759 assert!(
760 !seen.is_empty(),
761 "the fs.* call should have been recorded by RecordingCheck"
762 );
763 for (caller, capability) in &seen {
764 assert_eq!(
765 caller, "main",
766 "expected caller=main for capability {capability}; got {caller}"
767 );
768 }
769 }
770}