Skip to main content

git_lfs_creds/
query.rs

1//! Per-request input to the credential helpers.
2
3use url::Url;
4
5/// The fields `git credential` expects on stdin, and that [`Helper`]
6/// implementations key on.
7///
8/// Mirrors the subset of git-credential input upstream LFS sends:
9/// `protocol`, `host`, `path`. `username` is intentionally not
10/// pre-populated from the URL; helpers may fill it in themselves.
11///
12/// [`Helper`]: crate::Helper
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct Query {
15    /// URL scheme (`https`, `http`, `ssh`, …).
16    pub protocol: String,
17    /// Host portion, with `:port` suffix when the URL specifies one.
18    pub host: String,
19    /// Path portion. Empty string means "no path", which omits the
20    /// `path=` line on stdin to git-credential. Helpers that key on path
21    /// (e.g. `credential.<url>.useHttpPath`) only see this when the
22    /// caller decides to pass it.
23    pub path: String,
24}
25
26impl Query {
27    /// Build a query from a URL.
28    ///
29    /// `path` is included by default; callers that want host-only
30    /// matching (the upstream default) should clear it via
31    /// [`Self::without_path`]. The path is **percent-decoded** to
32    /// match what upstream LFS sends to `git credential` (Go's
33    /// `url.URL.Path` is the decoded form), which lets
34    /// `git credential`'s `protectProtocol` check see real
35    /// newlines / NULs / CRs in hostile URLs rather than their `%0a` /
36    /// `%00` / `%0d` forms.
37    pub fn from_url(url: &Url) -> Self {
38        let raw_path = url.path().trim_start_matches('/');
39        Self {
40            protocol: url.scheme().to_owned(),
41            host: host_with_port(url),
42            path: percent_decode(raw_path),
43        }
44    }
45
46    /// Variant with the path cleared.
47    ///
48    /// Matches the default `git credential` behavior, which scopes by
49    /// host only unless `useHttpPath` is set.
50    pub fn without_path(mut self) -> Self {
51        self.path.clear();
52        self
53    }
54}
55
56/// Decode `%xx` byte triples in `s`, leaving everything else verbatim.
57/// Lossy on invalid UTF-8 (replaces with `U+FFFD`) — matches Go's
58/// `url.URL.Path`, which always returns a string. Real-world LFS paths
59/// are ASCII, so the lossy edge case is academic.
60fn percent_decode(s: &str) -> String {
61    let bytes = s.as_bytes();
62    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
63    let mut i = 0;
64    while i < bytes.len() {
65        if bytes[i] == b'%' && i + 2 < bytes.len() {
66            let hi = (bytes[i + 1] as char).to_digit(16);
67            let lo = (bytes[i + 2] as char).to_digit(16);
68            if let (Some(h), Some(l)) = (hi, lo) {
69                out.push(((h << 4) | l) as u8);
70                i += 3;
71                continue;
72            }
73        }
74        out.push(bytes[i]);
75        i += 1;
76    }
77    String::from_utf8_lossy(&out).into_owned()
78}
79
80fn host_with_port(url: &Url) -> String {
81    match (url.host_str(), url.port()) {
82        (Some(h), Some(p)) => format!("{h}:{p}"),
83        (Some(h), None) => h.to_owned(),
84        // url::Url enforces a host on http/https, so this branch only fires
85        // for unusual schemes. Empty string keeps the type non-optional.
86        _ => String::new(),
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn from_url_extracts_protocol_host_path() {
96        let q =
97            Query::from_url(&Url::parse("https://git.example.com/foo/bar.git/info/lfs").unwrap());
98        assert_eq!(q.protocol, "https");
99        assert_eq!(q.host, "git.example.com");
100        assert_eq!(q.path, "foo/bar.git/info/lfs");
101    }
102
103    #[test]
104    fn from_url_includes_port() {
105        let q = Query::from_url(&Url::parse("http://localhost:8080/lfs").unwrap());
106        assert_eq!(q.host, "localhost:8080");
107    }
108
109    #[test]
110    fn without_path_clears_path() {
111        let q = Query::from_url(&Url::parse("https://h.example/a/b").unwrap()).without_path();
112        assert!(q.path.is_empty());
113    }
114
115    #[test]
116    fn from_url_decodes_percent_escapes_in_path() {
117        // `%0a` (newline) in the URL must reach the credential helper as a
118        // literal `\n` so git's `protectProtocol` can reject it. Mirrors
119        // upstream's `url.URL.Path` behavior in Go.
120        let q =
121            Query::from_url(&Url::parse("https://h.example/test%0aprotect-linefeed.git").unwrap());
122        assert_eq!(q.path, "test\nprotect-linefeed.git");
123    }
124
125    #[test]
126    fn from_url_preserves_invalid_percent_sequences() {
127        // A bare `%` with non-hex following stays as-is rather than
128        // crashing or eating bytes.
129        let q = Query::from_url(&Url::parse("https://h.example/100%25done").unwrap());
130        assert_eq!(q.path, "100%done");
131    }
132}