LibURL: Implement a URL parser in rust

This commit is contained in:
Shannon Booth 2026-03-14 18:05:06 +01:00 committed by Shannon Booth
parent 8cc458e73a
commit d44ff6628f
20 changed files with 3175 additions and 1 deletions

8
Cargo.lock generated
View file

@ -736,6 +736,14 @@ dependencies = [
"icu_calendar",
]
[[package]]
name = "liburl_rust"
version = "0.1.0"
dependencies = [
"cbindgen",
"libunicode_rust",
]
[[package]]
name = "libwasm_cranelift"
version = "0.1.0"

View file

@ -4,6 +4,7 @@ members = [
"Libraries/LibJS/Rust",
"Libraries/LibRegex/Rust",
"Libraries/LibUnicode/Rust",
"Libraries/LibURL/Rust",
"Libraries/LibWasm/Rust",
"Libraries/LibWeb/ContentBlocker/Rust",
"Libraries/LibWeb/Rust",

View file

@ -4,6 +4,7 @@ set(SOURCES
Host.cpp
Origin.cpp
Parser.cpp
RustIntegration.cpp
Site.cpp
URL.cpp
${PUBLIC_SUFFIX_SOURCES}
@ -20,4 +21,7 @@ set(SOURCES
)
ladybird_lib(LibURL url)
target_link_libraries(LibURL PRIVATE LibUnicode LibTextCodec LibRegex)
target_link_libraries(LibURL PRIVATE liburl_rust LibUnicode LibTextCodec LibRegex)
import_rust_crate(MANIFEST_PATH Rust/Cargo.toml CRATE_NAME liburl_rust FEATURES allocator FFI_HEADER RustFFI.h)
target_compile_definitions(LibURL PRIVATE ENABLE_RUST)

View file

@ -20,6 +20,7 @@
#include <LibTextCodec/Decoder.h>
#include <LibTextCodec/Encoder.h>
#include <LibURL/Parser.h>
#include <LibURL/RustIntegration.h>
#include <LibUnicode/IDNA.h>
namespace URL {
@ -708,6 +709,10 @@ static String remove_ascii_tab_or_newline(StringView input)
// https://url.spec.whatwg.org/#concept-basic-url-parser
Optional<URL> Parser::basic_parse(StringView raw_input, Optional<URL const&> base_url, URL* url, Optional<State> state_override, Optional<StringView> encoding)
{
#ifdef ENABLE_RUST
return RustIntegration::parse_basic_url(raw_input, base_url, url, state_override, encoding);
#endif
dbgln_if(URL_PARSER_DEBUG, "URL::Parser::basic_parse: Parsing '{}'", raw_input);
size_t start_index = 0;

View file

@ -0,0 +1,18 @@
[package]
name = "liburl_rust"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["staticlib"]
[dependencies]
libunicode_rust = { path = "../../LibUnicode/Rust" }
[features]
default = []
allocator = []
debug-validation-errors = []
[build-dependencies]
cbindgen = "0.29"

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
use std::env;
use std::error::Error;
use std::path::PathBuf;
fn main() -> Result<(), Box<dyn Error>> {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=cbindgen.toml");
println!("cargo:rerun-if-env-changed=FFI_OUTPUT_DIR");
println!("cargo:rerun-if-changed=src");
let ffi_out_dir = env::var("FFI_OUTPUT_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| out_dir.clone());
cbindgen::generate(manifest_dir).map_or_else(
|error| match error {
cbindgen::Error::ParseSyntaxError { .. } => {}
e => panic!("{e:?}"),
},
|bindings| {
let header_path = out_dir.join("RustFFI.h");
bindings.write_to_file(&header_path);
if ffi_out_dir != out_dir {
bindings.write_to_file(ffi_out_dir.join("RustFFI.h"));
}
},
);
Ok(())
}

View file

@ -0,0 +1,17 @@
language = "C++"
header = """/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/"""
pragma_once = true
include_version = true
namespaces = ["URL", "FFI"]
line_length = 120
tab_width = 4
no_includes = true
sys_includes = ["stdint.h", "stddef.h"]
usize_is_size_t = true
[export.mangle]
rename_types = "PascalCase"

View file

@ -0,0 +1,7 @@
/*
* Copyright (c) 2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
pub mod url;

View file

@ -0,0 +1,289 @@
/*
* Copyright (c) 2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
use std::ffi::c_void;
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
use std::panic::AssertUnwindSafe;
use std::panic::catch_unwind;
use crate::url::BasicParseOptions;
use crate::url::Host;
use crate::url::State;
use crate::url::Url;
use crate::url::basic_parse;
use crate::url::basic_parse_into;
use crate::url::is_special_scheme;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct RustUrlByteSlice {
pub data: *const u8,
pub length: usize,
}
#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RustUrlHostKind {
String,
Ipv4,
Ipv6,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct FfiUrlHost {
pub has_host: bool,
pub kind: RustUrlHostKind,
pub ipv4: [u8; 4],
pub ipv6: [u8; 16],
pub string_data: *const u8,
pub string_length: usize,
}
impl Default for FfiUrlHost {
fn default() -> Self {
Self {
has_host: false,
kind: RustUrlHostKind::String,
ipv4: [0; 4],
ipv6: [0; 16],
string_data: std::ptr::null(),
string_length: 0,
}
}
}
#[repr(C)]
pub struct RustFfiUrl {
pub scheme: RustUrlByteSlice,
pub username: RustUrlByteSlice,
pub password: RustUrlByteSlice,
pub host: FfiUrlHost,
pub has_port: bool,
pub port: u16,
pub path_segments: *const RustUrlByteSlice,
pub path_segment_count: usize,
pub has_opaque_path: bool,
pub has_query: bool,
pub query: RustUrlByteSlice,
pub has_fragment: bool,
pub fragment: RustUrlByteSlice,
}
/// FFI parse options borrowed from C++ storage.
///
/// The embedded `RustFfiUrl` values contain raw pointers into `UrlFfiStorage`
/// objects on the C++ side. Those storage objects must outlive the call to
/// `rust_url_basic_parse`.
#[repr(C)]
pub struct RustBasicParseOptions {
pub has_base_url: bool,
pub has_url: bool,
pub base_url: RustFfiUrl,
pub url: RustFfiUrl,
pub has_state_override: bool,
pub state_override: State,
pub encoding: RustUrlByteSlice,
}
pub type FfiUrlResultFn = unsafe extern "C" fn(*mut c_void, *const RustFfiUrl);
fn abort_on_panic<F: FnOnce() -> R, R>(f: F) -> R {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(result) => result,
Err(_) => std::process::abort(),
}
}
fn decode_utf8(slice: RustUrlByteSlice) -> String {
if slice.data.is_null() {
return String::new();
}
// SAFETY: slice.data is valid for slice.length bytes (contract with C++ caller).
let bytes = unsafe { std::slice::from_raw_parts(slice.data, slice.length) };
std::str::from_utf8(bytes)
.expect("URL fields are valid UTF-8")
.to_owned()
}
fn host_from_ffi(ffi: &FfiUrlHost, scheme: &str) -> Option<Host> {
if !ffi.has_host {
return None;
}
Some(match ffi.kind {
RustUrlHostKind::String => {
let s = decode_utf8(RustUrlByteSlice {
data: ffi.string_data,
length: ffi.string_length,
});
if is_special_scheme(scheme.as_bytes()) {
Host::Domain(s)
} else {
Host::Opaque(s)
}
}
RustUrlHostKind::Ipv4 => Host::Ipv4(Ipv4Addr::new(ffi.ipv4[0], ffi.ipv4[1], ffi.ipv4[2], ffi.ipv4[3])),
RustUrlHostKind::Ipv6 => Host::Ipv6(Ipv6Addr::from(ffi.ipv6)),
})
}
fn host_to_ffi(host: Option<&Host>) -> FfiUrlHost {
let Some(host) = host else {
return FfiUrlHost::default();
};
match host {
Host::Domain(s) | Host::Opaque(s) => FfiUrlHost {
has_host: true,
kind: RustUrlHostKind::String,
string_data: s.as_ptr(),
string_length: s.len(),
..FfiUrlHost::default()
},
Host::Ipv4(addr) => FfiUrlHost {
has_host: true,
kind: RustUrlHostKind::Ipv4,
ipv4: addr.octets(),
..FfiUrlHost::default()
},
Host::Ipv6(addr) => FfiUrlHost {
has_host: true,
kind: RustUrlHostKind::Ipv6,
ipv6: addr.octets(),
..FfiUrlHost::default()
},
}
}
fn url_from_ffi(ffi: &RustFfiUrl) -> Url {
let scheme = decode_utf8(ffi.scheme);
let path = if !ffi.path_segments.is_null() {
// SAFETY: path_segments is valid for path_segment_count elements.
let segments = unsafe { std::slice::from_raw_parts(ffi.path_segments, ffi.path_segment_count) };
segments.iter().map(|s| decode_utf8(*s)).collect()
} else {
vec![]
};
Url {
scheme: scheme.clone(),
username: decode_utf8(ffi.username),
password: decode_utf8(ffi.password),
host: host_from_ffi(&ffi.host, &scheme),
port: ffi.has_port.then_some(ffi.port),
path,
has_opaque_path: ffi.has_opaque_path,
query: ffi.has_query.then(|| decode_utf8(ffi.query)),
fragment: ffi.has_fragment.then(|| decode_utf8(ffi.fragment)),
}
}
fn url_to_ffi_result<'a>(url: &'a Url, path_slices: &'a [RustUrlByteSlice]) -> RustFfiUrl {
let null_slice = RustUrlByteSlice {
data: std::ptr::null(),
length: 0,
};
RustFfiUrl {
scheme: RustUrlByteSlice {
data: url.scheme.as_ptr(),
length: url.scheme.len(),
},
username: RustUrlByteSlice {
data: url.username.as_ptr(),
length: url.username.len(),
},
password: RustUrlByteSlice {
data: url.password.as_ptr(),
length: url.password.len(),
},
host: host_to_ffi(url.host.as_ref()),
has_port: url.port.is_some(),
port: url.port.unwrap_or(0),
path_segments: path_slices.as_ptr(),
path_segment_count: path_slices.len(),
has_opaque_path: url.has_opaque_path,
has_query: url.query.is_some(),
query: url.query.as_deref().map_or(null_slice, |s: &str| RustUrlByteSlice {
data: s.as_ptr(),
length: s.len(),
}),
has_fragment: url.fragment.is_some(),
fragment: url.fragment.as_deref().map_or(null_slice, |s: &str| RustUrlByteSlice {
data: s.as_ptr(),
length: s.len(),
}),
}
}
/// # Safety
/// `input` must be valid for `input_length` bytes. `options` must be a valid pointer
/// whose embedded URL pointers remain valid for the duration of this call. `on_complete`
/// is called exactly once with the parse result.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_url_basic_parse(
input: *const u8,
input_length: usize,
options: *const RustBasicParseOptions,
ctx: *mut c_void,
on_complete: FfiUrlResultFn,
) -> bool {
abort_on_panic(|| {
// SAFETY: caller guarantees input and options are valid.
let input_bytes = unsafe { std::slice::from_raw_parts(input, input_length) };
let input_str = String::from_utf8_lossy(input_bytes);
let options = unsafe { options.as_ref() };
let base_url = options.filter(|o| o.has_base_url).map(|o| url_from_ffi(&o.base_url));
let existing_url = options.filter(|o| o.has_url).map(|o| url_from_ffi(&o.url));
let state_override = options.filter(|o| o.has_state_override).map(|o| o.state_override);
let encoding = options.and_then(|o| {
if o.encoding.data.is_null() {
None
} else {
Some(decode_utf8(o.encoding))
}
});
let mut parse_options = BasicParseOptions::new()
.state_override(state_override)
.encoding(encoding.as_deref());
parse_options.base_url = base_url.as_ref();
let (did_succeed, maybe_url) = if let Some(mut existing_url) = existing_url {
let did_succeed = basic_parse_into(&input_str, &mut existing_url, &parse_options);
(did_succeed, Some(existing_url))
} else if let Some(parsed) = basic_parse(&input_str, parse_options) {
(true, Some(parsed))
} else {
(false, None)
};
let Some(url) = maybe_url else {
// SAFETY: on_complete is a valid function pointer; ctx is caller-provided.
unsafe { on_complete(ctx, std::ptr::null()) };
return false;
};
// Build path slices borrowing from url.path — all live until end of closure.
let path_slices: Vec<RustUrlByteSlice> = url
.path
.iter()
.map(|s: &String| RustUrlByteSlice {
data: s.as_ptr(),
length: s.len(),
})
.collect();
let ffi_result = url_to_ffi_result(&url, &path_slices);
// SAFETY: ffi_result borrows from url and path_slices, both live here.
unsafe { on_complete(ctx, &raw const ffi_result) };
did_succeed
})
}

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#[cfg(feature = "allocator")]
#[path = "../../../RustAllocator.rs"]
mod rust_allocator;
mod ffi;
mod textcodec;
pub mod url;
pub use url::BasicParseOptions;
pub use url::Host;
pub use url::State;
pub use url::Url;
pub use url::basic_parse;
pub use url::basic_parse_into;

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
use std::ffi::c_void;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EncodeItem {
Byte(u8),
Error(u32),
}
type FfiByteFn = unsafe extern "C" fn(*mut c_void, u8);
type FfiCodePointFn = unsafe extern "C" fn(*mut c_void, u32);
unsafe extern "C" {
fn textcodec_rust_encode(
encoding: *const u8,
encoding_length: usize,
input: *const u8,
input_length: usize,
ctx: *mut c_void,
on_byte: FfiByteFn,
on_error: FfiCodePointFn,
) -> bool;
}
struct EncodeCallbacks<'a> {
on_item: &'a mut dyn FnMut(EncodeItem),
}
unsafe extern "C" fn on_encode_byte_with_callbacks(ctx: *mut c_void, byte: u8) {
// SAFETY: `ctx` was set to `addr_of_mut!(callbacks)` in `encode_into`.
let callbacks = unsafe { &mut *(ctx as *mut EncodeCallbacks<'_>) };
(callbacks.on_item)(EncodeItem::Byte(byte));
}
unsafe extern "C" fn on_encode_error_with_callbacks(ctx: *mut c_void, error: u32) {
// SAFETY: `ctx` was set to `addr_of_mut!(callbacks)` in `encode_into`.
let callbacks = unsafe { &mut *(ctx as *mut EncodeCallbacks<'_>) };
(callbacks.on_item)(EncodeItem::Error(error));
}
// https://encoding.spec.whatwg.org/#get-an-output-encoding
pub(crate) fn get_output_encoding(encoding: &str) -> &str {
// 1. If encoding is replacement or UTF-16BE/LE, then return UTF-8.
if encoding.eq_ignore_ascii_case("replacement")
|| encoding.eq_ignore_ascii_case("utf-16le")
|| encoding.eq_ignore_ascii_case("utf-16be")
{
return "UTF-8";
}
// 2. Return encoding.
encoding
}
pub(crate) fn encode_into(encoding: &str, input: &str, mut on_item: impl FnMut(EncodeItem)) -> bool {
let mut callbacks = EncodeCallbacks { on_item: &mut on_item };
// SAFETY: `encoding`, `input`, and `callbacks` are valid for the duration of the call.
unsafe {
textcodec_rust_encode(
encoding.as_ptr(),
encoding.len(),
input.as_ptr(),
input.len(),
std::ptr::addr_of_mut!(callbacks) as *mut c_void,
on_encode_byte_with_callbacks,
on_encode_error_with_callbacks,
)
}
}

View file

@ -0,0 +1,566 @@
/*
* Copyright (c) 2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
use libunicode_rust::idna::ToAsciiOptions;
use libunicode_rust::idna::idna_to_ascii;
use super::Host;
use super::State;
use super::parser::report_validation_error;
use super::percent_encoding::PercentEncodeSet;
use super::percent_encoding::percent_decode;
use super::percent_encoding::percent_encode;
// https://url.spec.whatwg.org/#forbidden-host-code-point
fn is_forbidden_host_code_point(code_point: char) -> bool {
// A forbidden host code point is U+0000 NULL, U+0009 TAB, U+000A LF, U+000D CR, U+0020 SPACE, U+0023 (#), U+002F (/),
// U+003A (:), U+003C (<), U+003E (>), U+003F (?), U+0040 (@), U+005B ([), U+005C (\), U+005D (]), U+005E (^), or U+007C (|).
"\0\t\n\r #/:<>?@[\\]^|".contains(code_point)
}
// https://url.spec.whatwg.org/#forbidden-domain-code-point
pub(super) fn is_forbidden_domain_code_point(code_point: char) -> bool {
// A forbidden domain code point is a forbidden host code point, a C0 control, U+0025 (%), or U+007F DELETE.
is_forbidden_host_code_point(code_point)
|| code_point.is_ascii_control()
|| code_point == '%'
|| code_point == '\u{007F}'
}
// https://url.spec.whatwg.org/#concept-opaque-host-parser
fn parse_opaque_host(input: &str) -> Option<Host> {
// 1. If input contains a forbidden host code point, host-invalid-code-point validation error, return failure.
if input.chars().any(is_forbidden_host_code_point) {
report_validation_error(State::Host, 0, None, "host-invalid-code-point");
return None;
}
// 2. If input contains a code point that is not a URL code point and not U+0025 (%), invalid-URL-unit validation error.
// 3. If input contains a U+0025 (%) and the two code points following it are not ASCII hex digits, invalid-URL-unit validation error.
// NOTE: These steps are not implemented because they are not cheap checks and exist just to report validation errors. With how we
// currently report validation errors, they are only useful for debugging efforts in the URL parsing code.
// 4. Return the result of running UTF-8 percent-encode on input using the C0 control percent-encode set.
Some(Host::Opaque(percent_encode(input, PercentEncodeSet::C0Control, false)))
}
// https://url.spec.whatwg.org/#concept-domain-to-ascii
pub(crate) fn domain_to_ascii(domain: &str, be_strict: bool) -> Option<String> {
// 1. Let result be the result of running Unicode ToASCII with domain_name set to domain,
// CheckHyphens set to beStrict,
// CheckBidi set to true,
// CheckJoiners set to true,
// UseSTD3ASCIIRules set to beStrict,
// Transitional_Processing set to false,
// VerifyDnsLength set to beStrict,
// and IgnoreInvalidPunycode set to false. [UTS46]
//
// NOTE: If beStrict is false, domain is an ASCII string, and strictly splitting domain on U+002E (.) does not
// produce any item that starts with an ASCII case-insensitive match for "xn--", this step is equivalent to
// ASCII lowercasing domain.
// OPTIMIZATION: See spec note above.
if !be_strict && domain.is_ascii() {
// 3. If result is the empty string, domain-to-ASCII validation error, return failure.
if domain.is_empty() {
report_validation_error(State::Host, 0, None, "domain-to-ASCII");
return None;
}
let mut slow_path = false;
for part in domain.split('.') {
if part.len() >= 4 && part[..4].eq_ignore_ascii_case("xn--") {
slow_path = true;
break;
}
}
if !slow_path {
let result = domain.to_ascii_lowercase();
return Some(result);
}
}
// 2. If result is a failure value, domain-to-ASCII validation error, return failure.
let result = idna_to_ascii(
domain,
ToAsciiOptions {
check_hyphens: be_strict,
check_bidi: true,
check_joiners: true,
use_std3_ascii_rules: be_strict,
transitional_processing: false,
verify_dns_length: be_strict,
ignore_invalid_punycode: false,
},
)?;
// 3. If beStrict is false:
if !be_strict {
// 1. If result is the empty string, domain-to-ASCII validation error, return failure.
if result.is_empty() {
report_validation_error(State::Host, 0, None, "domain-to-ASCII");
return None;
}
// 2. If result contains a forbidden domain code point, domain-invalid-code-point validation error, return failure.
// NOTE: Due to web compatibility and compatibility with non-DNS-based systems the forbidden domain code points
// are a subset of those disallowed when UseSTD3ASCIIRules is true. See also issue #397.
if result.chars().any(is_forbidden_domain_code_point) {
report_validation_error(State::Host, 0, None, "domain-invalid-code-point");
return None;
}
}
// 4. Assert: result is not the empty string and does not contain a forbidden domain code point.
// NOTE: Unicode IDNA Compatibility Processing guarantees this holds when beStrict is true. [UTS46]
assert!(!result.is_empty());
assert!(!result.chars().any(is_forbidden_domain_code_point));
// 5. Return result.
// NOTE: This document and the web platform at large use Unicode IDNA Compatibility Processing and not IDNA2008. For
// instance, ☕.example becomes xn--53h.example and not failure. [UTS46] [RFC5890]
Some(result)
}
struct ParsedIpv4Number {
number: u32,
validation_error: bool,
}
// https://url.spec.whatwg.org/#ipv4-number-parser
fn parse_ipv4_number(mut input: &str) -> Option<ParsedIpv4Number> {
// 1. If input is the empty string, then return failure.
if input.is_empty() {
return None;
}
// 2. Let validationError be false.
let mut validation_error = false;
// 3. Let R be 10.
let mut radix = 10;
// 4. If input contains at least two code points and the first two code points are either "0X" or "0x", then:
if input.len() >= 2 && (input.starts_with("0X") || input.starts_with("0x")) {
// 1. Set validationError to true.
validation_error = true;
// 2. Remove the first two code points from input.
input = &input[2..];
// 3. Set R to 16.
radix = 16;
}
// 5. Otherwise, if input contains at least two code points and the first code point is U+0030 (0), then:
else if input.len() >= 2 && input.starts_with('0') {
// 1. Set validationError to true.
validation_error = true;
// 2. Remove the first code point from input.
input = &input[1..];
// 3. Set R to 8.
radix = 8;
}
// 6. If input is the empty string, then return (0, true).
if input.is_empty() {
return Some(ParsedIpv4Number {
number: 0,
validation_error: true,
});
}
// 7. If input contains a code point that is not a radix-R digit, then return failure.
// 8. Let output be the mathematical integer value that is represented by input in radix-R notation, using ASCII hex
// digits for digits with values 0 through 15.
let number = u32::from_str_radix(input, radix).ok()?;
// 9. Return (output, validationError).
Some(ParsedIpv4Number {
number,
validation_error,
})
}
// https://url.spec.whatwg.org/#concept-ipv4-parser
fn parse_ipv4_address(input: &str) -> Option<Ipv4Addr> {
// 1. Let parts be the result of strictly splitting input on U+002E (.).
let mut parts: Vec<&str> = input.split('.').collect();
// 2. If the last item in parts is the empty string, then:
if parts.last().unwrap().is_empty() {
// 1. IPv4-empty-part validation error.
report_validation_error(State::Host, 0, None, "IPv4-empty-part");
// 2. If partss size is greater than 1, then remove the last item from parts.
if parts.len() > 1 {
parts.pop();
}
}
// 3. If partss size is greater than 4, IPv4-too-many-parts validation error, return failure.
if parts.len() > 4 {
report_validation_error(State::Host, 0, None, "IPv4-too-many-parts");
return None;
}
// 4. Let numbers be an empty list.
let mut numbers = Vec::with_capacity(parts.len());
// 5. For each part of parts:
for part in parts {
// 1. Let result be the result of parsing part.
// 2. If result is failure, IPv4-non-numeric-part validation error, return failure.
let result = parse_ipv4_number(part)?;
// 3. If result[1] is true, IPv4-non-decimal-part validation error.
if result.validation_error {
report_validation_error(State::Host, 0, None, "IPv4-non-decimal-part");
}
// 4. Append result[0] to numbers.
numbers.push(result.number);
}
// 6. If any item in numbers is greater than 255, IPv4-out-of-range-part validation error.
// 7. If any but the last item in numbers is greater than 255, then return failure.
for (index, number) in numbers.iter().copied().enumerate() {
if number > 255 {
report_validation_error(State::Host, 0, None, "IPv4-out-of-range-part");
if index != numbers.len() - 1 {
return None;
}
}
}
// 8. If the last item in numbers is greater than or equal to 256(5 numberss size), then return failure.
if u64::from(numbers.last().copied()?) >= 256u64.pow(5 - numbers.len() as u32) {
return None;
}
// 9. Let ipv4 be the last item in numbers.
// 10. Remove the last item from numbers.
let mut ipv4 = numbers.pop().unwrap();
// 11. Let counter be 0.
// 12. For each n of numbers:
for (counter, number) in numbers.into_iter().enumerate() {
// 1. Increment ipv4 by n × 256(3 counter).
ipv4 += number * 256u32.pow(3 - counter as u32);
// 2. Increment counter by 1.
}
// 13. Return ipv4.
Some(Ipv4Addr::from(ipv4))
}
// https://url.spec.whatwg.org/#concept-ipv6-parser
fn parse_ipv6_address(input: &str) -> Option<Ipv6Addr> {
// 1. Let address be a new IPv6 address whose pieces are all 0.
let mut address = [0u16; 8];
// 2. Let pieceIndex be 0.
let mut piece_index = 0usize;
// 3. Let compress be null.
let mut compress = None;
// 4. Let pointer be a pointer for input.
let bytes = input.as_bytes();
let mut pointer = 0usize;
// 5. If c is U+003A (:), then:
if bytes.get(pointer) == Some(&b':') {
// 1. If remaining does not start with U+003A (:), IPv6-invalid-compression validation error, return failure.
if bytes.get(pointer + 1) != Some(&b':') {
report_validation_error(State::Host, 0, None, "IPv6-invalid-compression");
return None;
}
// 2. Increase pointer by 2.
pointer += 2;
// 3. Increase pieceIndex by 1 and then set compress to pieceIndex.
piece_index += 1;
compress = Some(piece_index);
}
// 6. While c is not the EOF code point:
while let Some(&code_point) = bytes.get(pointer) {
// 1. If pieceIndex is 8, IPv6-too-many-pieces validation error, return failure.
if piece_index == 8 {
report_validation_error(State::Host, 0, None, "IPv6-too-many-pieces");
return None;
}
// 2. If c is U+003A (:), then:
if code_point == b':' {
// 1. If compress is non-null, IPv6-multiple-compression validation error, return failure.
if compress.is_some() {
report_validation_error(State::Host, 0, None, "IPv6-multiple-compression");
return None;
}
// 2. Increase pointer and pieceIndex by 1, set compress to pieceIndex, and then continue.
pointer += 1;
piece_index += 1;
compress = Some(piece_index);
continue;
}
// 3. Let value and length be 0.
let mut value = 0u32;
let mut length = 0usize;
// 4. While length is less than 4 and c is an ASCII hex digit, set value to value × 0x10 + c interpreted as
// hexadecimal number, and increase pointer and length by 1.
while length < 4 {
let Some(&code_point) = bytes.get(pointer) else {
break;
};
let Some(digit) = char::from(code_point).to_digit(16) else {
break;
};
value = value * 0x10 + digit;
pointer += 1;
length += 1;
}
// 5. If c is U+002E (.), then:
if bytes.get(pointer) == Some(&b'.') {
// 1. If length is 0, IPv4-in-IPv6-invalid-code-point validation error, return failure.
if length == 0 {
report_validation_error(State::Host, 0, None, "IPv4-in-IPv6-invalid-code-point");
return None;
}
// 2. Decrease pointer by length.
pointer -= length;
// 3. If pieceIndex is greater than 6, IPv4-in-IPv6-too-many-pieces validation error, return failure.
if piece_index > 6 {
report_validation_error(State::Host, 0, None, "IPv4-in-IPv6-too-many-pieces");
return None;
}
// 4. Let numbersSeen be 0.
let mut numbers_seen = 0usize;
// 5. While c is not the EOF code point:
while let Some(&code_point) = bytes.get(pointer) {
// 1. Let ipv4Piece be null.
let mut ipv4_piece = None;
// 2. If numbersSeen is greater than 0, then:
if numbers_seen > 0 {
// 1. If c is a U+002E (.) and numbersSeen is less than 4, then increase pointer by 1.
if code_point == b'.' && numbers_seen < 4 {
pointer += 1;
}
// 2. Otherwise, IPv4-in-IPv6-invalid-code-point validation error, return failure.
else {
report_validation_error(State::Host, 0, None, "IPv4-in-IPv6-invalid-code-point");
return None;
}
}
// 3. If c is not an ASCII digit, IPv4-in-IPv6-invalid-code-point validation error, return failure.
let Some(&code_point) = bytes.get(pointer) else {
break;
};
if !code_point.is_ascii_digit() {
report_validation_error(State::Host, 0, None, "IPv4-in-IPv6-invalid-code-point");
return None;
}
// 4. While c is an ASCII digit:
while let Some(&code_point) = bytes.get(pointer) {
if !code_point.is_ascii_digit() {
break;
}
// 1. Let number be c interpreted as decimal number.
let number = u32::from(code_point - b'0');
// 2. If ipv4Piece is null, then set ipv4Piece to number.
if ipv4_piece.is_none() {
ipv4_piece = Some(number);
}
// 3. Otherwise, if ipv4Piece is 0, IPv4-in-IPv6-invalid-code-point validation error, return failure.
else if ipv4_piece == Some(0) {
report_validation_error(State::Host, 0, None, "IPv4-in-IPv6-invalid-code-point");
return None;
}
// 4. Otherwise, set ipv4Piece to ipv4Piece × 10 + number.
else {
ipv4_piece = Some(ipv4_piece.unwrap() * 10 + number);
}
// 5. If ipv4Piece is greater than 255, IPv4-in-IPv6-out-of-range-part validation error, return failure.
if ipv4_piece.unwrap() > 255 {
report_validation_error(State::Host, 0, None, "IPv4-in-IPv6-out-of-range-part");
return None;
}
// 6. Increase pointer by 1.
pointer += 1;
}
// 5. Set address[pieceIndex] to address[pieceIndex] × 0x100 + ipv4Piece.
address[piece_index] = address[piece_index] * 0x100 + ipv4_piece.unwrap() as u16;
// 6. Increase numbersSeen by 1.
numbers_seen += 1;
// 7. If numbersSeen is 2 or 4, then increase pieceIndex by 1.
if numbers_seen == 2 || numbers_seen == 4 {
piece_index += 1;
}
}
// 6. If numbersSeen is not 4, IPv4-in-IPv6-too-few-parts validation error, return failure.
if numbers_seen != 4 {
report_validation_error(State::Host, 0, None, "IPv4-in-IPv6-too-few-parts");
return None;
}
// 7. Break.
break;
}
// 6. Otherwise, if c is U+003A (:):
else if bytes.get(pointer) == Some(&b':') {
// 1. Increase pointer by 1.
pointer += 1;
// 2. If c is the EOF code point, IPv6-invalid-code-point validation error, return failure.
if bytes.get(pointer).is_none() {
report_validation_error(State::Host, 0, None, "IPv6-invalid-code-point");
return None;
}
}
// 7. Otherwise, if c is not the EOF code point, IPv6-invalid-code-point validation error, return failure.
else if bytes.get(pointer).is_some() {
report_validation_error(State::Host, 0, None, "IPv6-invalid-code-point");
return None;
}
// 8. Set address[pieceIndex] to value.
address[piece_index] = value as u16;
// 9. Increase pieceIndex by 1.
piece_index += 1;
}
// 7. If compress is non-null, then:
if let Some(compress) = compress {
// 1. Let swaps be pieceIndex compress.
let mut swaps = piece_index - compress;
// 2. Set pieceIndex to 7.
piece_index = 7;
// 3. While pieceIndex is not 0 and swaps is greater than 0, swap address[pieceIndex] with
// address[compress + swaps 1], and then decrease both pieceIndex and swaps by 1.
while piece_index != 0 && swaps > 0 {
address.swap(piece_index, compress + swaps - 1);
piece_index -= 1;
swaps -= 1;
}
}
// 8. Otherwise, if compress is null and pieceIndex is not 8, IPv6-too-few-pieces validation error, return failure.
else if piece_index != 8 {
report_validation_error(State::Host, 0, None, "IPv6-too-few-pieces");
return None;
}
// 9. Return address.
Some(Ipv6Addr::from(address))
}
// https://url.spec.whatwg.org/#ends-in-a-number-checker
fn ends_in_a_number_checker(input: &str) -> bool {
// 1. Let parts be the result of strictly splitting input on U+002E (.).
let mut parts: Vec<&str> = input.split('.').collect();
// 2. If the last item in parts is the empty string, then:
if parts.last().unwrap().is_empty() {
// 1. If partss size is 1, then return false.
if parts.len() == 1 {
return false;
}
// 2. Remove the last item from parts.
parts.pop();
}
// 3. Let last be the last item in parts.
let last = parts.last().unwrap();
// 4. If last is non-empty and contains only ASCII digits, then return true.
// NOTE: The erroneous input "09" will be caught by the IPv4 parser at a later stage.
if !last.is_empty() && last.chars().all(|code_point| code_point.is_ascii_digit()) {
return true;
}
// 5. If parsing last as an IPv4 number does not return failure, then return true.
// NOTE: This is equivalent to checking that last is "0X" or "0x", followed by zero or more ASCII hex digits.
// 6. Return false.
last.len() >= 2
&& last[..2].eq_ignore_ascii_case("0x")
&& last[2..].chars().all(|code_point| code_point.is_ascii_hexdigit())
}
// https://url.spec.whatwg.org/#concept-host-parser
pub(super) fn parse_host(input: &str, is_opaque: bool) -> Option<Host> {
// 1. If input starts with U+005B ([), then:
if input.starts_with('[') {
// 1. If input does not end with U+005D (]), IPv6-unclosed validation error, return failure.
if !input.ends_with(']') {
report_validation_error(State::Host, 0, None, "IPv6-unclosed");
return None;
}
// 2. Return the result of IPv6 parsing input with its leading U+005B ([) and trailing U+005D (]) removed.
let address = parse_ipv6_address(&input[1..input.len() - 1])?;
return Some(Host::Ipv6(address));
}
// 2. If isOpaque is true, then return the result of opaque-host parsing input.
if is_opaque {
return parse_opaque_host(input);
}
// 3. Assert: input is not the empty string.
assert!(!input.is_empty());
// 4. Let domain be the result of running UTF-8 decode without BOM on the percent-decoding of input.
let domain = String::from_utf8_lossy(&percent_decode(input)).into_owned();
// 5. Let asciiDomain be the result of running domain to ASCII with domain and false.
// 6. If asciiDomain is failure, then return failure.
let ascii_domain = domain_to_ascii(&domain, false)?;
// 7. If asciiDomain contains a forbidden domain code point, domain-invalid-code-point validation error, return failure.
if ascii_domain.chars().any(is_forbidden_domain_code_point) {
report_validation_error(State::Host, 0, None, "domain-invalid-code-point");
return None;
}
// 8. If asciiDomain ends in a number, then return the result of IPv4 parsing asciiDomain.
if ends_in_a_number_checker(&ascii_domain) {
return parse_ipv4_address(&ascii_domain).map(Host::Ipv4);
}
// 9. Return asciiDomain.
Some(Host::Domain(ascii_domain))
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
mod host;
mod parser;
mod percent_encoding;
mod scheme;
#[allow(dead_code)]
mod serialize;
mod types;
pub(crate) use self::scheme::default_port_for_scheme;
pub(crate) use self::scheme::is_special_scheme;
pub use self::types::Host;
pub use self::types::State;
pub use self::types::Url;
#[derive(Debug, Default)]
pub struct BasicParseOptions<'a> {
pub base_url: Option<&'a Url>,
state_override: Option<State>,
encoding: Option<&'a str>,
}
impl<'a> BasicParseOptions<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn base_url(mut self, base_url: &'a Url) -> Self {
self.base_url = Some(base_url);
self
}
pub fn state_override(mut self, state: impl Into<Option<State>>) -> Self {
self.state_override = state.into();
self
}
pub fn encoding(mut self, encoding: impl Into<Option<&'a str>>) -> Self {
self.encoding = encoding.into();
self
}
}
pub fn basic_parse(input: &str, options: BasicParseOptions<'_>) -> Option<Url> {
let mut url = Url::default();
if parser::basic_parse_into(input, &mut url, &options, false) {
Some(url)
} else {
None
}
}
pub fn basic_parse_into(input: &str, url: &mut Url, options: &BasicParseOptions<'_>) -> bool {
parser::basic_parse_into(input, url, options, true)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,195 @@
/*
* Copyright (c) 2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
use crate::textcodec::EncodeItem;
use crate::textcodec::encode_into as textcodec_encode_into;
#[allow(dead_code)]
#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum PercentEncodeSet {
C0Control,
Fragment,
Query,
SpecialQuery,
Path,
Userinfo,
Component,
ApplicationXWWWFormUrlencoded,
}
fn code_point_is_in_percent_encode_set(code_point: char, set: PercentEncodeSet) -> bool {
let code_point_u32 = code_point as u32;
match set {
// https://url.spec.whatwg.org/#c0-control-percent-encode-set
// The C0 control percent-encode set are the C0 controls and all code points greater than U+007E (~).
PercentEncodeSet::C0Control => !(0x20..=0x7e).contains(&code_point_u32),
// https://url.spec.whatwg.org/#fragment-percent-encode-set
// The query percent-encode set is the C0 control percent-encode set and U+0020 SPACE, U+0022 ("), U+0023 (#), U+003C (<), and U+003E (>).
PercentEncodeSet::Fragment => {
code_point_is_in_percent_encode_set(code_point, PercentEncodeSet::C0Control)
|| [' ', '"', '<', '>', '`'].contains(&code_point)
}
// https://url.spec.whatwg.org/#query-percent-encode-set
// The query percent-encode set is the C0 control percent-encode set and U+0020 SPACE, U+0022 ("), U+0023 (#), U+003C (<), and U+003E (>).
// NOTE: The query percent-encode set cannot be defined in terms of the fragment percent-encode set due to the omission of U+0060 (`).
PercentEncodeSet::Query => {
code_point_is_in_percent_encode_set(code_point, PercentEncodeSet::C0Control)
|| [' ', '"', '#', '<', '>'].contains(&code_point)
}
// https://url.spec.whatwg.org/#special-query-percent-encode-set
// The special-query percent-encode set is the query percent-encode set and U+0027 (').
PercentEncodeSet::SpecialQuery => {
code_point_is_in_percent_encode_set(code_point, PercentEncodeSet::Query) || code_point == '\''
}
// https://url.spec.whatwg.org/#path-percent-encode-set
// The path percent-encode set is the query percent-encode set and U+003F (?), U+005E (^), U+0060 (`), U+007B ({), and U+007D (}).
PercentEncodeSet::Path => {
code_point_is_in_percent_encode_set(code_point, PercentEncodeSet::Query)
|| ['?', '^', '`', '{', '}'].contains(&code_point)
}
// https://url.spec.whatwg.org/#userinfo-percent-encode-set
// The userinfo percent-encode set is the path percent-encode set and U+002F (/), U+003A (:), U+003B (;),
// U+003D (=), U+0040 (@), U+005B ([) to U+005D (]), inclusive, and U+007C (|).
PercentEncodeSet::Userinfo => {
code_point_is_in_percent_encode_set(code_point, PercentEncodeSet::Path)
|| ['/', ':', ';', '=', '@', '[', '\\', ']', '|'].contains(&code_point)
}
// https://url.spec.whatwg.org/#component-percent-encode-set
// The component percent-encode set is the userinfo percent-encode set and U+0024 ($) to U+0026 (&), inclusive, U+002B (+), and U+002C (,).
// NOTE: This is used by HTML for registerProtocolHandler(), and could also be used by other standards to
// percent-encode data that can then be embedded in a URLs path, query, or fragment; or in an opaque host.
// Using it with UTF-8 percent-encode gives identical results to JavaScripts encodeURIComponent() [sic]. [HTML] [ECMA-262]
PercentEncodeSet::Component => {
code_point_is_in_percent_encode_set(code_point, PercentEncodeSet::Userinfo)
|| ['$', '%', '&', '+', ','].contains(&code_point)
}
// https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set
// The application/x-www-form-urlencoded percent-encode set is the component percent-encode set and U+0021 (!),
// U+0027 (') to U+0029 RIGHT PARENTHESIS, inclusive, and U+007E (~).
PercentEncodeSet::ApplicationXWWWFormUrlencoded => {
code_point_is_in_percent_encode_set(code_point, PercentEncodeSet::Component)
|| ['!', '\'', '(', ')', '~'].contains(&code_point)
}
}
}
fn append_percent_encoded(builder: &mut String, code_point: char) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut utf8 = [0u8; 4];
for byte in code_point.encode_utf8(&mut utf8).as_bytes() {
builder.push('%');
builder.push(HEX[(byte >> 4) as usize] as char);
builder.push(HEX[(byte & 0x0f) as usize] as char);
}
}
fn append_percent_encoded_byte(builder: &mut String, byte: u8) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
builder.push('%');
builder.push(HEX[(byte >> 4) as usize] as char);
builder.push(HEX[(byte & 0x0f) as usize] as char);
}
pub(super) fn percent_encode(input: &str, set: PercentEncodeSet, space_as_plus: bool) -> String {
let mut output = String::new();
for code_point in input.chars() {
if space_as_plus && code_point == ' ' {
output.push('+');
} else if code_point_is_in_percent_encode_set(code_point, set) {
append_percent_encoded(&mut output, code_point);
} else {
output.push(code_point);
}
}
output
}
pub(super) fn percent_decode(input: &str) -> Vec<u8> {
fn decode_hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
let bytes = input.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%'
&& index + 2 < bytes.len()
&& let (Some(high), Some(low)) = (decode_hex(bytes[index + 1]), decode_hex(bytes[index + 2]))
{
output.push((high << 4) | low);
index += 3;
continue;
}
output.push(bytes[index]);
index += 1;
}
output
}
// https://url.spec.whatwg.org/#string-percent-encode-after-encoding
pub(super) fn percent_encode_after_encoding(
encoding: &str,
input: &str,
set: PercentEncodeSet,
space_as_plus: bool,
) -> String {
// 1. Let encoder be the result of getting an encoder from encoding.
// 2. Let inputQueue be input converted to an I/O queue.
// 3. Let output be the empty string.
let mut result = String::new();
let did_succeed = textcodec_encode_into(encoding, input, |item| match item {
EncodeItem::Byte(byte) => {
// 1. If spaceAsPlus is true and byte is 0x20 (SP), then append U+002B (+) to output and continue.
if space_as_plus && byte == b' ' {
result.push('+');
return;
}
// 2. Let isomorph be a code point whose value is bytes value.
let code_point = char::from(byte);
// 4. If isomorphic is not in percentEncodeSet, then append isomorph to output.
if !code_point_is_in_percent_encode_set(code_point, set) {
result.push(code_point);
} else {
append_percent_encoded_byte(&mut result, byte);
}
}
EncodeItem::Error(error) => {
result.push_str("%26%23");
result.push_str(&error.to_string());
result.push_str("%3B");
}
});
assert!(
did_succeed,
"TextCodec::encode should succeed for a valid output encoding"
);
// 6. Return output.
result
}
pub(super) fn append_percent_encoded_if_necessary(buffer: &mut String, code_point: char, set: PercentEncodeSet) {
if code_point_is_in_percent_encode_set(code_point, set) {
append_percent_encoded(buffer, code_point);
} else {
buffer.push(code_point);
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
// https://url.spec.whatwg.org/#special-scheme
pub(crate) fn special_schemes() -> &'static [&'static str] {
&["ftp", "file", "http", "https", "ws", "wss"]
}
// https://url.spec.whatwg.org/#is-special
pub(crate) fn is_special_scheme(scheme: &[u8]) -> bool {
special_schemes()
.iter()
.any(|special_scheme| scheme == special_scheme.as_bytes())
}
// https://url.spec.whatwg.org/#default-port
pub(crate) fn default_port_for_scheme(scheme: &str) -> Option<u16> {
match scheme {
"ftp" => Some(21),
"http" => Some(80),
"https" => Some(443),
"ws" => Some(80),
"wss" => Some(443),
_ => None,
}
}

View file

@ -0,0 +1,228 @@
/*
* Copyright (c) 2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
use super::parser::url_includes_credentials;
use super::types::ExcludeFragment;
use super::types::Host;
use super::types::Url;
// https://url.spec.whatwg.org/#concept-ipv4-serializer
fn serialize_ipv4_address(address: Ipv4Addr) -> String {
address.to_string()
}
// https://url.spec.whatwg.org/#find-the-ipv6-address-compressed-piece-index
fn find_the_ipv6_address_compressed_piece_index(address: Ipv6Addr) -> Option<usize> {
let address = address.segments();
// 1. Let longestIndex be null.
let mut longest_index = None;
// 2. Let longestSize be 1.
let mut longest_size = 1;
// 3. Let foundIndex be null.
let mut found_index = None;
// 4. Let foundSize be 0.
let mut found_size = 0;
// 5. For each pieceIndex of addresss piecess indices:
for (piece_index, piece) in address.iter().enumerate() {
// 1. If addresss pieces[pieceIndex] is not 0:
if *piece != 0 {
// 1. If foundSize is greater than longestSize, then set longestIndex to foundIndex and longestSize to foundSize.
if found_size > longest_size {
longest_index = found_index;
longest_size = found_size;
}
// 2. Set foundIndex to null.
found_index = None;
// 3. Set foundSize to 0.
found_size = 0;
}
// 2. Otherwise:
else {
// 1. If foundIndex is null, then set foundIndex to pieceIndex.
if found_index.is_none() {
found_index = Some(piece_index);
}
// 2. Increment foundSize by 1.
found_size += 1;
}
}
// 6. If foundSize is greater than longestSize, then return foundIndex.
if found_size > longest_size {
return found_index;
}
// 7. Return longestIndex.
longest_index
}
// https://url.spec.whatwg.org/#concept-ipv6-serializer
fn serialize_ipv6_address(address: Ipv6Addr, output: &mut String) {
let address = address.segments();
// 1. Let output be the empty string.
// 2. Let compress be the result of finding the IPv6 address compressed piece index given address.
let compress = find_the_ipv6_address_compressed_piece_index(Ipv6Addr::from(address));
// 3. Let ignore0 be false.
let mut ignore0 = false;
// 4. For each pieceIndex of addresss piecess indices:
for (piece_index, piece) in address.iter().enumerate() {
// 1. If ignore0 is true and address[pieceIndex] is 0, then continue.
if ignore0 && *piece == 0 {
continue;
}
// 2. Otherwise, if ignore0 is true, set ignore0 to false.
if ignore0 {
ignore0 = false;
}
// 3. If compress is pieceIndex, then:
if compress == Some(piece_index) {
// 1. Let separator be "::" if pieceIndex is 0, and U+003A (:) otherwise.
let separator = if piece_index == 0 { "::" } else { ":" };
// 2. Append separator to output.
output.push_str(separator);
// 3. Set ignore0 to true and continue.
ignore0 = true;
continue;
}
// 4. Append address[pieceIndex], represented as the shortest possible lowercase hexadecimal number, to output.
output.push_str(&format!("{piece:x}"));
// 5. If pieceIndex is not 7, then append U+003A (:) to output.
if piece_index != 7 {
output.push(':');
}
}
// 5. Return output.
}
impl Host {
// https://url.spec.whatwg.org/#concept-host-serializer
pub(crate) fn serialize(&self) -> String {
match self {
// 1. If host is an IPv4 address, return the result of running the IPv4 serializer on host.
Self::Ipv4(address) => serialize_ipv4_address(*address),
// 2. Otherwise, if host is an IPv6 address, return U+005B ([), followed by the result of running the
// IPv6 serializer on host, followed by U+005D (]).
Self::Ipv6(address) => {
let mut output = String::new();
output.push('[');
serialize_ipv6_address(*address, &mut output);
output.push(']');
output
}
// 3. Otherwise, host is a domain, opaque host, or empty host, return host.
Self::Domain(string) | Self::Opaque(string) => string.clone(),
}
}
}
impl Url {
// https://url.spec.whatwg.org/#url-path-serializer
pub(crate) fn serialize_path(&self) -> String {
// 1. If url has an opaque path, then return url's path.
if self.has_opaque_path {
return self.path[0].clone();
}
// 2. Let output be the empty string.
let mut output = String::new();
// 3. For each segment of url's path: append U+002F (/) followed by segment to output.
for segment in &self.path {
output.push('/');
output.push_str(segment);
}
// 4. Return output.
output
}
// https://url.spec.whatwg.org/#concept-url-serializer
pub(crate) fn serialize(&self, exclude_fragment: ExcludeFragment) -> String {
// 1. Let output be url's scheme and U+003A (:) concatenated.
let mut output = String::new();
output.push_str(&self.scheme);
output.push(':');
// 2. If url's host is non-null:
if let Some(host) = self.host.as_ref() {
// 1. Append "//" to output.
output.push_str("//");
// 2. If url includes credentials, then:
if url_includes_credentials(self) {
// 1. Append url's username to output.
output.push_str(&self.username);
// 2. If url's password is not the empty string, then append U+003A (:), followed by url's password, to output.
if !self.password.is_empty() {
output.push(':');
output.push_str(&self.password);
}
// 3. Append U+0040 (@) to output.
output.push('@');
}
// 3. Append url's host, serialized, to output.
output.push_str(&host.serialize());
// 4. If url's port is non-null, append U+003A (:) followed by url's port, serialized, to output.
if let Some(port) = self.port {
output.push(':');
output.push_str(&port.to_string());
}
}
// 3. If url's host is null, url does not have an opaque path, url's path's size is greater than 1, and url's
// path[0] is the empty string, then append U+002F (/) followed by U+002E (.) to output.
if self.host.is_none() && !self.has_opaque_path && self.path.len() > 1 && self.path[0].is_empty() {
output.push_str("/.");
}
// 4. Append the result of URL path serializing url to output.
output.push_str(&self.serialize_path());
// 5. If url's query is non-null, append U+003F (?), followed by url's query, to output.
if let Some(query) = &self.query {
output.push('?');
output.push_str(query);
}
// 6. If exclude fragment is false and url's fragment is non-null, then append U+0023 (#), followed by url's
// fragment, to output.
if exclude_fragment == ExcludeFragment::No
&& let Some(fragment) = &self.fragment
{
output.push('#');
output.push_str(fragment);
}
// 7. Return output.
output
}
}

View file

@ -0,0 +1,147 @@
/*
* Copyright (c) 2026, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
use super::percent_encoding::PercentEncodeSet;
use super::percent_encoding::percent_encode;
use super::scheme::is_special_scheme;
#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum State {
SchemeStart,
Scheme,
NoScheme,
SpecialRelativeOrAuthority,
PathOrAuthority,
Relative,
RelativeSlash,
SpecialAuthoritySlashes,
SpecialAuthorityIgnoreSlashes,
Authority,
Host,
Hostname,
Port,
File,
FileSlash,
FileHost,
PathStart,
Path,
OpaquePath,
Query,
Fragment,
}
#[allow(dead_code)]
#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ExcludeFragment {
No,
Yes,
}
#[allow(dead_code)]
impl Url {
pub(crate) fn set_scheme(&mut self, scheme: String) {
self.scheme = scheme;
}
// https://url.spec.whatwg.org/#set-the-username
pub(crate) fn set_username(&mut self, username: &str) {
// To set the username given a url and username, set urls username to the result of running UTF-8 percent-encode
// on username using the userinfo percent-encode set.
self.username = percent_encode(username, PercentEncodeSet::Userinfo, false);
}
// https://url.spec.whatwg.org/#set-the-password
pub(crate) fn set_password(&mut self, password: &str) {
// To set the password given a url and password, set urls password to the result of running UTF-8 percent-encode
// on password using the userinfo percent-encode set.
self.password = percent_encode(password, PercentEncodeSet::Userinfo, false);
}
pub(crate) fn serialized_host(&self) -> String {
self.host.as_ref().expect("host should be present").serialize()
}
pub(crate) fn set_paths(&mut self, paths: &[&str]) {
self.path.clear();
self.path.reserve(paths.len());
for segment in paths {
self.path.push(percent_encode(segment, PercentEncodeSet::Path, false));
}
}
pub(crate) fn set_query(&mut self, query: Option<String>) {
self.query = query;
}
pub(crate) fn set_fragment(&mut self, fragment: Option<String>) {
self.fragment = fragment;
}
pub(crate) fn set_has_an_opaque_path(&mut self, value: bool) {
self.has_opaque_path = value;
}
// https://url.spec.whatwg.org/#is-special
pub(crate) fn is_special(&self) -> bool {
is_special_scheme(self.scheme.as_bytes())
}
}
// https://url.spec.whatwg.org/#concept-host
// A host is a domain, an IP address, an opaque host, or an empty host. Typically a host serves as a network address,
// but it is sometimes used as opaque identifier in URLs where a network address is not necessary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Host {
Domain(String),
Ipv4(Ipv4Addr),
Ipv6(Ipv6Addr),
Opaque(String),
}
impl Host {
pub(crate) fn is_empty_host(&self) -> bool {
matches!(self, Self::Domain(host) if host.is_empty())
}
}
// https://url.spec.whatwg.org/#url-representation
// A URL is a struct that represents a universal identifier.
// To disambiguate from a valid URL string it can also be referred to as a URL record.
#[derive(Clone, Debug, Default)]
pub struct Url {
// A URLs scheme is an ASCII string that identifies the type of URL and can be used to dispatch a URL for further
// processing after parsing. It is initially the empty string.
pub(crate) scheme: String,
// A URLs username is an ASCII string identifying a username. It is initially the empty string.
pub(crate) username: String,
// A URLs password is an ASCII string identifying a password. It is initially the empty string.
pub(crate) password: String,
// A URLs host is null or a host. It is initially null.
pub(crate) host: Option<Host>,
// A URLs port is either null or a 16-bit unsigned integer that identifies a networking port. It is initially null.
pub(crate) port: Option<u16>,
// A URLs path is either a URL path segment or a list of zero or more URL path segments, usually identifying a location. It is initially « ».
// A URL path segment is an ASCII string. It commonly refers to a directory or a file, but has no predefined meaning.
pub(crate) path: Vec<String>,
pub(crate) has_opaque_path: bool,
// A URLs query is either null or an ASCII string. It is initially null.
pub(crate) query: Option<String>,
// A URLs fragment is either null or an ASCII string that can be used for further processing on the resource the
// URLs other components identify. It is initially null.
pub(crate) fragment: Option<String>,
}

View file

@ -0,0 +1,235 @@
/*
* Copyright (c) 2026, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Array.h>
#include <AK/IPv4Address.h>
#include <AK/IPv6Address.h>
#include <AK/StringUtils.h>
#include <AK/Utf8View.h>
#include <AK/Vector.h>
#include <LibTextCodec/Encoder.h>
#include <LibURL/Parser.h>
#include <LibURL/RustFFI.h>
#include <LibURL/RustIntegration.h>
#include <LibURL/URL.h>
namespace URL::RustIntegration {
static String string_from_ffi(FFI::RustUrlByteSlice slice)
{
return String::from_ascii_without_validation({ reinterpret_cast<char const*>(slice.data), slice.length });
}
static FFI::FfiUrlHost host_to_ffi(Optional<Host> const& host)
{
FFI::FfiUrlHost result {};
if (!host.has_value()) {
result.has_host = false;
return result;
}
result.has_host = true;
host->value().visit(
[&](IPv4Address const& addr) {
result.kind = FFI::RustUrlHostKind::Ipv4;
u32 const n = addr.to_u32();
result.ipv4[0] = static_cast<u8>(n >> 24);
result.ipv4[1] = static_cast<u8>(n >> 16);
result.ipv4[2] = static_cast<u8>(n >> 8);
result.ipv4[3] = static_cast<u8>(n);
},
[&](IPv6Address const& addr) {
result.kind = FFI::RustUrlHostKind::Ipv6;
for (size_t i = 0; i < 8; i++) {
u16 piece = addr[i];
result.ipv6[i * 2] = static_cast<u8>(piece >> 8);
result.ipv6[(i * 2) + 1] = static_cast<u8>(piece & 0xff);
}
},
[&](String const& str) {
result.kind = FFI::RustUrlHostKind::String;
result.string_data = reinterpret_cast<uint8_t const*>(str.bytes().data());
result.string_length = str.bytes().size();
});
return result;
}
static Optional<URL> url_from_ffi(FFI::RustFfiUrl const& ffi)
{
URL url;
url.set_scheme(string_from_ffi(ffi.scheme));
url.set_username(string_from_ffi(ffi.username));
url.set_password(string_from_ffi(ffi.password));
if (ffi.host.has_host) {
switch (ffi.host.kind) {
case FFI::RustUrlHostKind::String:
url.set_host(Host(string_from_ffi({ ffi.host.string_data, ffi.host.string_length })));
break;
case FFI::RustUrlHostKind::Ipv4: {
u32 const n = (static_cast<u32>(ffi.host.ipv4[0]) << 24)
| (static_cast<u32>(ffi.host.ipv4[1]) << 16)
| (static_cast<u32>(ffi.host.ipv4[2]) << 8)
| static_cast<u32>(ffi.host.ipv4[3]);
url.set_host(Host(IPv4Address(NetworkOrdered<u32>(n))));
break;
}
case FFI::RustUrlHostKind::Ipv6: {
Array<u16, 8> pieces;
for (size_t i = 0; i < 8; i++)
pieces[i] = (static_cast<u16>(ffi.host.ipv6[i * 2]) << 8) | ffi.host.ipv6[(i * 2) + 1];
url.set_host(Host(IPv6Address(pieces)));
break;
}
}
}
if (ffi.has_port)
url.set_port(ffi.port);
else
url.set_port({});
url.set_has_an_opaque_path(ffi.has_opaque_path);
Vector<String> paths;
if (ffi.path_segments) {
paths.ensure_capacity(ffi.path_segment_count);
for (size_t i = 0; i < ffi.path_segment_count; i++)
paths.unchecked_append(string_from_ffi(ffi.path_segments[i]));
}
url.set_raw_paths(move(paths));
if (ffi.has_query)
url.set_query(string_from_ffi(ffi.query));
else
url.set_query({});
if (ffi.has_fragment)
url.set_fragment(string_from_ffi(ffi.fragment));
else
url.set_fragment({});
return url;
}
struct UrlFfiStorage {
Vector<FFI::RustUrlByteSlice> path_segments;
FFI::RustFfiUrl ffi_url {};
};
static UrlFfiStorage url_to_ffi(URL const& url)
{
UrlFfiStorage storage;
storage.ffi_url.scheme = { reinterpret_cast<uint8_t const*>(url.scheme().bytes().data()), url.scheme().bytes().size() };
storage.ffi_url.username = { reinterpret_cast<uint8_t const*>(url.username().bytes().data()), url.username().bytes().size() };
storage.ffi_url.password = { reinterpret_cast<uint8_t const*>(url.password().bytes().data()), url.password().bytes().size() };
storage.ffi_url.host = host_to_ffi(url.host());
storage.ffi_url.has_port = url.port().has_value();
storage.ffi_url.port = url.port().value_or(0);
storage.path_segments.ensure_capacity(url.paths().size());
for (auto const& segment : url.paths()) {
storage.path_segments.unchecked_append({
reinterpret_cast<uint8_t const*>(segment.bytes().data()),
segment.bytes().size(),
});
}
storage.ffi_url.path_segments = storage.path_segments.data();
storage.ffi_url.path_segment_count = storage.path_segments.size();
storage.ffi_url.has_opaque_path = url.has_an_opaque_path();
storage.ffi_url.has_query = url.query().has_value();
if (url.query().has_value())
storage.ffi_url.query = { reinterpret_cast<uint8_t const*>(url.query()->bytes().data()), url.query()->bytes().size() };
storage.ffi_url.has_fragment = url.fragment().has_value();
if (url.fragment().has_value())
storage.ffi_url.fragment = { reinterpret_cast<uint8_t const*>(url.fragment()->bytes().data()), url.fragment()->bytes().size() };
return storage;
}
struct ParseCallbackCtx {
Optional<URL>* result;
URL* url_inout;
};
static void on_basic_parse_complete(void* ctx_ptr, FFI::RustFfiUrl const* ffi_result)
{
auto* ctx = static_cast<ParseCallbackCtx*>(ctx_ptr);
if (!ffi_result)
return;
*ctx->result = url_from_ffi(*ffi_result);
if (ctx->url_inout && ctx->result->has_value())
*ctx->url_inout = **ctx->result;
}
Optional<URL> parse_basic_url(StringView input, Optional<URL const&> base_url, URL* url, Optional<Parser::State> state_override, Optional<StringView> encoding)
{
auto const state_override_from_cpp = [](Parser::State state) {
return static_cast<FFI::State>(to_underlying(state));
};
Optional<UrlFfiStorage> base_storage;
if (base_url.has_value())
base_storage = url_to_ffi(*base_url);
Optional<UrlFfiStorage> url_storage;
if (url)
url_storage = url_to_ffi(*url);
FFI::RustBasicParseOptions options {
.has_base_url = base_url.has_value(),
.has_url = url != nullptr,
.base_url = base_storage.has_value() ? base_storage->ffi_url : FFI::RustFfiUrl {},
.url = url_storage.has_value() ? url_storage->ffi_url : FFI::RustFfiUrl {},
.has_state_override = state_override.has_value(),
.state_override = state_override.map(state_override_from_cpp).value_or(FFI::State::SchemeStart),
.encoding = {
reinterpret_cast<uint8_t const*>(encoding.has_value() ? encoding->characters_without_null_termination() : nullptr),
encoding.value_or(""sv).length(),
},
};
Optional<URL> result;
ParseCallbackCtx ctx { .result = &result, .url_inout = url };
bool const did_succeed = rust_url_basic_parse(
reinterpret_cast<uint8_t const*>(input.characters_without_null_termination()),
input.length(),
&options,
&ctx,
on_basic_parse_complete);
if (!did_succeed)
return {};
return result;
}
}
namespace URL::FFI {
extern "C" bool textcodec_rust_encode(uint8_t const* encoding, size_t encoding_length, uint8_t const* input, size_t input_length, void* ctx, FfiByteFn on_byte, FfiCodePointFn on_error)
{
auto encoder = TextCodec::encoder_for(StringView { encoding, encoding_length });
if (!encoder.has_value())
return false;
auto result = encoder->process(
Utf8View { StringView { input, input_length } },
[&](u8 byte) -> ErrorOr<void> {
on_byte(ctx, byte);
return {};
},
[&](u32 error) -> ErrorOr<void> {
on_error(ctx, error);
return {};
});
return !result.is_error();
}
}

View file

@ -0,0 +1,17 @@
/*
* Copyright (c) 2026, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Optional.h>
#include <AK/StringView.h>
#include <LibURL/URL.h>
namespace URL::RustIntegration {
Optional<URL> parse_basic_url(StringView input, Optional<URL const&> base_url = {}, URL* url = nullptr, Optional<Parser::State> state_override = {}, Optional<StringView> encoding = {});
}