diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..14f4962 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + build + uses: Swatinem/rust-cache@v2 + + - name: Run unit tests (default features) + run: cargo test --verbose + + - name: Run unit tests (all features) + run: cargo test --all-features --verbose diff --git a/Cargo.toml b/Cargo.toml index 91798fa..e009daa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "pathlink" -version = "0.4.0" +version = "0.5.0" authors = ["code@tinychain.net"] -edition = "2021" +edition = "2024" license = "Apache-2.0" description = "A URL type whose path can also be used as a filesystem path, for Rust" repository = "https://github.com/TinyChain-Inc/pathlink.git" @@ -21,11 +21,12 @@ uuid = ["hr-id/uuid"] [dependencies] async-hash = { version = "0.5", optional = true } derive_more = { version = "1.0", features=["display"] } -destream = { path = "../destream", version = "0.10.1", optional = true } +destream = { version = "0.10", optional = true } get-size = "0.1" get-size-derive = "0.1" hex = { version = "0.4", optional = true } -hr-id = { path = "../hr-id", version = "0.7.0" } +hr-id = "0.7" safecast = "0.2" serde = { version = "1.0", optional = true } smallvec = "1.13" +url = "2.5" diff --git a/src/lib.rs b/src/lib.rs index 4621657..24f8a2e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,10 +8,11 @@ use std::str::FromStr; use derive_more::Display; use get_size::GetSize; use smallvec::SmallVec; +use url::Url; pub use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -pub use hr_id::{label, Id, Label, ParseError}; +pub use hr_id::{Id, Label, ParseError, label}; mod path; #[cfg(feature = "serialize")] @@ -123,6 +124,7 @@ impl<'a> From<&'a [PathSegment]> for ToUrl<'a> { pub enum Protocol { #[default] HTTP, + HTTPS, } impl PartialOrd for Protocol { @@ -135,6 +137,9 @@ impl Ord for Protocol { fn cmp(&self, other: &Self) -> Ordering { match (self, other) { (Self::HTTP, Self::HTTP) => Ordering::Equal, + (Self::HTTP, Self::HTTPS) => Ordering::Less, + (Self::HTTPS, Self::HTTP) => Ordering::Greater, + (Self::HTTPS, Self::HTTPS) => Ordering::Equal, } } } @@ -143,6 +148,7 @@ impl fmt::Display for Protocol { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str(match self { Self::HTTP => "http", + Self::HTTPS => "https", }) } } @@ -152,7 +158,7 @@ impl fmt::Display for Protocol { pub enum Address { IPv4(Ipv4Addr), IPv6(Ipv6Addr), - // TODO: international domain names with IDNA: https://docs.rs/idna/0.3.0/idna/ + Domain(String), } impl Default for Address { @@ -169,6 +175,7 @@ impl Address { match self { Self::IPv4(addr) => Some((*addr).into()), Self::IPv6(addr) => Some((*addr).into()), + Self::Domain(_) => None, } } @@ -177,6 +184,7 @@ impl Address { match self { Self::IPv4(addr) => addr.is_loopback(), Self::IPv6(addr) => addr.is_loopback(), + Self::Domain(domain) => domain.eq_ignore_ascii_case("localhost"), } } } @@ -192,8 +200,11 @@ impl Ord for Address { match (self, other) { (Self::IPv4(this), Self::IPv4(that)) => this.cmp(that), (Self::IPv6(this), Self::IPv6(that)) => this.cmp(that), - (Self::IPv4(_this), _) => Ordering::Less, - (Self::IPv6(_this), _) => Ordering::Greater, + (Self::Domain(this), Self::Domain(that)) => this.cmp(that), + (Self::IPv4(_), _) => Ordering::Less, + (Self::IPv6(_), Self::IPv4(_)) => Ordering::Greater, + (Self::IPv6(_), Self::Domain(_)) => Ordering::Less, + (Self::Domain(_), _) => Ordering::Greater, } } } @@ -203,6 +214,7 @@ impl GetSize for Address { match self { Self::IPv4(_) => 4, Self::IPv6(_) => 16, + Self::Domain(domain) => domain.len(), } } } @@ -300,71 +312,43 @@ impl FromStr for Host { type Err = ParseError; fn from_str(s: &str) -> Result { - if !s.starts_with("http://") { - return Err(format!("invalid protocol: {}", s).into()); - } - - let protocol = Protocol::HTTP; + // Delegate host/domain/IP parsing and IDNA handling to `url` so this crate + // remains focused on protocol + path semantics rather than URL-host + // normalization/validation rules. + let url = + Url::parse(s).map_err(|cause| ParseError::from(format!("invalid host: {cause}")))?; - let s = &s[7..]; + if !url.username().is_empty() || url.password().is_some() { + return Err(ParseError::from(format!( + "invalid host (userinfo not allowed): {s}" + ))); + } - let (address, port): (Address, Option) = if s.contains("::") { - let mut segments: Segments<&str> = s.split("::").collect(); - let port: Option = if segments.last().unwrap().contains(':') { - let last_segment: Segments<&str> = segments.pop().unwrap().split(':').collect(); - if last_segment.len() == 2 { - segments.push(last_segment[0]); + if url.query().is_some() || url.fragment().is_some() || url.path() != "/" { + return Err(ParseError::from(format!( + "invalid host (unexpected URL components): {s}" + ))); + } - let port = last_segment[1].parse().map_err(|cause| { - format!("{} is not a valid port number: {}", last_segment[1], cause) - })?; + let protocol = match url.scheme() { + "http" => Protocol::HTTP, + "https" => Protocol::HTTPS, + _ => return Err(ParseError::from(format!("invalid protocol: {s}"))), + }; - Some(port) - } else { - return Err(format!("invalid IPv6 address: {}", s).into()); - } - } else { - None - }; - - let address = segments.join("::"); - let address: Ipv6Addr = address.parse().map_err(|cause| { - ParseError::from(format!( - "{} is not a valid IPv6 address: {}", - address, cause - )) - })?; - - (address.into(), port) - } else { - let (address, port) = if s.contains(':') { - let segments: Segments<&str> = s.split(':').collect(); - if segments.len() == 2 { - let port: u16 = segments[1].parse().map_err(|cause| { - ParseError::from(format!( - "{} is not a valid port number: {}", - segments[1], cause - )) - })?; - - (segments[0], Some(port)) - } else { - return Err(format!("invalid network address: {}", s).into()); - } - } else { - (s, None) - }; - - let address: Ipv4Addr = address.parse().map_err(|cause| { - ParseError::from(format!( - "{} is not a valid IPv4 address: {}", - address, cause - )) - })?; - - (address.into(), port) + let address = match url.host() { + Some(url::Host::Ipv4(addr)) => Address::IPv4(addr), + Some(url::Host::Ipv6(addr)) => Address::IPv6(addr), + Some(url::Host::Domain(domain)) => Address::Domain(domain.to_string()), + None => { + return Err(ParseError::from(format!( + "invalid host (missing address): {s}" + ))); + } }; + let port = url.port(); + Ok(Host { protocol, address, @@ -579,7 +563,7 @@ impl FromStr for Link { host: None, path: s.parse()?, }); - } else if !s.starts_with("http://") { + } else if !s.starts_with("http://") && !s.starts_with("https://") { return Err(format!("cannot parse {} as a Link: invalid protocol", s).into()); } @@ -679,3 +663,50 @@ impl async_hash::Hash for &Link { } } } + +#[cfg(test)] +mod tests { + use super::{Address, Host, Link, Protocol}; + + #[test] + fn parse_http_host_ipv4() { + let host: Host = "http://127.0.0.1:80".parse().expect("host"); + assert_eq!(host.protocol(), Protocol::HTTP); + assert_eq!(host.port(), Some(80)); + assert_eq!( + host.address(), + &Address::from(std::net::Ipv4Addr::new(127, 0, 0, 1)) + ); + } + + #[test] + fn parse_https_host_domain() { + let host: Host = "https://example.com:443".parse().expect("host"); + assert_eq!(host.protocol(), Protocol::HTTPS); + assert_eq!(host.port(), Some(443)); + assert_eq!(host.address(), &Address::Domain("example.com".to_string())); + } + + #[test] + fn parse_https_idn_host_normalizes_to_ascii() { + let host: Host = "https://bücher.example".parse().expect("host"); + assert_eq!( + host.address(), + &Address::Domain("xn--bcher-kva.example".to_string()) + ); + } + + #[test] + fn parse_invalid_domain_name_fails() { + let err = "https://-bad-.com" + .parse::() + .expect_err("invalid domain should fail"); + assert!(err.to_string().contains("invalid domain name")); + } + + #[test] + fn parse_link_accepts_https() { + let link: Link = "https://example.com/a/b".parse().expect("link"); + assert_eq!(link.to_string(), "https://example.com/a/b"); + } +} diff --git a/src/path.rs b/src/path.rs index 4be4f4f..9c6b12c 100644 --- a/src/path.rs +++ b/src/path.rs @@ -7,7 +7,7 @@ use std::{fmt, iter}; use get_size::GetSize; use smallvec::smallvec; -use super::{label, Id, Label, ParseError, Segments}; +use super::{Id, Label, ParseError, Segments, label}; /// A segment of a [`Path`] pub type PathSegment = Id; diff --git a/src/serial.rs b/src/serial.rs index ffd86a7..596ea5d 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -40,3 +40,17 @@ impl Serialize for PathBuf { self.to_string().serialize(serializer) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_serde Deserialize<'de>>() {} + + #[test] + fn host_link_pathbuf_have_serde_impls() { + assert_serde::(); + assert_serde::(); + assert_serde::(); + } +} diff --git a/src/stream.rs b/src/stream.rs index 04834ba..2017e95 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -69,3 +69,22 @@ impl<'en> en::ToStream<'en> for PathBuf { e.encode_str(&self.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_destream() + where + T: de::FromStream, + for<'en> T: en::ToStream<'en> + en::IntoStream<'en>, + { + } + + #[test] + fn host_link_pathbuf_have_destream_impls() { + assert_destream::(); + assert_destream::(); + assert_destream::(); + } +}