use thiserror::Error;
use url::Url;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum UrlAppendError {
#[error("URL cannot be a base: it has no hierarchical path to append segments to")]
NonSplittable,
#[error("path segments cannot be . or ..")]
DotSegment,
}
pub trait UrlAppendExt {
fn append<I, S>(&self, segments: I) -> Result<Url, UrlAppendError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>;
fn append_path(&self, path: &'static str) -> Result<Url, UrlAppendError> {
self.append(path.split('/').filter(|s| !s.is_empty()))
}
}
impl UrlAppendExt for Url {
fn append<I, S>(&self, segments: I) -> Result<Url, UrlAppendError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let trailing_empty = self.path_segments().map_or(0, |segments| {
segments.rev().take_while(|s| s.is_empty()).count()
});
let mut url = self.clone();
let mut path = url
.path_segments_mut()
.map_err(|()| UrlAppendError::NonSplittable)?;
for _ in 0..trailing_empty {
path.pop_if_empty();
}
for segment in segments {
let segment = segment.as_ref();
if matches!(segment, "." | "..") {
return Err(UrlAppendError::DotSegment);
}
path.push(segment);
}
drop(path);
Ok(url)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
fn parse(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[rstest]
#[case("https://api.atuin.sh", "me", "https://api.atuin.sh/me")]
#[case("https://api.atuin.sh/", "me", "https://api.atuin.sh/me")]
#[case("https://host.example/atuin", "api", "https://host.example/atuin/api")]
#[case("https://host.example/atuin/", "api", "https://host.example/atuin/api")]
#[case("https://h.example", "a/b", "https://h.example/a%2Fb")]
#[case(
"https://h.example/x",
"../../etc",
"https://h.example/x/..%2F..%2Fetc"
)]
#[case("https://h.example", "john doe", "https://h.example/john%20doe")]
#[case("https://h.example", "a?b#c", "https://h.example/a%3Fb%23c")]
#[case("https://h.example:8443/x", "y", "https://h.example:8443/x/y")]
#[case(
"https://host.example/atuin//",
"api",
"https://host.example/atuin/api"
)]
#[case(
"https://host.example/atuin////",
"api",
"https://host.example/atuin/api"
)]
#[case("https://h.example//", "me", "https://h.example/me")]
#[case("https://h.example////", "me", "https://h.example/me")]
#[case("https://h.example/a//b", "me", "https://h.example/a//b/me")]
#[case("https://h.example/a//b//", "me", "https://h.example/a//b/me")]
fn append_cases(#[case] base: &str, #[case] segment: &str, #[case] expected: &str) {
assert_eq!(parse(base).append([segment]).unwrap().as_str(), expected);
}
#[rstest]
#[case(".")]
#[case("..")]
fn dot_segments_are_rejected(#[case] segment: &str) {
assert_eq!(
parse("https://host.example/atuin").append([segment]),
Err(UrlAppendError::DotSegment),
);
assert_eq!(
parse("https://host.example/atuin").append(["user", segment]),
Err(UrlAppendError::DotSegment),
);
}
#[test]
fn dot_segments_are_rejected_by_append_path() {
assert_eq!(
parse("https://h.example").append_path("api/../v0"),
Err(UrlAppendError::DotSegment),
);
}
#[test]
fn dot_lookalikes_are_still_appended() {
assert_eq!(
parse("https://h.example")
.append(["...", ".hidden", "a.b"])
.unwrap()
.as_str(),
"https://h.example/.../.hidden/a.b",
);
}
#[test]
fn append_encodes_each_segment_independently() {
assert_eq!(
parse("https://h.example")
.append(["user", "john doe"])
.unwrap()
.as_str(),
"https://h.example/user/john%20doe",
);
assert_eq!(
parse("https://h.example")
.append(["a", "b/c"])
.unwrap()
.as_str(),
"https://h.example/a/b%2Fc",
);
}
#[rstest]
#[case("https://api.atuin.sh", "api/v0/me", "https://api.atuin.sh/api/v0/me")]
#[case(
"https://host.example/atuin",
"api/v0/me",
"https://host.example/atuin/api/v0/me"
)]
#[case(
"https://host.example/atuin/",
"api/v0/me",
"https://host.example/atuin/api/v0/me"
)]
#[case("https://h.example", "/api/v0/me", "https://h.example/api/v0/me")]
#[case("https://h.example", "account", "https://h.example/account")]
#[case("https://h.example//", "api/v0/me", "https://h.example/api/v0/me")]
#[case(
"https://host.example/atuin//",
"api/v0/me",
"https://host.example/atuin/api/v0/me"
)]
fn append_path_cases(#[case] base: &str, #[case] path: &'static str, #[case] expected: &str) {
assert_eq!(parse(base).append_path(path).unwrap().as_str(), expected);
}
#[test]
fn cannot_be_a_base_url_is_an_error() {
let url = parse("mailto:me@example.com");
assert_eq!(url.append(["x"]), Err(UrlAppendError::NonSplittable));
assert_eq!(url.append_path("a/b"), Err(UrlAppendError::NonSplittable));
}
}