Skip to main content

git_lfs_creds/
git_helper.rs

1//! `git credential fill/approve/reject` bridge.
2//!
3//! The wire protocol is documented at <https://git-scm.com/docs/git-credential>:
4//!
5//! * `git credential fill` reads `key=value` lines on stdin terminated by a
6//!   blank line, and writes the same on stdout (with `username` /
7//!   `password` filled in).
8//! * `git credential approve` and `reject` take the same key/value input
9//!   and produce no useful stdout.
10//!
11//! For now we only emit `protocol`, `host`, and (optionally) `path` —
12//! upstream LFS also passes `wwwauth[]` and `state[]` for multi-stage
13//! authentication, which is on the deferred list.
14
15use std::io::Write;
16use std::process::{Command, Stdio};
17
18use crate::helper::{Credentials, Helper, HelperError};
19use crate::query::Query;
20use crate::trace::trace_enabled;
21
22/// Shells out to the `git` binary's credential subsystem.
23///
24/// `git_program` defaults to `"git"`. Override for tests via
25/// [`Self::with_program`] — point at a fake `git` that records its input
26/// or scripts a response.
27///
28/// `protect_protocol` mirrors `credential.protectProtocol` (default
29/// `true`). When set, every value written to `git credential` stdin is
30/// rejected if it contains a carriage return — preventing newline-based
31/// protocol smuggling. Newlines and null bytes are rejected
32/// unconditionally regardless of this flag.
33#[derive(Debug, Clone)]
34pub struct GitCredentialHelper {
35    git_program: String,
36    protect_protocol: bool,
37}
38
39impl Default for GitCredentialHelper {
40    fn default() -> Self {
41        Self {
42            git_program: "git".to_owned(),
43            protect_protocol: true,
44        }
45    }
46}
47
48impl GitCredentialHelper {
49    /// Build a helper that shells out to the system `git` binary.
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Build the helper around a custom `git`-compatible binary.
55    ///
56    /// Used by the integration tests, which point at a shell script
57    /// that fakes the protocol.
58    pub fn with_program(git_program: impl Into<String>) -> Self {
59        Self {
60            git_program: git_program.into(),
61            protect_protocol: true,
62        }
63    }
64
65    /// Toggle `credential.protectProtocol` for this helper.
66    ///
67    /// Default is `true`. Pass `false` only when the user has
68    /// explicitly opted out; carriage returns in URLs are otherwise
69    /// a known smuggling vector.
70    pub fn with_protect_protocol(mut self, protect: bool) -> Self {
71        self.protect_protocol = protect;
72        self
73    }
74
75    fn run(&self, subcommand: &str, query: &Query) -> Result<String, HelperError> {
76        // Upstream's `creds: git credential <sub> (%q, %q, %q)` trace
77        // at `creds/creds.go:328`. Gated on GIT_TRACE because t-lock's
78        // `lock multiple files` test runs with GIT_TRACE=0 and asserts
79        // errlog is clean.
80        if trace_enabled() {
81            let mut e = std::io::stderr().lock();
82            let _ = writeln!(
83                e,
84                "creds: git credential {subcommand} ({:?}, {:?}, {:?})",
85                query.protocol, query.host, query.path,
86            );
87        }
88        let mut child = Command::new(&self.git_program)
89            .args(["credential", subcommand])
90            .stdin(Stdio::piped())
91            .stdout(Stdio::piped())
92            .stderr(Stdio::piped())
93            .spawn()?;
94
95        {
96            let stdin = child
97                .stdin
98                .as_mut()
99                .ok_or_else(|| HelperError::Failed("git stdin unavailable".into()))?;
100            write_input(stdin, query, None, self.protect_protocol)?;
101        }
102
103        let out = child.wait_with_output()?;
104        if !out.status.success() {
105            // `git credential fill` exits 128 when no helper is configured
106            // to provide credentials. Treat that as "I don't know" rather
107            // than a hard failure so the chain can fall through.
108            if subcommand == "fill" && out.status.code() == Some(128) {
109                return Ok(String::new());
110            }
111            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
112            return Err(HelperError::Failed(format!(
113                "git credential {subcommand} exited {}: {stderr}",
114                out.status,
115            )));
116        }
117        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
118    }
119}
120
121impl Helper for GitCredentialHelper {
122    fn fill(&self, query: &Query) -> Result<Option<Credentials>, HelperError> {
123        let stdout = self.run("fill", query)?;
124        Ok(parse_response(&stdout))
125    }
126
127    fn approve(&self, query: &Query, creds: &Credentials) -> Result<(), HelperError> {
128        let mut child = spawn(&self.git_program, "approve")?;
129        if let Some(stdin) = child.stdin.as_mut() {
130            write_input(stdin, query, Some(creds), self.protect_protocol)?;
131        }
132        let out = child.wait_with_output()?;
133        if !out.status.success() {
134            return Err(HelperError::Failed(format!(
135                "git credential approve exited {}: {}",
136                out.status,
137                String::from_utf8_lossy(&out.stderr).trim(),
138            )));
139        }
140        Ok(())
141    }
142
143    fn reject(&self, query: &Query, creds: &Credentials) -> Result<(), HelperError> {
144        let mut child = spawn(&self.git_program, "reject")?;
145        if let Some(stdin) = child.stdin.as_mut() {
146            write_input(stdin, query, Some(creds), self.protect_protocol)?;
147        }
148        let out = child.wait_with_output()?;
149        if !out.status.success() {
150            return Err(HelperError::Failed(format!(
151                "git credential reject exited {}: {}",
152                out.status,
153                String::from_utf8_lossy(&out.stderr).trim(),
154            )));
155        }
156        Ok(())
157    }
158}
159
160fn spawn(program: &str, subcommand: &str) -> Result<std::process::Child, HelperError> {
161    Command::new(program)
162        .args(["credential", subcommand])
163        .stdin(Stdio::piped())
164        .stdout(Stdio::piped())
165        .stderr(Stdio::piped())
166        .spawn()
167        .map_err(HelperError::Io)
168}
169
170fn write_input(
171    sink: &mut impl Write,
172    query: &Query,
173    creds: Option<&Credentials>,
174    protect_protocol: bool,
175) -> Result<(), HelperError> {
176    write_field(sink, "protocol", &query.protocol, protect_protocol)?;
177    write_field(sink, "host", &query.host, protect_protocol)?;
178    write_field(sink, "path", &query.path, protect_protocol)?;
179    if let Some(c) = creds {
180        write_field(sink, "username", &c.username, protect_protocol)?;
181        // password is required for approve/reject to be meaningful, even
182        // if empty — let git decide. Empty values still get a key= line.
183        validate_value("password", &c.password, protect_protocol)?;
184        writeln!(sink, "password={}", c.password)?;
185    }
186    // Trailing blank line tells git "end of input".
187    writeln!(sink)?;
188    Ok(())
189}
190
191/// Emit a `key=value` line to the credential helper's stdin if `value`
192/// is non-empty. Empty fields are skipped to match git-credential's own
193/// "absent = no constraint" semantics. Returns an error if `value`
194/// contains bytes that would break the line-based protocol.
195fn write_field(
196    sink: &mut impl Write,
197    key: &str,
198    value: &str,
199    protect_protocol: bool,
200) -> Result<(), HelperError> {
201    if value.is_empty() {
202        return Ok(());
203    }
204    validate_value(key, value, protect_protocol)?;
205    writeln!(sink, "{key}={value}")?;
206    Ok(())
207}
208
209/// Reject any byte in `value` that would let an attacker inject extra
210/// `key=value` lines into the credential-helper stream. Mirrors
211/// upstream's `Creds.buffer` checks (`creds/creds.go`):
212///
213/// - `\n` (newline) and `\0` (null) are rejected unconditionally.
214/// - `\r` (carriage return) is rejected when `protect_protocol` is set
215///   (the default); disabling it is the documented escape hatch for
216///   pre-existing setups whose URLs contain CRs.
217///
218/// Error wording matches upstream verbatim so `t-credentials-protect`
219/// can grep for it.
220fn validate_value(key: &str, value: &str, protect_protocol: bool) -> Result<(), HelperError> {
221    if value.contains('\n') {
222        return Err(HelperError::Failed(format!(
223            "credential value for {key} contains newline: {value:?}"
224        )));
225    }
226    if value.contains('\0') {
227        return Err(HelperError::Failed(format!(
228            "credential value for {key} contains null byte: {value:?}"
229        )));
230    }
231    if protect_protocol && value.contains('\r') {
232        return Err(HelperError::Failed(format!(
233            "credential value for {key} contains carriage return: {value:?}\n\
234             If this is intended, set `credential.protectProtocol=false`"
235        )));
236    }
237    Ok(())
238}
239
240/// Parse the `key=value\n…\n` response from `git credential fill`.
241///
242/// Returns `None` if no password was provided — git can return a partial
243/// response (e.g. just a username) when a helper bails halfway through;
244/// a usable credential needs at least the password.
245fn parse_response(stdout: &str) -> Option<Credentials> {
246    let mut username = String::new();
247    let mut password: Option<String> = None;
248    for line in stdout.lines() {
249        let Some((k, v)) = line.split_once('=') else {
250            continue;
251        };
252        match k {
253            "username" => username = v.to_owned(),
254            "password" => password = Some(v.to_owned()),
255            _ => {}
256        }
257    }
258    password.map(|p| Credentials::new(username, p))
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn parse_response_extracts_credentials() {
267        let out = "protocol=https\nhost=git.example.com\nusername=alice\npassword=hunter2\n";
268        assert_eq!(
269            parse_response(out),
270            Some(Credentials::new("alice", "hunter2")),
271        );
272    }
273
274    #[test]
275    fn parse_response_returns_none_without_password() {
276        // `git credential fill` can spit out everything but a password if
277        // every helper bailed. A username-only response isn't usable.
278        let out = "protocol=https\nhost=git.example.com\nusername=alice\n";
279        assert_eq!(parse_response(out), None);
280    }
281
282    #[test]
283    fn parse_response_allows_empty_username_with_token_password() {
284        let out = "password=ghp_token\n";
285        assert_eq!(parse_response(out), Some(Credentials::new("", "ghp_token")));
286    }
287
288    #[test]
289    fn write_input_rejects_newline_in_path() {
290        let q = Query {
291            protocol: "https".into(),
292            host: "h.example".into(),
293            path: "evil\nrepo".into(),
294        };
295        let mut buf = Vec::new();
296        let err = write_input(&mut buf, &q, None, true).unwrap_err();
297        assert!(
298            matches!(&err, HelperError::Failed(m) if m.contains("contains newline")),
299            "got {err:?}"
300        );
301    }
302
303    #[test]
304    fn write_input_rejects_null_byte_even_when_protection_off() {
305        let q = Query {
306            protocol: "https".into(),
307            host: "h.example".into(),
308            path: "evil\0repo".into(),
309        };
310        let mut buf = Vec::new();
311        let err = write_input(&mut buf, &q, None, false).unwrap_err();
312        assert!(
313            matches!(&err, HelperError::Failed(m) if m.contains("contains null byte")),
314            "got {err:?}"
315        );
316    }
317
318    #[test]
319    fn write_input_rejects_carriage_return_by_default() {
320        let q = Query {
321            protocol: "https".into(),
322            host: "h.example".into(),
323            path: "evil\rrepo".into(),
324        };
325        let mut buf = Vec::new();
326        let err = write_input(&mut buf, &q, None, true).unwrap_err();
327        assert!(
328            matches!(&err, HelperError::Failed(m) if m.contains("contains carriage return")),
329            "got {err:?}"
330        );
331    }
332
333    #[test]
334    fn write_input_allows_carriage_return_when_protection_off() {
335        let q = Query {
336            protocol: "https".into(),
337            host: "h.example".into(),
338            path: "evil\rrepo".into(),
339        };
340        let mut buf = Vec::new();
341        write_input(&mut buf, &q, None, false).unwrap();
342        let s = String::from_utf8(buf).unwrap();
343        assert!(s.contains("path=evil\rrepo\n"));
344    }
345
346    #[test]
347    fn write_input_skips_empty_fields() {
348        let q = Query {
349            protocol: "https".into(),
350            host: "h.example".into(),
351            path: String::new(),
352        };
353        let mut buf = Vec::new();
354        write_input(&mut buf, &q, None, true).unwrap();
355        let s = String::from_utf8(buf).unwrap();
356        assert!(!s.contains("path="));
357        assert!(s.contains("protocol=https\n"));
358        assert!(s.contains("host=h.example\n"));
359    }
360}