Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 8 additions & 7 deletions faup/src/grammar.pest
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ userinfo = ${ username ~ (":" ~ password)? ~ "@" }

// this rule is used to check if the hostname is actually
// a valid ipv4 address as hostname rule matches any ipv4
ipv4 = ${ SOI ~ (ASCII_DIGIT{1, 3} ~ "."){3} ~ ASCII_DIGIT{1, 3} ~ EOI }
host = ${ ipv6 | hostname }
domain_part = ${ (!(":" | "?" | "/" | "#" | "." | WHITE_SPACE) ~ ANY)+ }
tld = ${ (!(":" | "?" | "/" | "#" | WHITE_SPACE) ~ ANY)+ }
hostname = ${ (((domain_part ~ ".")+ ~ tld) | domain_part) }

ipv6 = ${ "[" ~ (ASCII_HEX_DIGIT{,4} ~ ":"){2, 7} ~ ASCII_HEX_DIGIT{,4} ~ "]" }
ipv4 = ${ SOI ~ (ASCII_DIGIT{1, 3} ~ "."){3} ~ ASCII_DIGIT{1, 3} ~ EOI }
host = ${ "[" ~ ipv6 ~ "]" | hostname }
checked_host = _{ SOI ~ (ipv6 | hostname) ~ EOI }
domain_part = ${ (!(":" | "?" | "/" | "#" | "." | WHITE_SPACE) ~ ANY)+ }
tld = ${ (!(":" | "?" | "/" | "#" | "." | WHITE_SPACE) ~ ANY)+ }
hostname = ${ (((domain_part ~ ".")+ ~ tld) | domain_part) }

ipv6 = ${ (ASCII_HEX_DIGIT{,4} ~ ":"){2, 7} ~ ASCII_HEX_DIGIT{,4} }

encoded_char = ${ "%" ~ ASCII_DIGIT{2} }

Expand Down
157 changes: 128 additions & 29 deletions faup/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,20 @@ pub enum Error {
InvalidIPv4,
#[error("invalid ipv6 address")]
InvalidIPv6,
#[error("invalid host")]
InvalidHost,
#[error("{0}")]
Other(String),
#[error("parser error: {0}")]
Parse(#[from] Box<pest::error::Error<Rule>>),
}

impl Error {
fn other<S: AsRef<str>>(s: S) -> Self {
Error::Other(s.as_ref().to_string())
}
}

#[derive(Parser)]
#[grammar = "grammar.pest"]
pub(crate) struct UrlParser;
Expand Down Expand Up @@ -395,14 +405,96 @@ impl fmt::Display for Host<'_> {
}
}

impl<'url> Host<'url> {
impl<'host> Host<'host> {
fn into_owned<'owned>(self) -> Host<'owned> {
match self {
Host::Hostname(h) => Host::Hostname(h.into_owned()),
Host::Ip(ip) => Host::Ip(ip),
}
}

#[inline(always)]
fn from_pair(host_pair: Pair<'host, Rule>) -> Result<Self, Error> {
match host_pair.as_rule() {
Rule::hostname => {
if let Ok(ipv4) =
UrlParser::parse(Rule::ipv4, host_pair.as_str()).map(|p| p.as_str())
{
Ok(Ipv4Addr::from_str(ipv4)
.map(IpAddr::from)
.map(Host::Ip)
.map_err(|_| Error::InvalidIPv4)?)
} else {
Ok(Host::Hostname(Hostname::from_str(host_pair.as_str())))
}
}

Rule::ipv6 => Ok(Ipv6Addr::from_str(
host_pair.as_str().trim_matches(|c| c == '[' || c == ']'),
)
.map(IpAddr::from)
.map(Host::Ip)
.map_err(|_| Error::InvalidIPv6)?),
_ => Err(Error::other(format!(
"unexpected parsing rule: {:?}",
host_pair.as_rule()
))),
}
}

/// Parses a string into a `Host` enum.
///
/// This function expects the input string to be a URL host, which can be either
/// an IPv4 address, an IPv6 address, or a hostname.
///
/// # Arguments
///
/// * `host` - A string slice that holds the host to parse (e.g., `"example.com"`, `"127.0.0.1"`, `"::1"`).
///
/// # Returns
///
/// * `Result<Host, Error>` - A [`Host`] enum if parsing is successful, or an [`Error`] if parsing fails.
///
/// # Examples
///
/// ```
/// use faup_rs::Host;
///
/// // Parse an IPv4 address
/// let host = Host::parse("127.0.0.1").unwrap();
/// assert!(matches!(host, Host::Ip(std::net::IpAddr::V4(_))));
///
/// // Parse an IPv6 address
/// let host = Host::parse("::1").unwrap();
/// assert!(matches!(host, Host::Ip(std::net::IpAddr::V6(_))));
///
/// // Parse a hostname
/// let host = Host::parse("example.com").unwrap();
/// assert!(matches!(host, Host::Hostname(_)));
///
/// // Parse a hostname with a subdomain
/// let host = Host::parse("sub.example.com").unwrap();
/// assert!(matches!(host, Host::Hostname(_)));
///
/// // Parse a hostname with a custom TLD
/// let host = Host::parse("example.b32.i2p").unwrap();
/// assert!(matches!(host, Host::Hostname(_)));
///
/// // Attempt to parse an invalid host
/// let result = Host::parse("invalid..host");
/// assert!(matches!(result, Err(faup_rs::Error::InvalidHost)));
/// ```
#[inline]
pub fn parse(host: &'host str) -> Result<Self, Error> {
Self::from_pair(
UrlParser::parse(Rule::checked_host, host)
.map_err(|_| Error::InvalidHost)?
.next()
// this should not panic as parser guarantee some pair exist
.expect("expecting host pair"),
)
}

/// Returns the hostname component if this is a `Host::Hostname` variant.
///
/// # Returns
Expand Down Expand Up @@ -595,34 +687,7 @@ impl<'url> Url<'url> {
Rule::host => {
// cannot panic guarantee by parser
let host_pair = p.into_inner().next().unwrap();
match host_pair.as_rule() {
Rule::hostname => {
if let Ok(ipv4) =
UrlParser::parse(Rule::ipv4, host_pair.as_str()).map(|p| p.as_str())
{
host = Some(
Ipv4Addr::from_str(ipv4)
.map(IpAddr::from)
.map(Host::Ip)
.map_err(|_| Error::InvalidIPv4)?,
);
} else {
host = Some(Host::Hostname(Hostname::from_str(host_pair.as_str())))
}
}

Rule::ipv6 => {
host = Some(
Ipv6Addr::from_str(
host_pair.as_str().trim_matches(|c| c == '[' || c == ']'),
)
.map(IpAddr::from)
.map(Host::Ip)
.map_err(|_| Error::InvalidIPv6)?,
);
}
_ => {}
}
host = Some(Host::from_pair(host_pair)?)
}
Rule::port => {
port = Some(u16::from_str(p.as_str()).map_err(|_| Error::InvalidPort)?)
Expand Down Expand Up @@ -1255,4 +1320,38 @@ mod tests {
let url = Url::parse("https://example.com/../../..some/directory/traversal/../").unwrap();
assert_eq!(url.path(), Some("/../../..some/directory/traversal/../"));
}

#[test]
fn test_host_from_str() {
// Valid IPv4
let host = Host::parse("127.0.0.1").unwrap();
assert!(matches!(host, Host::Ip(std::net::IpAddr::V4(_))));

// Valid IPv6
let host = Host::parse("::1").unwrap();
assert!(matches!(host, Host::Ip(std::net::IpAddr::V6(_))));

let host = Host::parse("[::1]");
assert!(matches!(host, Err(Error::InvalidHost)));

// Invalid IPv6
let result = Host::parse("::::");
assert!(matches!(result, Err(Error::InvalidIPv6)));

// Valid hostname
let host = Host::parse("example.com").unwrap();
assert!(matches!(host, Host::Hostname(_)));

// Hostname with subdomain
let host = Host::parse("sub.example.com").unwrap();
assert!(matches!(host, Host::Hostname(_)));

// Hostname with custom TLD
let host = Host::parse("example.b32.i2p").unwrap();
assert!(matches!(host, Host::Hostname(_)));

// Invalid hostname (placeholder logic)
let result = Host::parse("example..com");
assert!(matches!(result, Err(Error::InvalidHost)));
}
}
66 changes: 53 additions & 13 deletions python/pyfaup.pyi
Original file line number Diff line number Diff line change
@@ -1,3 +1,42 @@
from typing import Optional

class Hostname:
hostname: str
subdomain: Optional[str]
domain: Optional[str]
suffix: Optional[str]

def __init__(self, hn: str) -> None:
...

def __str__(self) -> str:
...

class Host:
def __init__(self, s: str) -> None:
...

def try_into_hostname(self) -> Hostname:
...

def try_into_ip(self) -> str:
...

def is_hostname(self) -> bool:
...

def is_ipv4(self) -> bool:
...

def is_ipv6(self) -> bool:
...

def is_ip_addr(self) -> bool:
...

def __str__(self) -> str:
...

class FaupCompat:

url: bytes
Expand Down Expand Up @@ -41,21 +80,22 @@ class FaupCompat:
def get_port(self) -> int | None:
...


class Url:

orig: str
scheme: str
username: str | None
password: str | None
username: Optional[str]
password: Optional[str]
host: str
subdomain: str | None
domain: str | None
suffix: str | None
port: int | None
path: str | None
query: str | None
fragment: str | None

def __init__(self, url: str | None = None) -> None:
subdomain: Optional[str]
domain: Optional[str]
suffix: Optional[str]
port: Optional[int]
path: Optional[str]
query: Optional[str]
fragment: Optional[str]

def __init__(self, url: str) -> None:
...

def __str__(self) -> str:
...
Loading
Loading