1use std::convert::Infallible;
2
3use bstr::{BStr, BString, ByteSlice};
4
5use crate::Scheme;
6
7#[derive(Debug, thiserror::Error)]
9#[expect(missing_docs)]
10pub enum Error {
11 #[error("{} \"{url}\" is not valid UTF-8", kind.as_str())]
12 Utf8 {
13 url: BString,
14 kind: UrlKind,
15 source: std::str::Utf8Error,
16 },
17 #[error("{} {url:?} can not be parsed as valid URL", kind.as_str())]
18 Url {
19 url: String,
20 kind: UrlKind,
21 source: crate::simple_url::UrlParseError,
22 },
23
24 #[error("The host portion of the following URL is too long ({} bytes, {len} bytes total): {truncated_url:?}", truncated_url.len())]
25 TooLong { truncated_url: BString, len: usize },
26 #[error("{} \"{url}\" does not specify a path to a repository", kind.as_str())]
27 MissingRepositoryPath { url: BString, kind: UrlKind },
28 #[error("URL {url:?} is relative which is not allowed in this context")]
29 RelativeUrl { url: String },
30}
31
32impl From<Infallible> for Error {
33 fn from(_: Infallible) -> Self {
34 unreachable!("Cannot actually happen, but it seems there can't be a blanket impl for this")
35 }
36}
37
38#[derive(Debug, Clone, Copy)]
40pub enum UrlKind {
41 Url,
43 Scp,
45 Local,
47}
48
49impl UrlKind {
50 fn as_str(&self) -> &'static str {
51 match self {
52 UrlKind::Url => "URL",
53 UrlKind::Scp => "SCP-like target",
54 UrlKind::Local => "local path",
55 }
56 }
57}
58
59pub(crate) enum InputScheme {
60 Url { protocol_end: usize },
61 Scp { colon: usize },
62 Local,
63}
64
65pub(crate) fn find_scheme(input: &BStr) -> InputScheme {
66 if let Some(protocol_end) = input.find("://") {
69 return InputScheme::Url { protocol_end };
70 }
71
72 let colon = if input.starts_with(b"[") {
74 if let Some(bracket_end) = input.find_byte(b']') {
76 input[bracket_end + 1..]
78 .find_byte(b':')
79 .map(|pos| bracket_end + 1 + pos)
80 } else {
81 input.find_byte(b':')
83 }
84 } else {
85 input.find_byte(b':')
86 };
87
88 if let Some(colon) = colon {
89 let explicitly_local = &input[..colon].contains(&b'/');
92 let dos_driver_letter = cfg!(windows) && input[..colon].len() == 1;
93
94 if !explicitly_local && !dos_driver_letter {
95 return InputScheme::Scp { colon };
96 }
97 }
98
99 InputScheme::Local
100}
101
102pub(crate) fn url(input: &BStr, protocol_end: usize) -> Result<crate::Url, Error> {
103 const MAX_LEN: usize = 1024;
104 let input_after_protocol = &input[protocol_end + "://".len()..];
105 let is_http = {
106 let scheme = &input[..protocol_end];
107 scheme.eq_ignore_ascii_case(b"http") || scheme.eq_ignore_ascii_case(b"https")
108 };
109 let bytes_to_path = input_after_protocol
110 .iter()
111 .filter(|b| !b.is_ascii_whitespace())
112 .skip_while(|b| **b == b'/' || **b == b'\\')
113 .position(|b| *b == b'/' || is_http && matches!(*b, b'?' | b'#'))
114 .unwrap_or(input_after_protocol.len());
115 if bytes_to_path > MAX_LEN || protocol_end > MAX_LEN {
116 return Err(Error::TooLong {
117 truncated_url: input[..(protocol_end + "://".len() + MAX_LEN).min(input.len())].into(),
118 len: input.len(),
119 });
120 }
121 let (input, url) = input_to_utf8_and_url(input, UrlKind::Url)?;
122 let scheme = Scheme::from(url.scheme.as_str());
123
124 if matches!(scheme, Scheme::Git | Scheme::Ssh) && url.path.is_empty() {
125 return Err(Error::MissingRepositoryPath {
126 url: input.into(),
127 kind: UrlKind::Url,
128 });
129 }
130
131 let path: BString = if url.path.is_empty() && matches!(scheme, Scheme::Http | Scheme::Https) {
133 "/".into()
134 } else if matches!(scheme, Scheme::Ssh | Scheme::Git) && url.path.starts_with("/~") {
135 url.path[1..].into()
138 } else {
139 url.path.into()
140 };
141
142 let user = if url.username.is_empty() && url.password.is_none() {
143 None
144 } else {
145 Some(url.username)
146 };
147 let password = url.password;
148 let port = url.port;
149
150 let host = if scheme == Scheme::Ssh {
152 url.host.map(|mut h| {
153 if let Some(h2) = h.strip_prefix('[') {
155 if let Some(inner) = h2.strip_suffix("]:") {
156 h = inner.to_owned();
158 } else if let Some(inner) = h2.strip_suffix(']') {
159 h = inner.to_owned();
161 }
162 } else {
163 let colon_count = h.chars().filter(|&c| c == ':').take(2).count();
165 if colon_count == 1 {
166 if let Some(inner) = h.strip_suffix(':') {
167 h = inner.to_string();
168 }
169 }
170 }
171 h
172 })
173 } else {
174 url.host
175 };
176 let path_with_percent_escapes = url.path_with_percent_escapes.map(Into::into);
177 Ok(crate::Url {
178 serialize_alternative_form: false,
179 path_with_percent_escapes,
180 scheme,
181 user,
182 password,
183 host,
184 port,
185 path,
186 })
187}
188
189pub(crate) fn scp(input: &BStr, colon: usize) -> Result<crate::Url, Error> {
190 let input = input_to_utf8(input, UrlKind::Scp)?;
191
192 let (host, path) = input.split_at(colon);
194 debug_assert_eq!(path.get(..1), Some(":"), "{path} should start with :");
195 let path = &path[1..];
196
197 if path.is_empty() {
198 return Err(Error::MissingRepositoryPath {
199 url: input.to_owned().into(),
200 kind: UrlKind::Scp,
201 });
202 }
203
204 let url_string = format!("ssh://{}", host.replace('%', "%25"));
210 let url = crate::simple_url::ParsedUrl::parse(&url_string).map_err(|source| Error::Url {
211 url: input.to_owned(),
212 kind: UrlKind::Scp,
213 source,
214 })?;
215
216 let path = if path.starts_with("/~") { &path[1..] } else { path };
219
220 let user = if url.username.is_empty() && url.password.is_none() {
221 None
222 } else {
223 Some(url.username)
224 };
225 let password = url.password;
226 let port = url.port;
227
228 let host = url.host.map(|h| {
230 if let Some(h) = h.strip_prefix("[").and_then(|h| h.strip_suffix("]")) {
231 h.to_string()
232 } else {
233 h
234 }
235 });
236
237 Ok(crate::Url {
238 serialize_alternative_form: true,
239 path_with_percent_escapes: None,
240 scheme: Scheme::from(url.scheme.as_str()),
241 user,
242 password,
243 host,
244 port,
245 path: path.into(),
246 })
247}
248
249pub(crate) fn file_url(input: &BStr, protocol_colon: usize) -> Result<crate::Url, Error> {
250 let input = input_to_utf8(input, UrlKind::Url)?;
251 let input_after_protocol = &input[protocol_colon + "://".len()..];
252
253 let Some(first_slash) = input_after_protocol
254 .find('/')
255 .or_else(|| cfg!(windows).then(|| input_after_protocol.find('\\')).flatten())
256 else {
257 return Err(Error::MissingRepositoryPath {
258 url: input.to_owned().into(),
259 kind: UrlKind::Url,
260 });
261 };
262
263 let windows_special_path = if cfg!(windows) {
271 let input_after_protocol = if first_slash == 0 {
275 &input_after_protocol[1..]
276 } else {
277 input_after_protocol
278 };
279 if input_after_protocol.chars().nth(1) == Some(':') {
281 Some(input_after_protocol)
282 } else {
283 None
284 }
285 } else {
286 None
287 };
288
289 let host = if windows_special_path.is_some() || first_slash == 0 {
290 None
292 } else {
293 Some(&input_after_protocol[..first_slash])
295 };
296
297 let path = windows_special_path.unwrap_or(&input_after_protocol[first_slash..]);
299
300 Ok(crate::Url {
301 serialize_alternative_form: false,
302 host: host.map(Into::into),
303 ..local(path.into())?
304 })
305}
306
307pub(crate) fn local(input: &BStr) -> Result<crate::Url, Error> {
308 if input.is_empty() {
309 return Err(Error::MissingRepositoryPath {
310 url: input.to_owned(),
311 kind: UrlKind::Local,
312 });
313 }
314
315 Ok(crate::Url {
316 serialize_alternative_form: true,
317 path_with_percent_escapes: None,
318 scheme: Scheme::File,
319 password: None,
320 user: None,
321 host: None,
322 port: None,
323 path: input.to_owned(),
324 })
325}
326
327fn input_to_utf8(input: &BStr, kind: UrlKind) -> Result<&str, Error> {
328 std::str::from_utf8(input).map_err(|source| Error::Utf8 {
329 url: input.to_owned(),
330 kind,
331 source,
332 })
333}
334
335fn input_to_utf8_and_url(input: &BStr, kind: UrlKind) -> Result<(&str, crate::simple_url::ParsedUrl), Error> {
336 let input = input_to_utf8(input, kind)?;
337 crate::simple_url::ParsedUrl::parse(input)
338 .map(|url| (input, url))
339 .map_err(|source| {
340 match source {
343 crate::simple_url::UrlParseError::RelativeUrlWithoutBase => {
344 Error::RelativeUrl { url: input.to_owned() }
345 }
346 _ => Error::Url {
347 url: input.to_owned(),
348 kind,
349 source,
350 },
351 }
352 })
353}