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    #[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/// The syntax used to interpret an input location.
41#[derive(Debug, Clone, Copy)]
42pub enum UrlKind {
43    /// A URL containing a `scheme://` separator.
44    Url,
45    /// An SCP-like SSH location such as `user@host:path`.
46    Scp,
47    /// A local filesystem path.
48    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
68/// Return the length of the leading remote-helper name if `input` uses the `<helper>::<address>` syntax
69/// of [`gitremote-helpers`](https://git-scm.com/docs/gitremote-helpers).
70pub(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    // Unlike Git, empty helper names are not accepted.
80    is_valid_remote_helper_name(&input[..helper_end]).then_some(helper_end)
81}
82
83pub(crate) fn find_scheme(input: &BStr) -> InputScheme {
84    // Git looks for the `<helper>::<address>` form, which happens before the location is examined as a URL.
85    // Hence this has to be checked first as well, as the address of a helper may itself contain `://`.
86    if let Some(helper_end) = find_remote_helper_end(input) {
87        return InputScheme::RemoteHelper { helper_end };
88    }
89
90    // TODO: url's may only contain `:/`, we should additionally check if the characters used for
91    //       protocol are all valid
92    if let Some(protocol_end) = input.find("://") {
93        return InputScheme::Url { protocol_end };
94    }
95
96    // Find colon, but skip over IPv6 brackets if present
97    let colon = if input.starts_with(b"[") {
98        // IPv6 address, find the closing bracket first
99        if let Some(bracket_end) = input.find_byte(b']') {
100            // Look for colon after the bracket
101            input[bracket_end + 1..]
102                .find_byte(b':')
103                .map(|pos| bracket_end + 1 + pos)
104        } else {
105            // No closing bracket, treat as regular search
106            input.find_byte(b':')
107        }
108    } else {
109        input.find_byte(b':')
110    };
111
112    if let Some(colon) = colon {
113        // allow user to select files containing a `:` by passing them as absolute or relative path
114        // this is behavior explicitly mentioned by the scp and git manuals
115        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
126/// Parse remote-helper syntax like `codecommit::eu-central-1://repository`, with `helper_end` pointing at the first
127/// colon. This is only for the `<helper>::<address>` form; `<helper>://<address>` is parsed as a URL instead.
128pub(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        // `ext` is special because callers need to know that its address is an executable command line.
136        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        // The address is only meaningful to the helper program, so it's kept verbatim.
146        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            // Git passes the entire URL where git-remote-ext expects its command line. Keeping it verbatim makes
178            // `ext::ext://...` serialization preserve that argument while normalizing all uses to `Scheme::Ext`.
179            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    // Normalize empty path to "/" for http/https URLs only
192    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        // For SSH and Git protocols, strip leading '/' from paths starting with '~'
196        // e.g., "ssh://host/~repo" -> path is "~repo", not "/~repo"
197        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    // For SSH URLs, strip brackets from IPv6 addresses
211    let host = if scheme == Scheme::Ssh {
212        url.host.map(|mut h| {
213            // Bracketed IPv6 forms
214            if let Some(h2) = h.strip_prefix('[') {
215                if let Some(inner) = h2.strip_suffix("]:") {
216                    // "[::1]:" → "::1"
217                    h = inner.to_owned();
218                } else if let Some(inner) = h2.strip_suffix(']') {
219                    // "[::1]" → "::1"
220                    h = inner.to_owned();
221                }
222            } else {
223                // Non-bracketed host: strip a single trailing colon
224                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    // TODO: this incorrectly splits at IPv6 addresses, check for `[]` before splitting
253    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    // The path returned by the parsed url often has the wrong number of leading `/` characters but
265    // should never differ in any other way (ssh URLs should not contain a query or fragment part).
266    // To avoid the various off-by-one errors caused by the `/` characters, we keep using the path
267    // determined above and can therefore skip parsing it here as well.
268    // Split at the last `@`, just as OpenSSH does. Feeding the user through URL parsing would mistake `:` for a
269    // password delimiter even though SCP-like syntax cannot represent passwords.
270    let (user, host) = host
271        .rsplit_once('@')
272        .map_or((None, host), |(user, host)| (Some(user.to_owned()), host));
273    // In SCP-like syntax `%` is literal host data, but the synthesized URL parser treats it as an escape introducer.
274    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    // For SCP-like SSH URLs, strip leading '/' from paths starting with '/~'
282    // e.g., "user@host:/~repo" -> path is "~repo", not "/~repo"
283    let path = if path.starts_with("/~") { &path[1..] } else { path };
284
285    let port = url.port;
286
287    // For SCP-like SSH URLs, strip brackets from IPv6 addresses
288    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    // We cannot use the url crate to parse host and path because it special cases Windows
323    // driver letters. With the url crate an input of `file://x:/path/to/git` is parsed as empty
324    // host and with `x:/path/to/git` as path. This behavior is wrong for Git which only follows
325    // that rule on Windows and parses `x:` as host on Unix platforms. Additionally, the url crate
326    // does not account for Windows special UNC path support.
327
328    // TODO: implement UNC path special case
329    let windows_special_path = if cfg!(windows) {
330        // Inputs created via url::Url::from_file_path contain an additional `/` between the
331        // protocol and the absolute path. Make sure we ignore that first slash character to avoid
332        // producing invalid paths.
333        let input_after_protocol = if first_slash == 0 {
334            &input_after_protocol[1..]
335        } else {
336            input_after_protocol
337        };
338        // parse `file://x:/path/to/git` as explained above
339        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        // `file:///path/to/git` or a windows special case was triggered
350        None
351    } else {
352        // `file://host/path/to/git`
353        Some(&input_after_protocol[..first_slash])
354    };
355
356    // default behavior on Unix platforms and if no Windows special case was triggered
357    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            // If the parser rejected it as RelativeUrlWithoutBase, map to Error::RelativeUrl
400            // to match the expected error type for malformed URLs like "invalid:://"
401            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}