kingfisher/src/git_url.rs
2025-09-23 13:07:45 -07:00

152 lines
4.3 KiB
Rust

use std::path::PathBuf;
use url::{Host, Url};
/// A wrapper around [`Url`] restricted to HTTPS and without credentials, query, or fragment.
#[derive(Clone, PartialEq, Eq, Debug, PartialOrd, Ord)]
pub struct GitUrl(Url);
impl GitUrl {
/// Convert this URL into a `PathBuf`, ensuring no path traversal.
///
/// For example, an HTTPS URL like `https://github.com/user/repo.git` becomes
/// `PathBuf(["https", "github.com", "user", "repo.git"])`.
///
/// Path segments that are `..` are already disallowed in [`TryFrom<Url>`].
pub fn to_path_buf(&self) -> PathBuf {
let mut result = PathBuf::new();
result.push(self.0.scheme());
let host_str = match self.0.host().expect("host should be non-empty") {
Host::Domain(h) => h.to_string(),
Host::Ipv4(addr) => addr.to_string(),
Host::Ipv6(addr) => addr.to_string(),
};
if let Some(port) = self.0.port() {
result.push(format!("{host_str}:{port}"));
} else {
result.push(host_str);
}
// Safe to unwrap path segments due to checks in `TryFrom<Url>`
if let Some(segments) = self.0.path_segments() {
result.extend(segments);
}
result
}
/// Return the wrapped URL as a string.
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl std::fmt::Display for GitUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
const GIT_URL_ERROR_MESSAGE: &str =
"only https URLs without credentials, query parameters, or fragment identifiers are supported";
impl std::str::FromStr for GitUrl {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Url::parse(s).map_err(|_| GIT_URL_ERROR_MESSAGE).and_then(Self::try_from)
}
}
impl TryFrom<Url> for GitUrl {
type Error = &'static str;
fn try_from(url: Url) -> Result<Self, Self::Error> {
// if url.scheme() != "https"
if url.host().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(GIT_URL_ERROR_MESSAGE);
}
match url.path_segments() {
Some(segs) if segs.clone().any(|s| s == "..") => Err(GIT_URL_ERROR_MESSAGE),
Some(_) => Ok(GitUrl(url)),
None => Err(GIT_URL_ERROR_MESSAGE),
}
}
}
#[cfg(test)]
mod test {
use std::{path::Path, str::FromStr};
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn bad_scheme_01() {
assert!(GitUrl::from_str("file://rel_repo.git").is_err());
}
#[test]
fn bad_scheme_02() {
assert!(GitUrl::from_str("file:///abs_repo.git").is_err());
}
#[test]
fn bad_scheme_03() {
assert!(GitUrl::from_str("ssh://example.com/repo.git").is_err());
}
#[test]
fn bad_scheme_04() {
assert!(GitUrl::from_str("http://example.com/repo.git").is_err());
}
#[test]
fn bad_query_params() {
assert!(GitUrl::from_str("https://example.com/repo.git?admin=1").is_err());
}
#[test]
fn ok_empty_path_01() {
let url = GitUrl::from_str("https://example.com").unwrap();
assert_eq!(url.to_path_buf(), Path::new("https/example.com"));
}
#[test]
fn ok_empty_path_02() {
let url = GitUrl::from_str("https://example.com/").unwrap();
assert_eq!(url.to_path_buf(), Path::new("https/example.com"));
}
#[test]
fn ok_01() {
let url = GitUrl::from_str("https://github.com/mongodb/mongo.git").unwrap();
assert_eq!(url.to_path_buf(), Path::new("https/github.com/mongodb/mongo.git"));
}
#[test]
fn ok_relpath_01() {
let url = GitUrl::from_str("https://example.com/../project.git").unwrap();
assert_eq!(url.to_path_buf(), Path::new("https/example.com/project.git"));
}
#[test]
fn ok_relpath_02() {
let url = GitUrl::from_str("https://example.com/root/../project.git").unwrap();
assert_eq!(url.to_path_buf(), Path::new("https/example.com/project.git"));
}
#[test]
fn ok_relpath_03() {
let url = GitUrl::from_str("https://example.com/root/..").unwrap();
assert_eq!(url.to_path_buf(), Path::new("https/example.com/"));
}
}