Skip to content

Commit 302a2d8

Browse files
committed
fix: reject empty paths where needed, add Url::from_parts_as_alternative_form().
The new constructor allows to create URLs that represent paths which otherwise couldn't be valid URLs.
1 parent 40f7379 commit 302a2d8

File tree

5 files changed

+148
-36
lines changed

5 files changed

+148
-36
lines changed

Diff for: git-url/src/lib.rs

+22
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,28 @@ impl Url {
7171
.as_ref(),
7272
)
7373
}
74+
75+
/// Create a new instance from the given parts, which will be validated by parsing them back from its alternative form.
76+
pub fn from_parts_as_alternative_form(
77+
scheme: Scheme,
78+
user: Option<String>,
79+
host: Option<String>,
80+
port: Option<u16>,
81+
path: BString,
82+
) -> Result<Self, parse::Error> {
83+
parse(
84+
Url {
85+
scheme,
86+
user,
87+
host,
88+
port,
89+
path,
90+
serialize_alternative_form: true,
91+
}
92+
.to_bstring()
93+
.as_ref(),
94+
)
95+
}
7496
}
7597

7698
/// Modification

Diff for: git-url/src/parse.rs

+41-28
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ pub enum Error {
1818
Url(#[from] url::ParseError),
1919
#[error("Protocol {protocol:?} is not supported")]
2020
UnsupportedProtocol { protocol: String },
21-
#[error("Paths cannot be empty")]
22-
EmptyPath,
21+
#[error("urls require the path to the repository")]
22+
MissingResourceLocation,
23+
#[error("file urls require an absolute or relative path to the repository repository")]
24+
MissingRepositoryPath,
2325
#[error("\"{url}\" is not a valid local path")]
2426
NotALocalFile { url: BString },
2527
#[error("Relative URLs are not permitted: {url:?}")]
@@ -90,32 +92,43 @@ pub fn parse(input: &BStr) -> Result<crate::Url, Error> {
9092
let guessed_protocol = guess_protocol(input).ok_or_else(|| Error::NotALocalFile { url: input.into() })?;
9193
let path_without_file_protocol = input.strip_prefix(b"file://");
9294
if path_without_file_protocol.is_some() || (has_no_explicit_protocol(input) && guessed_protocol == "file") {
93-
return Ok(crate::Url {
94-
scheme: Scheme::File,
95-
path: path_without_file_protocol
96-
.map(|stripped_path| {
97-
#[cfg(windows)]
98-
{
99-
if stripped_path.starts_with(b"/") {
100-
input
101-
.to_str()
102-
.ok()
103-
.and_then(|url| {
104-
let path = url::Url::parse(url).ok()?.to_file_path().ok()?;
105-
path.is_absolute().then(|| git_path::into_bstr(path).into_owned())
106-
})
107-
.unwrap_or_else(|| stripped_path.into())
108-
} else {
109-
stripped_path.into()
110-
}
111-
}
112-
#[cfg(not(windows))]
113-
{
95+
let path: BString = path_without_file_protocol
96+
.map(|stripped_path| {
97+
#[cfg(windows)]
98+
{
99+
if stripped_path.starts_with(b"/") {
100+
input
101+
.to_str()
102+
.ok()
103+
.and_then(|url| {
104+
let path = url::Url::parse(url).ok()?.to_file_path().ok()?;
105+
path.is_absolute().then(|| git_path::into_bstr(path).into_owned())
106+
})
107+
.unwrap_or_else(|| stripped_path.into())
108+
} else {
114109
stripped_path.into()
115110
}
116-
})
117-
.unwrap_or_else(|| input.into()),
118-
serialize_alternative_form: !input.starts_with(b"file://"),
111+
}
112+
#[cfg(not(windows))]
113+
{
114+
stripped_path.into()
115+
}
116+
})
117+
.unwrap_or_else(|| input.into());
118+
if path.is_empty() {
119+
return Err(Error::MissingRepositoryPath);
120+
}
121+
let input_starts_with_file_protocol = input.starts_with(b"file://");
122+
if input_starts_with_file_protocol {
123+
let wanted = cfg!(windows).then(|| &[b'\\', b'/'] as &[_]).unwrap_or(&[b'/']);
124+
if !wanted.iter().any(|w| path.contains(w)) {
125+
return Err(Error::MissingRepositoryPath);
126+
}
127+
}
128+
return Ok(crate::Url {
129+
scheme: Scheme::File,
130+
path,
131+
serialize_alternative_form: !input_starts_with_file_protocol,
119132
..Default::default()
120133
});
121134
}
@@ -143,8 +156,8 @@ pub fn parse(input: &BStr) -> Result<crate::Url, Error> {
143156
url = url::Url::parse(&format!("ssh://{}", sanitize_for_protocol("ssh", url_str)))?;
144157
sanitized_scp = true;
145158
}
146-
if url.scheme() != "rad" && url.path().is_empty() {
147-
return Err(Error::EmptyPath);
159+
if url.path().is_empty() && ["ssh", "git"].contains(&url.scheme()) {
160+
return Err(Error::MissingResourceLocation);
148161
}
149162
if url.cannot_be_a_base() {
150163
return Err(Error::RelativeUrl { url: url.into() });

Diff for: git-url/tests/parse/file.rs

+37
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,43 @@ fn relative_file_path_without_protocol() -> crate::Result {
7979
Ok(())
8080
}
8181

82+
#[test]
83+
fn shortest_possible_absolute_path() -> crate::Result {
84+
let parsed = assert_url("/", url_alternate(Scheme::File, None, None, None, b"/"))?.to_bstring();
85+
assert_eq!(parsed, "/");
86+
let parsed = assert_url("file:///", url(Scheme::File, None, None, None, b"/"))?.to_bstring();
87+
assert_eq!(parsed, "file:///");
88+
Ok(())
89+
}
90+
91+
#[test]
92+
fn shortest_possible_relative_path() -> crate::Result {
93+
let parsed = assert_url("a", url_alternate(Scheme::File, None, None, None, b"a"))?.to_bstring();
94+
assert_eq!(parsed, "a");
95+
let parsed = assert_url("../", url_alternate(Scheme::File, None, None, None, b"../"))?.to_bstring();
96+
assert_eq!(parsed, "../");
97+
let parsed = assert_url("..\\", url_alternate(Scheme::File, None, None, None, b"..\\"))?.to_bstring();
98+
assert_eq!(parsed, "..\\");
99+
let parsed = assert_url("./", url_alternate(Scheme::File, None, None, None, b"./"))?.to_bstring();
100+
assert_eq!(parsed, "./");
101+
let parsed = assert_url(".", url_alternate(Scheme::File, None, None, None, b"."))?.to_bstring();
102+
assert_eq!(parsed, ".");
103+
let parsed = assert_url("..", url_alternate(Scheme::File, None, None, None, b".."))?.to_bstring();
104+
assert_eq!(parsed, "..");
105+
let parsed = assert_url("file://../", url(Scheme::File, None, None, None, b"../"))?.to_bstring();
106+
assert_eq!(parsed, "file://../");
107+
let parsed = assert_url("file://./", url(Scheme::File, None, None, None, b"./"))?.to_bstring();
108+
assert_eq!(parsed, "file://./");
109+
#[cfg(windows)]
110+
{
111+
let parsed = assert_url("file://.\\", url(Scheme::File, None, None, None, b".\\"))?.to_bstring();
112+
assert_eq!(parsed, "file://.\\");
113+
}
114+
let parsed = assert_url("file://a/", url(Scheme::File, None, None, None, b"a/"))?.to_bstring();
115+
assert_eq!(parsed, "file://a/");
116+
Ok(())
117+
}
118+
82119
#[test]
83120
fn interior_relative_file_path_without_protocol() -> crate::Result {
84121
let url = assert_url(

Diff for: git-url/tests/parse/invalid.rs

+26-3
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,41 @@
11
use crate::parse::assert_failure;
2+
use git_url::parse::Error;
23

34
#[test]
45
fn unknown_protocol() {
56
assert_failure("foo://host.xz/path/to/repo.git/", "Protocol \"foo\" is not supported")
67
}
78

89
#[test]
9-
fn missing_path() {
10-
assert_failure("ssh://host.xz", "Paths cannot be empty")
10+
fn ssh_missing_path() {
11+
assert_failure("ssh://host.xz", Error::MissingResourceLocation)
12+
}
13+
14+
#[test]
15+
fn git_missing_path() {
16+
assert_failure("git://host.xz", Error::MissingResourceLocation)
17+
}
18+
19+
#[test]
20+
fn file_missing_path() {
21+
assert_failure("file://", Error::MissingRepositoryPath);
22+
}
23+
24+
#[test]
25+
fn empty() {
26+
assert_failure("", Error::MissingRepositoryPath);
27+
assert_failure("file://..", Error::MissingRepositoryPath);
28+
assert_failure("file://.", Error::MissingRepositoryPath);
29+
#[cfg(not(windows))]
30+
{
31+
assert_failure("file://.\\", Error::MissingRepositoryPath);
32+
}
33+
assert_failure("file://a", Error::MissingRepositoryPath);
1134
}
1235

1336
#[test]
1437
fn missing_port_despite_indication() {
15-
assert_failure("ssh://host.xz:", "Paths cannot be empty")
38+
assert_failure("ssh://host.xz:", Error::MissingResourceLocation)
1639
}
1740

1841
#[test]

Diff for: git-url/tests/parse/mod.rs

+22-5
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ fn assert_url_roundtrip(url: &str, expected: git_url::Url) -> crate::Result {
2222
Ok(())
2323
}
2424

25-
fn assert_failure(url: &str, expected_err: &str) {
26-
assert_eq!(git_url::parse(url.into()).unwrap_err().to_string(), expected_err);
25+
fn assert_failure(url: &str, expected_err: impl ToString) {
26+
assert_eq!(
27+
git_url::parse(url.into()).unwrap_err().to_string(),
28+
expected_err.to_string()
29+
);
2730
}
2831

2932
fn url<'a, 'b>(
@@ -40,7 +43,7 @@ fn url<'a, 'b>(
4043
port.into(),
4144
path.into(),
4245
)
43-
.expect("valid")
46+
.unwrap_or_else(|err| panic!("'{}' failed: {err:?}", path.as_bstr()))
4447
}
4548

4649
fn url_alternate<'a, 'b>(
@@ -50,7 +53,14 @@ fn url_alternate<'a, 'b>(
5053
port: impl Into<Option<u16>>,
5154
path: &[u8],
5255
) -> git_url::Url {
53-
url(protocol, user, host, port, path).serialize_alternate_form(true)
56+
git_url::Url::from_parts_as_alternative_form(
57+
protocol,
58+
user.into().map(Into::into),
59+
host.into().map(Into::into),
60+
port.into(),
61+
path.into(),
62+
)
63+
.expect("valid")
5464
}
5565

5666
mod file;
@@ -80,7 +90,7 @@ mod radicle {
8090
mod http {
8191
use git_url::Scheme;
8292

83-
use crate::parse::{assert_url_roundtrip, url};
93+
use crate::parse::{assert_url, assert_url_roundtrip, url};
8494

8595
#[test]
8696
fn username_expansion_is_unsupported() -> crate::Result {
@@ -96,6 +106,13 @@ mod http {
96106
url(Scheme::Https, None, "github.com", None, b"/byron/gitoxide"),
97107
)
98108
}
109+
110+
#[test]
111+
fn http_missing_path() -> crate::Result {
112+
assert_url_roundtrip("http://host.xz/", url(Scheme::Http, None, "host.xz", None, b"/"))?;
113+
assert_url("http://host.xz", url(Scheme::Http, None, "host.xz", None, b"/"))?;
114+
Ok(())
115+
}
99116
}
100117
mod git {
101118
use git_url::Scheme;

0 commit comments

Comments
 (0)