Skip to main content

gix_url/
parse.rs

1use std::convert::Infallible;
2
3use bstr::{BStr, BString, ByteSlice};
4
5use crate::Scheme;
6
7/// The error returned by [parse()](crate::parse()).
8#[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/// The syntax used to interpret an input location.
39#[derive(Debug, Clone, Copy)]
40pub enum UrlKind {
41    /// A URL containing a `scheme://` separator.
42    Url,
43    /// An SCP-like SSH location such as `user@host:path`.
44    Scp,
45    /// A local filesystem path.
46    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    // TODO: url's may only contain `:/`, we should additionally check if the characters used for
67    //       protocol are all valid
68    if let Some(protocol_end) = input.find("://") {
69        return InputScheme::Url { protocol_end };
70    }
71
72    // Find colon, but skip over IPv6 brackets if present
73    let colon = if input.starts_with(b"[") {
74        // IPv6 address, find the closing bracket first
75        if let Some(bracket_end) = input.find_byte(b']') {
76            // Look for colon after the bracket
77            input[bracket_end + 1..]
78                .find_byte(b':')
79                .map(|pos| bracket_end + 1 + pos)
80        } else {
81            // No closing bracket, treat as regular search
82            input.find_byte(b':')
83        }
84    } else {
85        input.find_byte(b':')
86    };
87
88    if let Some(colon) = colon {
89        // allow user to select files containing a `:` by passing them as absolute or relative path
90        // this is behavior explicitly mentioned by the scp and git manuals
91        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    // Normalize empty path to "/" for http/https URLs only
132    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        // For SSH and Git protocols, strip leading '/' from paths starting with '~'
136        // e.g., "ssh://host/~repo" -> path is "~repo", not "/~repo"
137        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    // For SSH URLs, strip brackets from IPv6 addresses
151    let host = if scheme == Scheme::Ssh {
152        url.host.map(|mut h| {
153            // Bracketed IPv6 forms
154            if let Some(h2) = h.strip_prefix('[') {
155                if let Some(inner) = h2.strip_suffix("]:") {
156                    // "[::1]:" → "::1"
157                    h = inner.to_owned();
158                } else if let Some(inner) = h2.strip_suffix(']') {
159                    // "[::1]" → "::1"
160                    h = inner.to_owned();
161                }
162            } else {
163                // Non-bracketed host: strip a single trailing colon
164                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    // TODO: this incorrectly splits at IPv6 addresses, check for `[]` before splitting
193    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    // The path returned by the parsed url often has the wrong number of leading `/` characters but
205    // should never differ in any other way (ssh URLs should not contain a query or fragment part).
206    // To avoid the various off-by-one errors caused by the `/` characters, we keep using the path
207    // determined above and can therefore skip parsing it here as well.
208    // In SCP-like syntax `%` is literal host data, but the synthesized URL parser treats it as an escape introducer.
209    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    // For SCP-like SSH URLs, strip leading '/' from paths starting with '/~'
217    // e.g., "user@host:/~repo" -> path is "~repo", not "/~repo"
218    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    // For SCP-like SSH URLs, strip brackets from IPv6 addresses
229    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    // We cannot use the url crate to parse host and path because it special cases Windows
264    // driver letters. With the url crate an input of `file://x:/path/to/git` is parsed as empty
265    // host and with `x:/path/to/git` as path. This behavior is wrong for Git which only follows
266    // that rule on Windows and parses `x:` as host on Unix platforms. Additionally, the url crate
267    // does not account for Windows special UNC path support.
268
269    // TODO: implement UNC path special case
270    let windows_special_path = if cfg!(windows) {
271        // Inputs created via url::Url::from_file_path contain an additional `/` between the
272        // protocol and the absolute path. Make sure we ignore that first slash character to avoid
273        // producing invalid paths.
274        let input_after_protocol = if first_slash == 0 {
275            &input_after_protocol[1..]
276        } else {
277            input_after_protocol
278        };
279        // parse `file://x:/path/to/git` as explained above
280        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        // `file:///path/to/git` or a windows special case was triggered
291        None
292    } else {
293        // `file://host/path/to/git`
294        Some(&input_after_protocol[..first_slash])
295    };
296
297    // default behavior on Unix platforms and if no Windows special case was triggered
298    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            // If the parser rejected it as RelativeUrlWithoutBase, map to Error::RelativeUrl
341            // to match the expected error type for malformed URLs like "invalid:://"
342            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}