diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b592170 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + test-feature-matrix: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: no-default-features + args: --no-default-features + - name: default-features + args: "" + - name: all-features + args: --all-features + - name: feature-hash + args: --no-default-features --features hash + - name: feature-stream + args: --no-default-features --features stream + - name: feature-serialize + args: --no-default-features --features serialize + - name: feature-uuid + args: --no-default-features --features uuid + 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 (${{ matrix.name }}) + run: cargo test ${{ matrix.args }} --verbose diff --git a/Cargo.toml b/Cargo.toml index 91798fa..827f7be 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,15 @@ 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" + +[dev-dependencies] +serde_json = "1.0" diff --git a/README.md b/README.md index fa472c0..0842735 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,35 @@ # pathlink -A URL type whose path can also be used as a filesystem path, for Rust + +A URL type whose path can also be used as a filesystem path, for Rust. + +`pathlink` supports absolute HTTP and HTTPS links with IPv4, bracketed IPv6, +and domain-name hosts, plus absolute path-only links: + +```rust +use pathlink::{Domain, Link}; + +let http: Link = "http://127.0.0.1:8702/api".parse().expect("HTTP link"); +let https: Link = "https://example.com/api".parse().expect("HTTPS link"); +let idn: Link = "https://bücher.example/api".parse().expect("IDN link"); +let local: Link = "/api".parse().expect("path-only link"); + +assert_eq!(idn.to_string(), "https://xn--bcher-kva.example/api"); + +let domain = Domain::new("EXAMPLE.COM").expect("domain"); +assert_eq!(domain.as_str(), "example.com"); +``` + +Host parsing intentionally accepts only the URL components represented by +`Host`: a scheme, host address, and optional port. Userinfo, paths, queries, +and fragments are rejected when parsing a `Host`. + +For HTTP and HTTPS links, an empty path is equivalent to the root path according +to IETF RFC 9110 Section 4.2.3, so parsing `https://example.com` canonicalizes and +displays it as `https://example.com/`. + +Domain names are normalized to ASCII/Punycode through the `url` crate's IDNA +handling and then validated as DNS-style labels. Display, equality, ordering, +hashing, and serialization use that normalized form. `Address::Domain` stores a +validated `Domain` value backed by immutable shared string storage, so domain +addresses cannot be constructed from an arbitrary `String` and cloning them does +not clone the underlying domain string. diff --git a/src/lib.rs b/src/lib.rs index 4621657..c81b19b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,14 +4,16 @@ use std::cmp::Ordering; use std::fmt; use std::iter; use std::str::FromStr; +use std::sync::Arc; 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")] @@ -118,11 +120,22 @@ impl<'a> From<&'a [PathSegment]> for ToUrl<'a> { } } -/// The protocol portion of a [`Link`] (e.g. "http") +/// The protocol portion of a [`Link`] (e.g. "http" or "https") #[derive(Copy, Clone, Debug, Default, Hash, Eq, PartialEq, get_size_derive::GetSize)] pub enum Protocol { #[default] HTTP, + HTTPS, +} + +impl Protocol { + /// Return this [`Protocol`] as a URL scheme. + pub fn as_str(self) -> &'static str { + match self { + Self::HTTP => "http", + Self::HTTPS => "https", + } + } } impl PartialOrd for Protocol { @@ -135,15 +148,108 @@ 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, } } } impl fmt::Display for Protocol { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str(match self { - Self::HTTP => "http", - }) + f.write_str(self.as_str()) + } +} + +impl FromStr for Protocol { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s { + s if s.eq_ignore_ascii_case("http") => Ok(Self::HTTP), + s if s.eq_ignore_ascii_case("https") => Ok(Self::HTTPS), + _ => Err(ParseError::from(format!("invalid protocol: {s}"))), + } + } +} + +/// A normalized ASCII/Punycode domain name. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct Domain(Arc); + +impl Domain { + /// Parse and normalize a domain name. + pub fn new>(domain: S) -> Result { + domain.as_ref().parse() + } + + /// Borrow this domain name's normalized ASCII representation. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consume this [`Domain`] and return its normalized ASCII representation. + pub fn into_inner(self) -> Arc { + self.0 + } + + fn from_normalized(domain: &str) -> Result { + validate_domain_name(domain)?; + Ok(Self(Arc::from(domain))) + } +} + +impl AsRef for Domain { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for Domain { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for Domain { + type Err = ParseError; + + fn from_str(domain: &str) -> Result { + let url = Url::parse(&format!("http://{domain}/")) + .map_err(|cause| ParseError::from(format!("invalid domain name: {cause}")))?; + + if !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + { + return Err(ParseError::from(format!("invalid domain name: {domain}"))); + } + + match url.host() { + Some(url::Host::Domain(domain)) => Self::from_normalized(domain), + _ => Err(ParseError::from(format!("invalid domain name: {domain}"))), + } + } +} + +impl PartialOrd for Domain { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Domain { + fn cmp(&self, other: &Self) -> Ordering { + self.as_str().cmp(other.as_str()) + } +} + +impl GetSize for Domain { + fn get_size(&self) -> usize { + self.0.len() } } @@ -152,7 +258,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(Domain), } impl Default for Address { @@ -169,6 +275,7 @@ impl Address { match self { Self::IPv4(addr) => Some((*addr).into()), Self::IPv6(addr) => Some((*addr).into()), + Self::Domain(_) => None, } } @@ -177,6 +284,7 @@ impl Address { match self { Self::IPv4(addr) => addr.is_loopback(), Self::IPv6(addr) => addr.is_loopback(), + Self::Domain(domain) => domain.as_str().eq_ignore_ascii_case("localhost"), } } } @@ -192,8 +300,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 +314,7 @@ impl GetSize for Address { match self { Self::IPv4(_) => 4, Self::IPv6(_) => 16, + Self::Domain(domain) => domain.get_size(), } } } @@ -257,6 +369,68 @@ impl PartialEq for Address { } } +fn validate_domain_name(domain: &str) -> Result<(), ParseError> { + let domain = domain.strip_suffix('.').unwrap_or(domain); + + if domain.is_empty() || domain.len() > 253 { + return Err(ParseError::from(format!("invalid domain name: {domain}"))); + } + + for label in domain.split('.') { + let label_len = label.len(); + let valid_chars = label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-'); + + if label_len == 0 + || label_len > 63 + || !valid_chars + || label.starts_with('-') + || label.ends_with('-') + { + return Err(ParseError::from(format!("invalid domain name: {domain}"))); + } + } + + Ok(()) +} + +fn explicit_port(s: &str) -> Result, ParseError> { + let (_, authority) = s + .split_once("://") + .ok_or_else(|| ParseError::from(format!("invalid host: {s}")))?; + + let authority = authority.strip_suffix('/').unwrap_or(authority); + + if let Some(authority) = authority.strip_prefix('[') { + let (_, suffix) = authority + .split_once(']') + .ok_or_else(|| ParseError::from(format!("invalid host: {s}")))?; + + if suffix.is_empty() { + Ok(None) + } else if let Some(port) = suffix.strip_prefix(':') { + parse_port(port, s) + } else { + Err(ParseError::from(format!("invalid host: {s}"))) + } + } else if let Some((_, port)) = authority.rsplit_once(':') { + if port.is_empty() { + Err(ParseError::from(format!("invalid host: {s}"))) + } else { + parse_port(port, s) + } + } else { + Ok(None) + } +} + +fn parse_port(port: &str, s: &str) -> Result, ParseError> { + port.parse() + .map(Some) + .map_err(|cause| ParseError::from(format!("invalid port in {s}: {cause}"))) +} + /// The host component of a [`Link`] (e.g. "http://127.0.0.1:8702") #[derive(Clone, Debug, Hash, Eq, PartialEq, get_size_derive::GetSize)] pub struct Host { @@ -300,71 +474,47 @@ 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; - - let s = &s[7..]; - - 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]); - - let port = last_segment[1].parse().map_err(|cause| { - format!("{} is not a valid port number: {}", last_segment[1], cause) - })?; - - 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 protocol: Protocol = s + .split_once("://") + .ok_or_else(|| ParseError::from(format!("invalid protocol: {s}")))? + .0 + .parse()?; + + // 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}")))?; + + if !url.username().is_empty() || url.password().is_some() { + return Err(ParseError::from(format!( + "invalid host (userinfo not allowed): {s}" + ))); + } + + if url.query().is_some() || url.fragment().is_some() || url.path() != "/" { + return Err(ParseError::from(format!( + "invalid host (unexpected URL components): {s}" + ))); + } + + if url.scheme() != protocol.as_str() { + return Err(ParseError::from(format!("invalid protocol: {s}"))); + } + + 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::from_normalized(domain)?), + None => { + return Err(ParseError::from(format!( + "invalid host (missing address): {s}" + ))); + } }; + let port = explicit_port(s)?; + Ok(Host { protocol, address, @@ -437,15 +587,18 @@ impl From<(Protocol, Address, Option)> for Host { impl fmt::Display for Host { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - if let Some(port) = self.port { - write!(f, "{}://{}:{}", self.protocol, self.address, port) - } else { - write!(f, "{}://{}", self.protocol, self.address) + match (&self.address, self.port) { + (Address::IPv6(address), Some(port)) => { + write!(f, "{}://[{}]:{}", self.protocol, address, port) + } + (Address::IPv6(address), None) => write!(f, "{}://[{}]", self.protocol, address), + (address, Some(port)) => write!(f, "{}://{}:{}", self.protocol, address, port), + (address, None) => write!(f, "{}://{}", self.protocol, address), } } } -/// An HTTP Link with an optional [`Address`] and [`PathBuf`] +/// An HTTP or HTTPS Link with an optional [`Address`] and [`PathBuf`] #[derive(Clone, Default, Eq, Hash, PartialEq, get_size_derive::GetSize)] pub struct Link { host: Option, @@ -579,7 +732,11 @@ impl FromStr for Link { host: None, path: s.parse()?, }); - } else if !s.starts_with("http://") { + } else if s + .split_once("://") + .map(|(protocol, _)| protocol.parse::().is_err()) + .unwrap_or(true) + { return Err(format!("cannot parse {} as a Link: invalid protocol", s).into()); } @@ -679,3 +836,186 @@ impl async_hash::Hash for &Link { } } } + +#[cfg(test)] +mod tests { + use super::{Address, Domain, Host, Link, PathBuf, 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_http_host_without_explicit_port() { + let host: Host = "http://127.0.0.1".parse().expect("host"); + assert_eq!(host.port(), None); + assert_eq!(host.to_string(), "http://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(Domain::new("example.com").unwrap()) + ); + } + + #[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(Domain::new("bücher.example").unwrap()) + ); + assert_eq!(host.to_string(), "https://xn--bcher-kva.example"); + } + + #[test] + fn parse_uppercase_scheme_and_domain_normalizes() { + let host: Host = "HTTPS://EXAMPLE.COM:8443".parse().expect("host"); + assert_eq!(host.protocol(), Protocol::HTTPS); + assert_eq!(host.port(), Some(8443)); + assert_eq!( + host.address(), + &Address::Domain(Domain::new("example.com").unwrap()) + ); + } + + #[test] + fn domain_constructor_normalizes_valid_domain_names() { + let domain = Domain::new("BÜCHER.EXAMPLE").expect("domain"); + assert_eq!(domain.as_str(), "xn--bcher-kva.example"); + assert_eq!(domain.to_string(), "xn--bcher-kva.example"); + } + + #[test] + fn domain_clone_shares_storage() { + let domain = Domain::new("example.com").expect("domain"); + let clone = domain.clone(); + assert!(std::sync::Arc::ptr_eq( + &domain.into_inner(), + &clone.into_inner() + )); + } + + #[test] + fn domain_constructor_rejects_invalid_domain_names() { + for domain in [ + "-bad-.com", + "bad-.com", + "bad..com", + "has_underscore.example", + "example.com:443", + "example.com/path", + ] { + let err = Domain::new(domain).expect_err("invalid domain should fail"); + assert!(err.to_string().contains("invalid domain name")); + } + } + + #[test] + fn parse_ipv6_host_uses_brackets_for_display() { + let host: Host = "https://[::1]:443".parse().expect("host"); + assert_eq!(host.protocol(), Protocol::HTTPS); + assert_eq!(host.port(), Some(443)); + assert_eq!( + host.address(), + &Address::from(std::net::Ipv6Addr::LOCALHOST) + ); + assert_eq!(host.to_string(), "https://[::1]:443"); + } + + #[test] + fn parse_invalid_domain_name_fails() { + for host in [ + "https://-bad-.com", + "https://bad-.com", + "https://bad..com", + "https://has_underscore.example", + ] { + let err = host + .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"); + } + + #[test] + fn parse_link_without_path_canonicalizes_to_root_path() { + let link: Link = "https://example.com".parse().expect("link"); + assert_eq!(link.path(), &PathBuf::default()); + assert_eq!(link.to_string(), "https://example.com/"); + + let link: Link = "https://example.com/".parse().expect("link"); + assert_eq!(link.path(), &PathBuf::default()); + assert_eq!(link.to_string(), "https://example.com/"); + } + + #[test] + fn parse_link_accepts_uppercase_https() { + let link: Link = "HTTPS://EXAMPLE.COM/a/b".parse().expect("link"); + assert_eq!(link.to_string(), "https://example.com/a/b"); + } + + #[test] + fn parse_link_accepts_ipv6_host() { + let link: Link = "https://[::1]:443/a/b".parse().expect("link"); + assert_eq!(link.to_string(), "https://[::1]:443/a/b"); + } + + #[test] + fn parse_link_rejects_unsupported_protocol() { + let err = "ftp://example.com/a" + .parse::() + .expect_err("unsupported protocol should fail"); + assert!(err.to_string().contains("invalid protocol")); + } + + #[test] + fn parse_host_rejects_userinfo() { + let err = "https://user@example.com" + .parse::() + .expect_err("userinfo should be rejected"); + assert!(err.to_string().contains("userinfo")); + } + + #[test] + fn parse_host_rejects_query() { + let err = "https://example.com?x=1" + .parse::() + .expect_err("query should be rejected"); + assert!(err.to_string().contains("unexpected URL components")); + } + + #[test] + fn parse_host_rejects_fragment() { + let err = "https://example.com#frag" + .parse::() + .expect_err("fragment should be rejected"); + assert!(err.to_string().contains("unexpected URL components")); + } + + #[test] + fn parse_host_rejects_path() { + let err = "https://example.com/a" + .parse::() + .expect_err("path should be rejected"); + assert!(err.to_string().contains("unexpected URL components")); + } +} diff --git a/src/path.rs b/src/path.rs index 4be4f4f..5303d84 100644 --- a/src/path.rs +++ b/src/path.rs @@ -1,5 +1,6 @@ //! A segmented [`Path`] safe to use as a filesystem [`std::path::Path`] or in a [`super::Link`]. +use std::cmp::Ordering; use std::ops::{Deref, DerefMut}; use std::str::FromStr; use std::{fmt, iter}; @@ -7,12 +8,13 @@ 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; /// A constant representing a [`PathBuf`]. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct PathLabel { segments: &'static [&'static str], } @@ -22,6 +24,11 @@ impl PathLabel { pub fn len(&self) -> usize { self.segments.len() } + + /// Return `true` if this path label has no segments. + pub fn is_empty(&self) -> bool { + self.segments.is_empty() + } } impl> std::ops::Index for PathLabel { @@ -94,7 +101,7 @@ impl From<[PathSegment; N]> for PathBuf { } /// A segmented link safe to use with a filesystem or via HTTP. -#[derive(Default)] +#[derive(Clone, Copy, Default, Hash, Eq, PartialEq)] pub struct Path<'a> { inner: &'a [PathSegment], } @@ -143,6 +150,18 @@ impl<'a> fmt::Display for Path<'a> { } } +impl<'a> PartialOrd for Path<'a> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl<'a> Ord for Path<'a> { + fn cmp(&self, other: &Self) -> Ordering { + self.inner.cmp(other.inner) + } +} + /// A segmented link buffer safe to use with a filesystem or via HTTP. #[derive(Clone, Default, Hash, Eq, PartialEq)] pub struct PathBuf { @@ -299,6 +318,18 @@ impl PartialEq<[PathSegment]> for PathBuf { } } +impl PartialOrd for PathBuf { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PathBuf { + fn cmp(&self, other: &Self) -> Ordering { + self.segments.cmp(&other.segments) + } +} + impl From for PathBuf { fn from(segment: PathSegment) -> PathBuf { PathBuf { @@ -374,6 +405,8 @@ impl fmt::Display for PathBuf { mod test { use super::*; + fn assert_ord() {} + #[test] fn test_path_label_to_string() { let path = path_label(&[]); @@ -385,4 +418,41 @@ mod test { let path = path_label(&["one", "two"]); assert_eq!(path.to_string(), "/one/two".to_string()); } + + #[test] + fn path_types_are_ordered() { + assert_ord::>(); + assert_ord::(); + assert_ord::(); + } + + #[test] + fn path_buf_orders_by_segments() { + let mut paths = [ + "/two".parse::().expect("path"), + "/one/two".parse::().expect("path"), + "/one".parse::().expect("path"), + "/".parse::().expect("path"), + ]; + + paths.sort(); + + assert_eq!( + paths.map(|path| path.to_string()), + [ + String::from("/"), + String::from("/one"), + String::from("/one/two"), + String::from("/two") + ] + ); + } + + #[test] + fn borrowed_path_orders_by_segments() { + let one = "/one".parse::().expect("path"); + let two = "/two".parse::().expect("path"); + + assert!(Path::from(&one[..]) < Path::from(&two[..])); + } } diff --git a/src/serial.rs b/src/serial.rs index ffd86a7..666ded6 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -40,3 +40,34 @@ impl Serialize for PathBuf { self.to_string().serialize(serializer) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{from_str, to_string}; + + fn assert_serde Deserialize<'de>>() {} + + #[test] + fn host_link_pathbuf_have_serde_impls() { + assert_serde::(); + assert_serde::(); + assert_serde::(); + } + + #[test] + fn host_serde_roundtrip() { + let host: Host = "https://example.com:443".parse().expect("host"); + let encoded = to_string(&host).expect("serialize host"); + let decoded: Host = from_str(&encoded).expect("deserialize host"); + assert_eq!(decoded, host); + } + + #[test] + fn link_serde_roundtrip() { + let link: Link = "https://bücher.example/a/b".parse().expect("link"); + let encoded = to_string(&link).expect("serialize link"); + let decoded: Link = from_str(&encoded).expect("deserialize link"); + assert_eq!(decoded, link); + } +} 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::(); + } +}