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}