Skip to main content

grit_lib/
transport_path.rs

1//! Safety checks for local transport URLs (matches Git `connect.c` / `path.c`).
2
3use thiserror::Error;
4
5/// Errors returned while validating local transport paths.
6#[derive(Clone, Debug, Error, PartialEq, Eq)]
7pub enum TransportPathError {
8    /// A repository path begins with `-` and could be interpreted as a command option.
9    #[error("fatal: strange pathname '{0}' blocked")]
10    OptionLikePath(String),
11}
12
13/// Returns true when `s` is non-empty and begins with `-`, matching Git's
14/// `looks_like_command_line_option` (used before quoting a path for shell-backed transport).
15#[must_use]
16pub fn looks_like_command_line_option(s: &str) -> bool {
17    !s.is_empty() && s.starts_with('-')
18}
19
20/// Rejects repository path strings that could be mistaken for options when passed to a shell.
21///
22/// Git dies with `strange pathname '%s' blocked` when the parsed local path starts with `-`.
23/// Absolute paths like `/tmp/-repo.git` are allowed because the path string begins with `/`.
24pub fn check_local_url_path_not_option_like(url: &str) -> Result<(), TransportPathError> {
25    let path = url
26        .strip_prefix("file://")
27        .unwrap_or(url)
28        .split('?')
29        .next()
30        .unwrap_or("");
31    if looks_like_command_line_option(path) {
32        return Err(TransportPathError::OptionLikePath(path.to_owned()));
33    }
34    Ok(())
35}
36
37/// Error returned by [`git_url_basename`] when no directory name can be derived from a URL.
38#[derive(Clone, Debug, Error, PartialEq, Eq)]
39#[error("No directory name could be guessed.\nPlease specify a directory on the command line")]
40pub struct NoDirectoryName;
41
42/// POSIX directory separator test, matching Git's `is_dir_sep` on non-Windows builds.
43#[inline]
44fn is_dir_sep(b: u8) -> bool {
45    b == b'/'
46}
47
48/// Derive the "humanish" directory name Git would use for `git clone <repo>` when no explicit
49/// target directory is given. This is a faithful port of Git's `git_url_basename`
50/// (`git/dir.c`): it operates on the **raw URL string** (not a pre-parsed path), so it can
51/// fall back to the hostname when the URL has no path component (e.g. `ssh://host/` → `host`).
52///
53/// # Parameters
54/// - `repo`: the raw repository URL or path exactly as passed on the command line.
55/// - `is_bundle`: strip a trailing `.bundle` suffix instead of `.git`.
56/// - `is_bare`: append `.git` to the guessed name (for `--bare` clones).
57///
58/// # Errors
59/// Returns [`NoDirectoryName`] when the URL collapses to an empty (or single-slash) name, which
60/// is the condition under which Git dies asking for an explicit directory.
61///
62/// Note: callers handle the `--mirror` exception (bare clone without a `.git` directory suffix)
63/// by passing `is_bare = false`, matching Git's `option_bare && !option_mirror` logic.
64pub fn git_url_basename(
65    repo: &str,
66    is_bundle: bool,
67    is_bare: bool,
68) -> Result<String, NoDirectoryName> {
69    let bytes = repo.as_bytes();
70    let mut end = bytes.len();
71
72    // Skip scheme (everything up to and including "://").
73    let mut start = match repo.find("://") {
74        Some(idx) => idx + 3,
75        None => 0,
76    };
77
78    // Skip authentication data, greedily up to the last '@' before the first dir separator.
79    let mut ptr = start;
80    while ptr < end && !is_dir_sep(bytes[ptr]) {
81        if bytes[ptr] == b'@' {
82            start = ptr + 1;
83        }
84        ptr += 1;
85    }
86
87    // Strip trailing spaces, slashes and a trailing "/.git".
88    while start < end && (is_dir_sep(bytes[end - 1]) || bytes[end - 1].is_ascii_whitespace()) {
89        end -= 1;
90    }
91    if end > start + 5 && is_dir_sep(bytes[end - 5]) && &bytes[end - 4..end] == b".git" {
92        end -= 5;
93        while start < end && is_dir_sep(bytes[end - 1]) {
94            end -= 1;
95        }
96    }
97
98    if end < start {
99        return Err(NoDirectoryName);
100    }
101
102    // Strip a trailing port number when we have only a hostname (no dir separator but a colon).
103    // This must NOT strip URIs like '/foo/bar:2222.git', which should guess dir '2222' for
104    // backwards compatibility.
105    let slice = &bytes[start..end];
106    if !slice.contains(&b'/') && slice.contains(&b':') {
107        let mut p = end;
108        while start < p && bytes[p - 1].is_ascii_digit() && bytes[p - 1] != b':' {
109            p -= 1;
110        }
111        if start < p && bytes[p - 1] == b':' {
112            end = p - 1;
113        }
114    }
115
116    // Find last component; colons also act as separators for backwards compatibility
117    // (`foo:bar.git` → `bar`).
118    let mut p = end;
119    while start < p && !is_dir_sep(bytes[p - 1]) && bytes[p - 1] != b':' {
120        p -= 1;
121    }
122    start = p;
123
124    // Strip a trailing .{bundle,git}.
125    let suffix: &[u8] = if is_bundle { b".bundle" } else { b".git" };
126    let mut len = end - start;
127    if len >= suffix.len() && &bytes[start + len - suffix.len()..start + len] == suffix {
128        len -= suffix.len();
129    }
130
131    if len == 0 || (len == 1 && bytes[start] == b'/') {
132        return Err(NoDirectoryName);
133    }
134
135    let core = &repo[start..start + len];
136    let mut dir = if is_bare {
137        format!("{core}.git")
138    } else {
139        core.to_string()
140    };
141
142    dir = collapse_control_and_whitespace(&dir);
143    Ok(dir)
144}
145
146/// Replace runs of control characters and whitespace in a guessed directory name with a single
147/// ASCII space, then strip leading and trailing spaces — mirroring the final pass of Git's
148/// `git_url_basename`.
149fn collapse_control_and_whitespace(dir: &str) -> String {
150    let mut out = String::with_capacity(dir.len());
151    let mut prev_space = true; // strip leading whitespace
152    for &b in dir.as_bytes() {
153        let ch = if b < 0x20 { b' ' } else { b };
154        if ch.is_ascii_whitespace() {
155            if prev_space {
156                continue;
157            }
158            prev_space = true;
159        } else {
160            prev_space = false;
161        }
162        out.push(ch as char);
163    }
164    if prev_space {
165        while out.ends_with(' ') {
166            out.pop();
167        }
168    }
169    out
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn basename(url: &str) -> String {
177        git_url_basename(url, false, false).expect("dir name")
178    }
179
180    #[test]
181    fn scp_style_basic() {
182        assert_eq!(basename("host:foo"), "foo");
183        assert_eq!(basename("host:foo.git"), "foo");
184        assert_eq!(basename("host:foo/.git"), "foo");
185    }
186
187    #[test]
188    fn ssh_url_basic() {
189        assert_eq!(basename("ssh://host/foo"), "foo");
190        assert_eq!(basename("ssh://host/foo.git"), "foo");
191        assert_eq!(basename("ssh://host/foo/.git"), "foo");
192    }
193
194    #[test]
195    fn trailing_slashes_and_git() {
196        assert_eq!(basename("ssh://host/foo/"), "foo");
197        assert_eq!(basename("ssh://host/foo///"), "foo");
198        assert_eq!(basename("ssh://host/foo/.git/"), "foo");
199        assert_eq!(basename("ssh://host/foo.git/"), "foo");
200        assert_eq!(basename("ssh://host/foo.git///"), "foo");
201        assert_eq!(basename("ssh://host/foo///.git/"), "foo");
202        assert_eq!(basename("ssh://host/foo/.git///"), "foo");
203
204        assert_eq!(basename("host:foo/"), "foo");
205        assert_eq!(basename("host:foo///"), "foo");
206        assert_eq!(basename("host:foo.git/"), "foo");
207        assert_eq!(basename("host:foo/.git/"), "foo");
208        assert_eq!(basename("host:foo.git///"), "foo");
209        assert_eq!(basename("host:foo///.git/"), "foo");
210        assert_eq!(basename("host:foo/.git///"), "foo");
211    }
212
213    #[test]
214    fn empty_path_defaults_to_hostname() {
215        assert_eq!(basename("ssh://host/"), "host");
216        assert_eq!(basename("ssh://host:1234/"), "host");
217        assert_eq!(basename("ssh://user@host/"), "host");
218        assert_eq!(basename("host:/"), "host");
219    }
220
221    #[test]
222    fn auth_material_is_redacted() {
223        assert_eq!(basename("ssh://user:password@host/"), "host");
224        assert_eq!(basename("ssh://user:password@host:1234/"), "host");
225        assert_eq!(basename("ssh://user:passw@rd@host:1234/"), "host");
226        assert_eq!(basename("user@host:/"), "host");
227        assert_eq!(basename("user:password@host:/"), "host");
228        assert_eq!(basename("user:passw@rd@host:/"), "host");
229    }
230
231    #[test]
232    fn auth_like_material_kept_in_path() {
233        assert_eq!(basename("ssh://host/foo@bar"), "foo@bar");
234        assert_eq!(basename("ssh://host/foo@bar.git"), "foo@bar");
235        assert_eq!(basename("ssh://user:password@host/foo@bar"), "foo@bar");
236        assert_eq!(basename("ssh://user:passw@rd@host/foo@bar.git"), "foo@bar");
237        assert_eq!(basename("host:/foo@bar"), "foo@bar");
238        assert_eq!(basename("host:/foo@bar.git"), "foo@bar");
239        assert_eq!(basename("user:password@host:/foo@bar"), "foo@bar");
240        assert_eq!(basename("user:passw@rd@host:/foo@bar.git"), "foo@bar");
241    }
242
243    #[test]
244    fn trailing_port_like_numbers_in_path_kept() {
245        assert_eq!(basename("ssh://user:password@host/test:1234"), "1234");
246        assert_eq!(basename("ssh://user:password@host/test:1234.git"), "1234");
247    }
248
249    #[test]
250    fn bare_appends_git() {
251        assert_eq!(
252            git_url_basename("host:foo", false, true).unwrap(),
253            "foo.git"
254        );
255        assert_eq!(
256            git_url_basename("host:foo.git", false, true).unwrap(),
257            "foo.git"
258        );
259    }
260
261    #[test]
262    fn empty_name_is_error() {
263        assert_eq!(git_url_basename("/", false, false), Err(NoDirectoryName));
264        assert_eq!(git_url_basename("", false, false), Err(NoDirectoryName));
265    }
266}