Skip to main content

git_lfs_creds/
askpass.rs

1//! `GIT_ASKPASS` / `core.askpass` / `SSH_ASKPASS` credential helper.
2//!
3//! Spawns the configured program once per credential field, with a single
4//! argument formatted as `Username for "<url>"` or `Password for "<url>"`,
5//! and reads the result from stdout. The askpass protocol has no
6//! approve/reject step — both are no-ops.
7//!
8//! Selection priority (resolved by the caller before constructing this
9//! helper):
10//!
11//! 1. `GIT_ASKPASS` env var — interactive Git's standard hook.
12//! 2. `core.askpass` git config — same idea, persisted in config.
13//! 3. `SSH_ASKPASS` env var — last-resort fallback that pre-existed Git.
14//!
15//! Skipped entirely when `credential.<url>.helper` is set, so callers
16//! can keep this slot in the helper chain without it stomping on a
17//! purpose-built credential helper. Trace lines (`creds: filling with
18//! GIT_ASKPASS: <argv>`) match upstream's wording — `t-askpass.sh`
19//! greps them verbatim.
20
21use std::io::Write;
22use std::process::{Command, Stdio};
23
24use crate::helper::{Credentials, Helper, HelperError};
25use crate::query::Query;
26
27/// Spawns `program` per call with a single prompt argument and reads
28/// the username or password from stdout.
29///
30/// `program` is the raw command string, split on whitespace the same
31/// way upstream's `subprocess.ExecCommand` shells expand it: the
32/// first token is the executable and subsequent tokens are passed as
33/// additional args before the prompt.
34#[derive(Debug, Clone)]
35pub struct AskpassHelper {
36    program: String,
37}
38
39impl AskpassHelper {
40    /// Build a helper around the given askpass command.
41    pub fn new(program: impl Into<String>) -> Self {
42        Self {
43            program: program.into(),
44        }
45    }
46
47    fn spawn(&self, prompt: &str) -> Result<String, HelperError> {
48        let mut parts = self.program.split_whitespace();
49        let prog = parts
50            .next()
51            .ok_or_else(|| HelperError::Failed("askpass program is empty".into()))?;
52        let mut args: Vec<&str> = parts.collect();
53        args.push(prompt);
54
55        // Trace line greppable by upstream's shell tests:
56        // `creds: filling with GIT_ASKPASS: <prog> <args...>`.
57        // Stderr (not stdout) — stdout is reserved for the helper's
58        // own protocol output.
59        let mut e = std::io::stderr().lock();
60        let _ = write!(e, "creds: filling with GIT_ASKPASS: {prog}");
61        for a in &args {
62            let _ = write!(e, " {a}");
63        }
64        let _ = writeln!(e);
65        drop(e);
66
67        let out = match Command::new(prog)
68            .args(&args)
69            .stdin(Stdio::null())
70            .stdout(Stdio::piped())
71            .stderr(Stdio::piped())
72            .output()
73        {
74            Ok(o) => o,
75            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
76                // Mirrors upstream's `creds: failed to find GIT_ASKPASS
77                // command: <prog>` trace at `creds/creds.go:284`.
78                // `t-credentials-no-prompt.sh::askpass: push with bad
79                // askpass` greps for this line when the configured
80                // askpass program isn't on PATH.
81                let mut e2 = std::io::stderr().lock();
82                let _ = writeln!(e2, "creds: failed to find GIT_ASKPASS command: {prog}");
83                return Err(e.into());
84            }
85            Err(e) => return Err(e.into()),
86        };
87        if !out.status.success() {
88            return Err(HelperError::Failed(format!(
89                "askpass {prog:?} exited {}: {}",
90                out.status,
91                String::from_utf8_lossy(&out.stderr).trim(),
92            )));
93        }
94        // A non-empty stderr from the askpass program is treated as an
95        // error message (matches upstream's `getFromProgram`).
96        if !out.stderr.is_empty() {
97            return Err(HelperError::Failed(
98                String::from_utf8_lossy(&out.stderr).trim().to_owned(),
99            ));
100        }
101        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
102    }
103}
104
105impl Helper for AskpassHelper {
106    fn fill(&self, query: &Query) -> Result<Option<Credentials>, HelperError> {
107        // Prompts mirror upstream byte-for-byte:
108        // `Username for "<scheme>://<host>[/<path>]"`
109        // `Password for "<scheme>://<username>@<host>[/<path>]"`
110        let bare_url = format_url(query, None);
111        let username = self.spawn(&format!("Username for \"{bare_url}\""))?;
112        if username.is_empty() {
113            return Ok(None);
114        }
115        let user_url = format_url(query, Some(&username));
116        let password = self.spawn(&format!("Password for \"{user_url}\""))?;
117        if password.is_empty() {
118            return Ok(None);
119        }
120        Ok(Some(Credentials::new(username, password)))
121    }
122
123    /// Askpass has no persistence, so approve is a no-op.
124    fn approve(&self, _query: &Query, _creds: &Credentials) -> Result<(), HelperError> {
125        Ok(())
126    }
127
128    /// Askpass has no persistence, so reject is a no-op.
129    fn reject(&self, _query: &Query, _creds: &Credentials) -> Result<(), HelperError> {
130        Ok(())
131    }
132}
133
134/// Build the URL string that goes into the prompt argument. With
135/// `username = Some(...)`, the URL is rendered as
136/// `<scheme>://<user>@<host>[/<path>]` — same form upstream's
137/// `net/url.URL.String()` produces for a `User`-bearing URL.
138fn format_url(query: &Query, username: Option<&str>) -> String {
139    let mut s = String::with_capacity(query.host.len() + query.path.len() + 16);
140    s.push_str(&query.protocol);
141    s.push_str("://");
142    if let Some(u) = username {
143        s.push_str(u);
144        s.push('@');
145    }
146    s.push_str(&query.host);
147    if !query.path.is_empty() {
148        s.push('/');
149        s.push_str(&query.path);
150    }
151    s
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn format_url_no_username() {
160        let q = Query {
161            protocol: "https".into(),
162            host: "git.example.com".into(),
163            path: "foo/bar.git".into(),
164        };
165        assert_eq!(format_url(&q, None), "https://git.example.com/foo/bar.git");
166    }
167
168    #[test]
169    fn format_url_with_username() {
170        let q = Query {
171            protocol: "https".into(),
172            host: "git.example.com".into(),
173            path: "foo/bar.git".into(),
174        };
175        assert_eq!(
176            format_url(&q, Some("alice")),
177            "https://alice@git.example.com/foo/bar.git",
178        );
179    }
180
181    #[test]
182    fn format_url_no_path() {
183        let q = Query {
184            protocol: "http".into(),
185            host: "h:42".into(),
186            path: String::new(),
187        };
188        assert_eq!(format_url(&q, None), "http://h:42");
189    }
190
191    #[test]
192    fn fill_runs_program_and_returns_credentials() {
193        // Stand-in askpass: a shell script that echoes a fixed value
194        // based on the argv so we can verify both prompts ran.
195        let tmp = tempfile::TempDir::new().unwrap();
196        let prog = tmp.path().join("ask");
197        std::fs::write(
198            &prog,
199            "#!/bin/sh\n\
200             case \"$1\" in\n\
201               Username*) echo alice;;\n\
202               Password*) echo s3cret;;\n\
203             esac\n",
204        )
205        .unwrap();
206        #[cfg(unix)]
207        {
208            use std::os::unix::fs::PermissionsExt;
209            let mut perms = std::fs::metadata(&prog).unwrap().permissions();
210            perms.set_mode(0o755);
211            std::fs::set_permissions(&prog, perms).unwrap();
212        }
213        let helper = AskpassHelper::new(prog.to_string_lossy().into_owned());
214        let q = Query {
215            protocol: "https".into(),
216            host: "h.example".into(),
217            path: "repo".into(),
218        };
219        let creds = helper.fill(&q).unwrap().expect("creds");
220        assert_eq!(creds.username, "alice");
221        assert_eq!(creds.password, "s3cret");
222    }
223
224    #[test]
225    fn fill_returns_none_on_empty_username() {
226        let tmp = tempfile::TempDir::new().unwrap();
227        let prog = tmp.path().join("ask");
228        std::fs::write(&prog, "#!/bin/sh\necho\n").unwrap();
229        #[cfg(unix)]
230        {
231            use std::os::unix::fs::PermissionsExt;
232            let mut perms = std::fs::metadata(&prog).unwrap().permissions();
233            perms.set_mode(0o755);
234            std::fs::set_permissions(&prog, perms).unwrap();
235        }
236        let helper = AskpassHelper::new(prog.to_string_lossy().into_owned());
237        let q = Query {
238            protocol: "https".into(),
239            host: "h.example".into(),
240            path: String::new(),
241        };
242        assert_eq!(helper.fill(&q).unwrap(), None);
243    }
244}