#![cfg_attr(
all(doc, feature = "document-features"),
doc = ::document_features::document_features!()
)]
#![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg))]
#![deny(missing_docs)]
#![forbid(unsafe_code)]
use std::{borrow::Cow, path::PathBuf};
use bstr::{BStr, BString};
use gix_utils::AsBStr;
const HTTP_PATH_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
.add(b' ')
.add(b'"')
.add(b'%')
.add(b'<')
.add(b'>')
.add(b'`')
.add(b'{')
.add(b'}');
pub mod expand_path;
mod scheme;
pub use scheme::Scheme;
mod impls;
pub mod parse;
mod simple_url;
pub fn parse(input: impl AsBStr) -> Result<Url, parse::Error> {
use parse::InputScheme;
let input = input.as_bstr();
match parse::find_scheme(input) {
InputScheme::Local => parse::local(input),
InputScheme::Url { protocol_end } if input[..protocol_end].eq_ignore_ascii_case(b"file") => {
parse::file_url(input, protocol_end)
}
InputScheme::Url { protocol_end } => parse::url(input, protocol_end),
InputScheme::Scp { colon } => parse::scp(input, colon),
}
}
pub fn expand_path(user: Option<&expand_path::ForUser>, path: &BStr) -> Result<PathBuf, expand_path::Error> {
expand_path::with(user, path, |user| match user {
expand_path::ForUser::Current => gix_path::env::home_dir(),
expand_path::ForUser::Name(user) => {
gix_path::env::home_dir().and_then(|home| home.parent().map(|home_dirs| home_dirs.join(user.to_string())))
}
})
}
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
pub enum ArgumentSafety<'a> {
Absent,
Usable(&'a str),
Dangerous(&'a str),
}
#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Url {
pub scheme: Scheme,
pub user: Option<String>,
pub password: Option<String>,
pub host: Option<String>,
pub serialize_alternative_form: bool,
pub port: Option<u16>,
pub path: BString,
#[cfg_attr(feature = "serde", serde(default))]
pub(crate) path_with_percent_escapes: Option<BString>,
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Url {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct Fields {
scheme: Scheme,
user: Option<String>,
password: Option<String>,
host: Option<String>,
serialize_alternative_form: bool,
port: Option<u16>,
path: BString,
#[serde(default)]
path_with_percent_escapes: Option<BString>,
}
let mut fields = Fields::deserialize(deserializer)?;
if fields.path_with_percent_escapes.as_ref() == Some(&fields.path) {
fields.path_with_percent_escapes = Some(encode_legacy_http_path(&fields.path));
fields.path = percent_encoding::percent_decode(&fields.path)
.collect::<Vec<_>>()
.into();
}
Ok(Url {
scheme: fields.scheme,
user: fields.user,
password: fields.password,
host: fields.host,
serialize_alternative_form: fields.serialize_alternative_form,
port: fields.port,
path: fields.path,
path_with_percent_escapes: fields.path_with_percent_escapes,
})
}
}
#[cfg(feature = "serde")]
fn encode_legacy_http_path(path: &[u8]) -> BString {
let mut out = Vec::with_capacity(path.len());
let mut start = 0;
let mut pos = 0;
while pos + 2 < path.len() {
if path[pos] == b'%' && path[pos + 1].is_ascii_hexdigit() && path[pos + 2].is_ascii_hexdigit() {
out.extend(
percent_encoding::percent_encode(&path[start..pos], HTTP_PATH_ENCODE_SET)
.to_string()
.bytes(),
);
out.extend_from_slice(&path[pos..pos + 3]);
pos += 3;
start = pos;
} else {
pos += 1;
}
}
out.extend(
percent_encoding::percent_encode(&path[start..], HTTP_PATH_ENCODE_SET)
.to_string()
.bytes(),
);
out.into()
}
impl Url {
pub fn from_parts(
scheme: Scheme,
user: Option<String>,
password: Option<String>,
host: Option<String>,
port: Option<u16>,
path: BString,
serialize_alternative_form: bool,
) -> Result<Self, parse::Error> {
let is_http = matches!(scheme, Scheme::Http | Scheme::Https);
let mut parsed = parse(
Url {
scheme,
user,
password,
host,
port,
path: path.clone(),
serialize_alternative_form,
path_with_percent_escapes: None,
}
.to_bstring(),
)?;
if is_http {
if !path.is_empty() {
parsed.path = path;
}
parsed.path_with_percent_escapes = None;
}
Ok(parsed)
}
}
impl Url {
pub fn set_user(&mut self, user: Option<String>) -> Option<String> {
let prev = self.user.take();
self.user = user;
prev
}
pub fn set_password(&mut self, password: Option<String>) -> Option<String> {
let prev = self.password.take();
self.password = password;
prev
}
}
impl Url {
pub fn serialize_alternate_form(mut self, use_alternate_form: bool) -> Self {
self.serialize_alternative_form = use_alternate_form;
self
}
pub fn canonicalize(&mut self, current_dir: &std::path::Path) -> Result<(), gix_path::realpath::Error> {
if self.scheme == Scheme::File {
let path = gix_path::from_bstr(Cow::Borrowed(self.path.as_ref()));
let abs_path = gix_path::realpath_opts(path.as_ref(), current_dir, gix_path::realpath::MAX_SYMLINKS)?;
self.path = gix_path::into_bstr(abs_path).into_owned();
}
Ok(())
}
}
impl Url {
pub fn user(&self) -> Option<&str> {
self.user.as_deref()
}
pub fn user_as_argument(&self) -> ArgumentSafety<'_> {
match self.user() {
Some(user) if looks_like_command_line_option(user.as_bytes()) => ArgumentSafety::Dangerous(user),
Some(user) => ArgumentSafety::Usable(user),
None => ArgumentSafety::Absent,
}
}
pub fn user_argument_safe(&self) -> Option<&str> {
match self.user_as_argument() {
ArgumentSafety::Usable(user) => Some(user),
_ => None,
}
}
pub fn password(&self) -> Option<&str> {
self.password.as_deref()
}
pub fn host(&self) -> Option<&str> {
self.host.as_deref()
}
pub fn host_as_argument(&self) -> ArgumentSafety<'_> {
match self.host() {
Some(host) if looks_like_command_line_option(host.as_bytes()) => ArgumentSafety::Dangerous(host),
Some(host) => ArgumentSafety::Usable(host),
None => ArgumentSafety::Absent,
}
}
pub fn host_argument_safe(&self) -> Option<&str> {
match self.host_as_argument() {
ArgumentSafety::Usable(host) => Some(host),
_ => None,
}
}
fn path_with_percent_escapes(&self) -> Option<&BStr> {
let encoded = self.path_with_percent_escapes.as_ref()?;
percent_encoding::percent_decode(encoded)
.eq(self.path.iter().copied())
.then_some(encoded.as_ref())
}
pub fn original_path(&self) -> &BStr {
self.path_with_percent_escapes().unwrap_or(self.path.as_ref())
}
pub fn path_argument_safe(&self) -> Option<&BStr> {
self.path
.strip_prefix(b"/")
.and_then(|truncated| (!looks_like_command_line_option(truncated)).then_some(self.path.as_ref()))
}
pub fn path_is_root(&self) -> bool {
self.path == "/"
}
pub fn port_or_default(&self) -> Option<u16> {
self.port.or_else(|| {
use Scheme::*;
Some(match self.scheme {
Http => 80,
Https => 443,
Ssh => 22,
Git => 9418,
File | Ext(_) => return None,
})
})
}
}
fn looks_like_command_line_option(b: &[u8]) -> bool {
b.first() == Some(&b'-')
}
impl Url {
pub fn canonicalized(&self, current_dir: &std::path::Path) -> Result<Self, gix_path::realpath::Error> {
let mut res = self.clone();
res.canonicalize(current_dir)?;
Ok(res)
}
}
impl Url {
pub fn write_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> {
if self.serialize_alternative_form
&& (self.scheme == Scheme::File || self.scheme == Scheme::Ssh)
&& self.password.is_none()
&& self.port.is_none()
{
self.write_alternative_form_to(out)
} else {
self.write_canonical_form_to(out)
}
}
fn write_canonical_form_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> {
fn percent_encode(s: &str, encode_colon: bool) -> Cow<'_, str> {
const USERINFO_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'/')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'@')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
const USERNAME_ENCODE_SET: &percent_encoding::AsciiSet = &USERINFO_ENCODE_SET.add(b':');
let encode_set = if encode_colon {
USERNAME_ENCODE_SET
} else {
USERINFO_ENCODE_SET
};
percent_encoding::utf8_percent_encode(s, encode_set).into()
}
fn write_host(out: &mut dyn std::io::Write, host: &str, bracket: bool, scheme: &Scheme) -> std::io::Result<()> {
if bracket {
out.write_all(b"[")?;
out.write_all(host.replace('%', "%25").as_bytes())?;
out.write_all(b"]")
} else if matches!(scheme, Scheme::File | Scheme::Http | Scheme::Https) {
out.write_all(host.as_bytes())
} else {
out.write_all(percent_encode(host, host.parse::<std::net::Ipv6Addr>().is_err()).as_bytes())
}
}
out.write_all(self.scheme.as_str().as_bytes())?;
out.write_all(b"://")?;
let needs_brackets = self.host_needs_brackets()
&& (self.port.is_some() || self.host.as_ref().is_some_and(|host| host.contains('%')));
match (&self.user, &self.host) {
(Some(user), Some(host)) => {
out.write_all(percent_encode(user, true).as_bytes())?;
if let Some(password) = &self.password {
out.write_all(b":")?;
out.write_all(percent_encode(password, false).as_bytes())?;
}
out.write_all(b"@")?;
write_host(out, host, needs_brackets, &self.scheme)?;
}
(None, Some(host)) => {
write_host(out, host, needs_brackets, &self.scheme)?;
}
(None, None) => {}
(Some(_user), None) => {
return Err(std::io::Error::other(
"Invalid URL structure: user specified without host",
));
}
}
if let Some(port) = &self.port {
write!(out, ":{port}")?;
}
if matches!(self.scheme, Scheme::Ssh | Scheme::Git) && !self.path.starts_with(b"/") {
out.write_all(b"/")?;
}
if let Some(encoded) = self.path_with_percent_escapes() {
out.write_all(encoded)?;
} else if matches!(self.scheme, Scheme::Http | Scheme::Https) {
write!(
out,
"{}",
percent_encoding::percent_encode(&self.path, HTTP_PATH_ENCODE_SET)
)?;
} else {
out.write_all(&self.path)?;
}
Ok(())
}
fn host_needs_brackets(&self) -> bool {
fn is_ipv6(h: &str) -> bool {
h.contains(':') && !h.starts_with('[')
}
self.host.as_ref().is_some_and(|h| is_ipv6(h))
}
fn write_alternative_form_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> {
let needs_brackets = self.host_needs_brackets();
match (&self.user, &self.host) {
(Some(user), Some(host)) => {
out.write_all(user.as_bytes())?;
out.write_all(b"@")?;
if needs_brackets {
out.write_all(b"[")?;
}
out.write_all(host.as_bytes())?;
if needs_brackets {
out.write_all(b"]")?;
}
}
(None, Some(host)) => {
if needs_brackets {
out.write_all(b"[")?;
}
out.write_all(host.as_bytes())?;
if needs_brackets {
out.write_all(b"]")?;
}
}
(None, None) => {}
(Some(_user), None) => {
return Err(std::io::Error::other(
"Invalid URL structure: user specified without host",
));
}
}
if self.scheme == Scheme::Ssh {
out.write_all(b":")?;
}
out.write_all(&self.path)?;
Ok(())
}
pub fn to_bstring(&self) -> BString {
let mut buf = Vec::with_capacity(
(5 + 3)
+ self.user.as_ref().map(String::len).unwrap_or_default()
+ 1
+ self.host.as_ref().map(String::len).unwrap_or_default()
+ self.port.map(|_| 5).unwrap_or_default()
+ self.path.len(),
);
self.write_to(&mut buf).expect("io cannot fail in memory");
buf.into()
}
}
impl Url {
pub fn from_bytes(bytes: &BStr) -> Result<Self, parse::Error> {
parse(bytes)
}
}
#[cfg(all(test, feature = "serde"))]
mod serde_tests {
#[test]
fn legacy_encoded_public_path_is_migrated() -> gix_testtools::Result {
for (input, legacy_path, decoded_path) in [
("https://example.com/a%2Fb", "/a%2Fb", "/a/b"),
("https://example.com/%20%25", "/ %25", "/ %"),
] {
let mut legacy = crate::parse(input)?;
legacy.path = legacy_path.into();
legacy.path_with_percent_escapes = Some(legacy_path.into());
let migrated: crate::Url = serde_json::from_slice(&serde_json::to_vec(&legacy)?)?;
assert_eq!(
migrated.path, decoded_path,
"the public path is upgraded to decoded form"
);
assert_eq!(migrated.to_bstring(), input, "the encoded spelling remains lossless");
}
Ok(())
}
}
#[doc(hidden)]
pub mod testing {
use bstr::BString;
use crate::{Scheme, Url};
pub trait TestUrlExtension {
fn from_parts_unchecked(
scheme: Scheme,
user: Option<String>,
password: Option<String>,
host: Option<String>,
port: Option<u16>,
path: BString,
serialize_alternative_form: bool,
) -> Url {
Url {
scheme,
user,
password,
host,
port,
path,
serialize_alternative_form,
path_with_percent_escapes: None,
}
}
}
impl TestUrlExtension for Url {}
}