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 #[error("{name:?} is not a valid remote-helper name")]
31 InvalidRemoteHelperName { name: String },
32}
33
34impl From<Infallible> for Error {
35 fn from(_: Infallible) -> Self {
36 unreachable!("Cannot actually happen, but it seems there can't be a blanket impl for this")
37 }
38}
39
40#[derive(Debug, Clone, Copy)]
42pub enum UrlKind {
43 Url,
45 Scp,
47 Local,
49}
50
51impl UrlKind {
52 fn as_str(&self) -> &'static str {
53 match self {
54 UrlKind::Url => "URL",
55 UrlKind::Scp => "SCP-like target",
56 UrlKind::Local => "local path",
57 }
58 }
59}
60
61pub(crate) enum InputScheme {
62 Url { protocol_end: usize },
63 Scp { colon: usize },
64 Local,
65 RemoteHelper { helper_end: usize },
66}
67
68pub(crate) fn is_valid_remote_helper_name(input: &[u8]) -> bool {
71 input.first().is_some_and(u8::is_ascii_alphanumeric)
72 && input[1..]
73 .iter()
74 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
75}
76
77fn find_remote_helper_end(input: &BStr) -> Option<usize> {
78 let helper_end = input.find("::")?;
79 is_valid_remote_helper_name(&input[..helper_end]).then_some(helper_end)
81}
82
83pub(crate) fn find_scheme(input: &BStr) -> InputScheme {
84 if let Some(helper_end) = find_remote_helper_end(input) {
87 return InputScheme::RemoteHelper { helper_end };
88 }
89
90 if let Some(protocol_end) = input.find("://") {
93 return InputScheme::Url { protocol_end };
94 }
95
96 let colon = if input.starts_with(b"[") {
98 if let Some(bracket_end) = input.find_byte(b']') {
100 input[bracket_end + 1..]
102 .find_byte(b':')
103 .map(|pos| bracket_end + 1 + pos)
104 } else {
105 input.find_byte(b':')
107 }
108 } else {
109 input.find_byte(b':')
110 };
111
112 if let Some(colon) = colon {
113 let explicitly_local = &input[..colon].contains(&b'/');
116 let dos_driver_letter = cfg!(windows) && input[..colon].len() == 1;
117
118 if !explicitly_local && !dos_driver_letter {
119 return InputScheme::Scp { colon };
120 }
121 }
122
123 InputScheme::Local
124}
125
126pub(crate) fn remote_helper(input: &BStr, helper_end: usize) -> crate::Url {
129 let helper = input[..helper_end]
130 .to_str()
131 .expect("remote helper names consist of ASCII characters only");
132 crate::Url {
133 serialize_alternative_form: true,
134 path_with_percent_escapes: None,
135 scheme: if helper == "ext" {
137 Scheme::Ext
138 } else {
139 Scheme::Helper(helper.to_owned())
140 },
141 user: None,
142 password: None,
143 host: None,
144 port: None,
145 path: input[helper_end + "::".len()..].into(),
147 }
148}
149
150pub(crate) fn url(input: &BStr, protocol_end: usize) -> Result<crate::Url, Error> {
151 const MAX_LEN: usize = 1024;
152 let input_after_protocol = &input[protocol_end + "://".len()..];
153 let scheme = &input[..protocol_end];
154 let is_http = scheme == "http" || scheme == "https";
155 let bytes_to_path = input_after_protocol
156 .iter()
157 .filter(|b| !b.is_ascii_whitespace())
158 .skip_while(|b| **b == b'/' || **b == b'\\')
159 .position(|b| *b == b'/' || is_http && matches!(*b, b'?' | b'#'))
160 .unwrap_or(input_after_protocol.len());
161 if bytes_to_path > MAX_LEN || protocol_end > MAX_LEN {
162 return Err(Error::TooLong {
163 truncated_url: input[..(protocol_end + "://".len() + MAX_LEN).min(input.len())].into(),
164 len: input.len(),
165 });
166 }
167 let (input, url) = input_to_utf8_and_url(input, UrlKind::Url)?;
168 if url.scheme == "ext" {
169 return Ok(crate::Url {
170 serialize_alternative_form: true,
171 path_with_percent_escapes: None,
172 scheme: Scheme::Ext,
173 user: None,
174 password: None,
175 host: None,
176 port: None,
177 path: input.into(),
180 });
181 }
182 let scheme = Scheme::from(url.scheme.as_str());
183
184 if matches!(scheme, Scheme::Git | Scheme::Ssh) && url.path.is_empty() {
185 return Err(Error::MissingRepositoryPath {
186 url: input.into(),
187 kind: UrlKind::Url,
188 });
189 }
190
191 let path: BString = if url.path.is_empty() && matches!(scheme, Scheme::Http | Scheme::Https) {
193 "/".into()
194 } else if matches!(scheme, Scheme::Ssh | Scheme::Git) && url.path.starts_with("/~") {
195 url.path[1..].into()
198 } else {
199 url.path.into()
200 };
201
202 let user = if url.username.is_empty() && url.password.is_none() {
203 None
204 } else {
205 Some(url.username)
206 };
207 let password = url.password;
208 let port = url.port;
209
210 let host = if scheme == Scheme::Ssh {
212 url.host.map(|mut h| {
213 if let Some(h2) = h.strip_prefix('[') {
215 if let Some(inner) = h2.strip_suffix("]:") {
216 h = inner.to_owned();
218 } else if let Some(inner) = h2.strip_suffix(']') {
219 h = inner.to_owned();
221 }
222 } else {
223 let colon_count = h.chars().filter(|&c| c == ':').take(2).count();
225 if colon_count == 1 {
226 if let Some(inner) = h.strip_suffix(':') {
227 h = inner.to_string();
228 }
229 }
230 }
231 h
232 })
233 } else {
234 url.host
235 };
236 let path_with_percent_escapes = url.path_with_percent_escapes.map(Into::into);
237 Ok(crate::Url {
238 serialize_alternative_form: false,
239 path_with_percent_escapes,
240 scheme,
241 user,
242 password,
243 host,
244 port,
245 path,
246 })
247}
248
249pub(crate) fn scp(input: &BStr, colon: usize) -> Result<crate::Url, Error> {
250 let input = input_to_utf8(input, UrlKind::Scp)?;
251
252 let (host, path) = input.split_at(colon);
254 debug_assert_eq!(path.get(..1), Some(":"), "{path} should start with :");
255 let path = &path[1..];
256
257 if path.is_empty() {
258 return Err(Error::MissingRepositoryPath {
259 url: input.to_owned().into(),
260 kind: UrlKind::Scp,
261 });
262 }
263
264 let (user, host) = host
271 .rsplit_once('@')
272 .map_or((None, host), |(user, host)| (Some(user.to_owned()), host));
273 let url_string = format!("ssh://{}", host.replace('%', "%25"));
275 let url = crate::simple_url::ParsedUrl::parse(&url_string).map_err(|source| Error::Url {
276 url: input.to_owned(),
277 kind: UrlKind::Scp,
278 source,
279 })?;
280
281 let path = if path.starts_with("/~") { &path[1..] } else { path };
284
285 let port = url.port;
286
287 let host = url.host.map(|h| {
289 if let Some(h) = h.strip_prefix("[").and_then(|h| h.strip_suffix("]")) {
290 h.to_string()
291 } else {
292 h
293 }
294 });
295
296 Ok(crate::Url {
297 serialize_alternative_form: true,
298 path_with_percent_escapes: None,
299 scheme: Scheme::from(url.scheme.as_str()),
300 user,
301 password: None,
302 host,
303 port,
304 path: path.into(),
305 })
306}
307
308pub(crate) fn file_url(input: &BStr, protocol_colon: usize) -> Result<crate::Url, Error> {
309 let input = input_to_utf8(input, UrlKind::Url)?;
310 let input_after_protocol = &input[protocol_colon + "://".len()..];
311
312 let Some(first_slash) = input_after_protocol
313 .find('/')
314 .or_else(|| cfg!(windows).then(|| input_after_protocol.find('\\')).flatten())
315 else {
316 return Err(Error::MissingRepositoryPath {
317 url: input.to_owned().into(),
318 kind: UrlKind::Url,
319 });
320 };
321
322 let windows_special_path = if cfg!(windows) {
330 let input_after_protocol = if first_slash == 0 {
334 &input_after_protocol[1..]
335 } else {
336 input_after_protocol
337 };
338 if input_after_protocol.chars().nth(1) == Some(':') {
340 Some(input_after_protocol)
341 } else {
342 None
343 }
344 } else {
345 None
346 };
347
348 let host = if windows_special_path.is_some() || first_slash == 0 {
349 None
351 } else {
352 Some(&input_after_protocol[..first_slash])
354 };
355
356 let path = windows_special_path.unwrap_or(&input_after_protocol[first_slash..]);
358
359 Ok(crate::Url {
360 serialize_alternative_form: false,
361 host: host.map(Into::into),
362 ..local(path.into())?
363 })
364}
365
366pub(crate) fn local(input: &BStr) -> Result<crate::Url, Error> {
367 if input.is_empty() {
368 return Err(Error::MissingRepositoryPath {
369 url: input.to_owned(),
370 kind: UrlKind::Local,
371 });
372 }
373
374 Ok(crate::Url {
375 serialize_alternative_form: true,
376 path_with_percent_escapes: None,
377 scheme: Scheme::File,
378 password: None,
379 user: None,
380 host: None,
381 port: None,
382 path: input.to_owned(),
383 })
384}
385
386fn input_to_utf8(input: &BStr, kind: UrlKind) -> Result<&str, Error> {
387 std::str::from_utf8(input).map_err(|source| Error::Utf8 {
388 url: input.to_owned(),
389 kind,
390 source,
391 })
392}
393
394fn input_to_utf8_and_url(input: &BStr, kind: UrlKind) -> Result<(&str, crate::simple_url::ParsedUrl), Error> {
395 let input = input_to_utf8(input, kind)?;
396 crate::simple_url::ParsedUrl::parse(input)
397 .map(|url| (input, url))
398 .map_err(|source| {
399 match source {
402 crate::simple_url::UrlParseError::RelativeUrlWithoutBase => {
403 Error::RelativeUrl { url: input.to_owned() }
404 }
405 _ => Error::Url {
406 url: input.to_owned(),
407 kind,
408 source,
409 },
410 }
411 })
412}