Skip to content
Closed
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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 5 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
159 changes: 95 additions & 64 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -123,6 +124,7 @@ impl<'a> From<&'a [PathSegment]> for ToUrl<'a> {
pub enum Protocol {
#[default]
HTTP,
HTTPS,
}

impl PartialOrd for Protocol {
Expand All @@ -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,
}
}
}
Expand All @@ -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",
})
}
}
Expand All @@ -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 {
Expand All @@ -169,6 +175,7 @@ impl Address {
match self {
Self::IPv4(addr) => Some((*addr).into()),
Self::IPv6(addr) => Some((*addr).into()),
Self::Domain(_) => None,
}
}

Expand All @@ -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"),
}
}
}
Expand All @@ -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,
}
}
}
Expand All @@ -203,6 +214,7 @@ impl GetSize for Address {
match self {
Self::IPv4(_) => 4,
Self::IPv6(_) => 16,
Self::Domain(domain) => domain.len(),
}
}
}
Expand Down Expand Up @@ -300,71 +312,43 @@ impl FromStr for Host {
type Err = ParseError;

fn from_str(s: &str) -> Result<Host, ParseError> {
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<u16>) = if s.contains("::") {
let mut segments: Segments<&str> = s.split("::").collect();
let port: Option<u16> = 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,
Expand Down Expand Up @@ -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());
}

Expand Down Expand Up @@ -679,3 +663,50 @@ impl<D: async_hash::Digest> async_hash::Hash<D> 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::<Host>()
.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");
}
}
2 changes: 1 addition & 1 deletion src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions src/serial.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,17 @@ impl Serialize for PathBuf {
self.to_string().serialize(serializer)
}
}

#[cfg(test)]
mod tests {
use super::*;

fn assert_serde<T: Serialize + for<'de> Deserialize<'de>>() {}

#[test]
fn host_link_pathbuf_have_serde_impls() {
assert_serde::<Host>();
assert_serde::<Link>();
assert_serde::<PathBuf>();
}
}
19 changes: 19 additions & 0 deletions src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()
where
T: de::FromStream<Context = ()>,
for<'en> T: en::ToStream<'en> + en::IntoStream<'en>,
{
}

#[test]
fn host_link_pathbuf_have_destream_impls() {
assert_destream::<Host>();
assert_destream::<Link>();
assert_destream::<PathBuf>();
}
}
Loading