Skip to main content

submilli_engine/stdlib/
url.rs

1//! `submilli:url` — percent-encoding, query-string codecs, URL parse/build.
2//!
3//! Pure Rust host functions registered directly under the package name. `URL`
4//! values are host-built `$UrlBacking` structs — a host-only `$Object` subtype
5//! the guest holds opaquely and reads through the registered property getters.
6//! `Query` maps are real prelude `Map<string, string>`s, built and consumed
7//! host-side.
8
9use 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
33/// What `encodeComponent` and `encodeQuery` both escape: everything but the characters
34/// RFC 3986 calls unreserved, which are the ASCII alphanumerics and `-`, `.`, `_`, `~`.
35const 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    // Positional form; named/optional-arg form deferred until those features land.
191    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
280// `$UrlBacking` field indices (0 is the vtable).
281const 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
288/// Host of `url`, or `""` when it has none. A fully qualified `evil.test.`
289/// names the same host as `evil.test`, so trailing dots are dropped: `parse`
290/// and every capability context report one spelling, and a
291/// `host == "evil.test"` rule sees both.
292///
293/// The dots stay when the host without them is not the same host, so that
294/// `build` can rebuild what `parse` reports: `.` would become empty, and
295/// `.1..` would become `.1`, which is not a valid host.
296pub(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
306/// Whether `host` parses, under `scheme`, as exactly that host.
307fn 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
311/// The parsed pieces of a URL, in host form; `write` turns them into the
312/// `$UrlBacking` the guest sees.
313struct 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
322/// `$UrlBacking` — a host-only `$Object` subtype; nothing in guest code names
323/// this type, so its layout is the host's to choose.
324fn 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),          // protocol
331            string_field(&intr),          // host
332            nullable_object_field(&intr), // port
333            string_field(&intr),          // path
334            nullable_object_field(&intr), // query map
335            nullable_object_field(&intr), // fragment
336        ],
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        /* deterministic = */ 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        /* deterministic = */ 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        /* deterministic = */ 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        /* deterministic = */ 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        /* deterministic = */ 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(),          // protocol
500                string.clone(),          // host
501                nullable_object.clone(), // port: number | undefined
502                string.clone(),          // path
503                nullable_object.clone(), // query
504                nullable_object,         // fragment: string | undefined
505            ],
506            [string],
507        ),
508        /* deterministic = */ 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            // `set_path` would remove the segment, so a caller's `..` would reach the parent.
527            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
546/// The six `URL#<prop>` getters. Each reads its `$UrlBacking` field verbatim —
547/// strings return as `(ref $string)`, nullable fields as `(ref null $Object)`.
548fn 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
579/// Unbox a `number | undefined` param — `undefined`, or a `$boxed_number` — to a port.
580fn 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    // Each part has its own argument. A path inside `host` would otherwise be
604    // parsed, normalized, and then replaced by `path`.
605    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
637/// An ASCII letter followed by letters, digits, `+`, `-` or `.`.
638pub(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
644/// The bytes of a query's keys and values, the size of encoding it.
645fn 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
675/// A dot is left alone, `..` included: escaping it would not stop a URL parser
676/// from reading `%2E%2E` as `..`. `submilli:http` and `build` refuse dot
677/// segments instead.
678fn 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    /// A `url.*` call followed by an `fs.*` call must record `caller="main"` —
724    /// host-fn packages leave the caller stack untouched.
725    #[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}