use crate::courierust_bytes::Bytes;
use crate::courierust_error::{Error, Result};
use alloc::string::String;
use alloc::string::ToString;
use core::fmt;
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct PathAndQuery(Bytes);
impl PathAndQuery {
pub fn from_bytes(b: &[u8]) -> Result<Self> {
if b.is_empty() {
return Err(Error::protocol("empty request target"));
}
if b == b"*" || b[0] == b'/' || b[0] == b'?' {
if b.iter().any(|&c| c < 0x20 || c == 0x7f) {
return Err(Error::protocol("control character in request target"));
}
return Ok(Self(Bytes::from(b)));
}
let s = core::str::from_utf8(b).map_err(|_| Error::protocol("non-UTF8 absolute target"))?;
if b.iter().any(|&c| c < 0x20 || c == 0x7f) {
return Err(Error::protocol("control character in request target"));
}
let (scheme, rest) = s
.split_once("://")
.ok_or_else(|| Error::protocol("invalid request target form"))?;
if scheme.is_empty() || rest.is_empty() {
return Err(Error::protocol("invalid absolute target"));
}
Ok(Self(Bytes::from(b)))
}
#[inline]
pub fn from_static(s: &'static str) -> Self {
Self(Bytes::from_static(s.as_bytes()))
}
pub fn path(&self) -> &str {
let s = self.as_str();
s.split('?').next().unwrap_or(s)
}
pub fn query(&self) -> Option<&str> {
let s = self.as_str();
s.split_once('?').map(|(_, q)| q)
}
pub fn as_str(&self) -> &str {
match core::str::from_utf8(self.0.as_slice()) {
Ok(s) => s,
Err(_) => "<non-utf8 target>",
}
}
#[inline]
pub fn as_bytes(&self) -> &[u8] {
self.0.as_slice()
}
}
impl fmt::Display for PathAndQuery {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl fmt::Debug for PathAndQuery {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "PathAndQuery({})", self.as_str())
}
}
impl From<&'static str> for PathAndQuery {
fn from(s: &'static str) -> Self {
Self::from_static(s)
}
}
impl core::str::FromStr for PathAndQuery {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::from_bytes(s.as_bytes())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Url {
pub scheme: String,
pub host: String,
pub port: u16,
pub path_and_query: PathAndQuery,
pub userinfo: Option<String>,
}
fn contains_forbidden_authority_byte(value: &str) -> bool {
value
.bytes()
.any(|byte| byte <= b' ' || byte == 0x7f || matches!(byte, b'/' | b'?' | b'#' | b'@'))
}
fn parse_port(value: &str) -> Result<u16> {
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(Error::protocol(
"URL port must be a non-zero decimal number",
));
}
let port = value
.parse::<u16>()
.map_err(|_| Error::protocol("URL port is out of range"))?;
if port == 0 {
return Err(Error::protocol("URL port must not be zero"));
}
Ok(port)
}
fn parse_host_and_port(authority: &str, default_port: u16) -> Result<(String, u16)> {
if authority.is_empty() {
return Err(Error::protocol("URL missing host"));
}
if let Some(bracketed) = authority.strip_prefix('[') {
let closing = bracketed
.find(']')
.ok_or_else(|| Error::protocol("URL IPv6 host is missing closing bracket"))?;
let host = &bracketed[..closing];
let suffix = &bracketed[closing + 1..];
if host.is_empty() || contains_forbidden_authority_byte(host) {
return Err(Error::protocol("invalid URL host"));
}
let port = if suffix.is_empty() {
default_port
} else if let Some(value) = suffix.strip_prefix(':') {
parse_port(value)?
} else {
return Err(Error::protocol("invalid URL authority after IPv6 host"));
};
return Ok((host.to_ascii_lowercase(), port));
}
if authority.contains(['[', ']']) {
return Err(Error::protocol("invalid URL host brackets"));
}
let (host, port) = match authority.rsplit_once(':') {
Some((host, port)) => {
if host.contains(':') {
return Err(Error::protocol(
"URL IPv6 host must be enclosed in brackets",
));
}
(host, parse_port(port)?)
}
None => (authority, default_port),
};
if host.is_empty() || contains_forbidden_authority_byte(host) {
return Err(Error::protocol("invalid URL host"));
}
Ok((host.to_ascii_lowercase(), port))
}
impl Url {
pub fn parse(s: &str) -> Result<Self> {
let (scheme, rest) = s
.split_once("://")
.ok_or_else(|| Error::protocol("URL missing scheme://"))?;
let scheme = scheme.to_ascii_lowercase();
if scheme != "http" && scheme != "https" {
return Err(Error::protocol(format!("unsupported scheme: {scheme}")));
}
let rest = rest.split_once('#').map_or(rest, |(before, _)| before);
let (authority, pathq) = match rest.find(['/', '?']) {
Some(index) => (&rest[..index], &rest[index..]),
None => (rest, ""),
};
let (userinfo, hostport) = match authority.rsplit_once('@') {
Some((u, hp)) => (Some(u.to_string()), hp),
None => (None, authority),
};
let default_port = if scheme == "https" { 443 } else { 80 };
let (host, port) = parse_host_and_port(hostport, default_port)?;
let path_and_query = if pathq.is_empty() {
PathAndQuery::from_static("/")
} else if pathq.starts_with('?') {
let mut origin_form = String::with_capacity(pathq.len() + 1);
origin_form.push('/');
origin_form.push_str(pathq);
PathAndQuery::from_bytes(origin_form.as_bytes())?
} else {
PathAndQuery::from_bytes(pathq.as_bytes())?
};
Ok(Self {
scheme,
host,
port,
path_and_query,
userinfo,
})
}
pub fn authority(&self) -> String {
let bracketed = self.host.contains(':');
let mut a = String::with_capacity(self.host.len() + 6 + usize::from(bracketed) * 2);
if bracketed {
a.push('[');
}
a.push_str(&self.host);
if bracketed {
a.push(']');
}
a.push(':');
a.push_str(&self.port.to_string());
a
}
}
impl fmt::Display for Url {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}://{}", self.scheme, self.authority())?;
f.write_str(self.path_and_query.as_str())
}
}
impl core::str::FromStr for Url {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::parse(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_plain_url() {
let u = Url::parse("http://example.com:8080/a/b?q=1").unwrap();
assert_eq!(u.scheme, "http");
assert_eq!(u.host, "example.com");
assert_eq!(u.port, 8080);
assert_eq!(u.path_and_query.as_str(), "/a/b?q=1");
}
#[test]
fn default_ports() {
assert_eq!(Url::parse("http://example.com/x").unwrap().port, 80);
assert_eq!(Url::parse("https://example.com/x").unwrap().port, 443);
}
#[test]
fn parses_bracketed_ipv6_and_normalizes_query_only_url() {
let ipv6 = Url::parse("http://[::1]:8080/status").unwrap();
assert_eq!(ipv6.host, "::1");
assert_eq!(ipv6.port, 8080);
assert_eq!(ipv6.authority(), "[::1]:8080");
let query_only = Url::parse("https://example.com?active=true#local").unwrap();
assert_eq!(query_only.path_and_query.as_str(), "/?active=true");
}
#[test]
fn rejects_ambiguous_or_invalid_authority() {
for url in [
"http://example.com:",
"http://example.com:not-a-port",
"http://example.com:0",
"http://example.com:65536",
"http://::1/",
"http://[::1/",
"http://[::1]suffix/",
"http://example.com\r\nInjected: value/",
] {
assert!(Url::parse(url).is_err(), "must reject {url:?}");
}
}
#[test]
fn rejects_bad_scheme() {
assert!(Url::parse("ftp://example.com/x").is_err());
}
}