Skip to main content

sley_remote/
credentials.rs

1//! Credential acquisition for authenticated remotes.
2//!
3//! Derives the credential lookup key for a remote URL, runs `credential.helper`
4//! programs to fill in a username/password, and remembers or forgets results.
5//! The default [`CredentialHelperProvider`] wraps this as a
6//! [`CredentialProvider`](crate::CredentialProvider); embedders targeting public
7//! remotes can use [`NoCredentials`](crate::NoCredentials) instead.
8
9use std::io::Write;
10use std::process::{Command, Stdio};
11
12use sley_config::GitConfig;
13use sley_core::Result;
14use sley_transport::{
15    GitCredential, RemoteTransport, RemoteUrl, encode_git_credential, parse_git_credential,
16};
17
18use crate::CredentialProvider;
19
20/// The `protocol` field of a credential request derived from `remote`.
21pub fn http_protocol_name(remote: &RemoteUrl) -> Option<String> {
22    match remote.transport {
23        RemoteTransport::Https => Some("https".to_string()),
24        RemoteTransport::Http => Some("http".to_string()),
25        _ => None,
26    }
27}
28
29/// The `host[:port]` field of a credential request derived from `remote`.
30pub fn http_credential_host(remote: &RemoteUrl) -> Option<String> {
31    remote.host.clone().map(|host| match remote.port {
32        Some(port) => format!("{host}:{port}"),
33        None => host,
34    })
35}
36
37/// Credential implied by `user[:password]@` userinfo in the remote URL.
38pub fn http_url_credential(remote: &RemoteUrl) -> Option<GitCredential> {
39    let username = remote.user.clone()?;
40    Some(GitCredential {
41        protocol: http_protocol_name(remote),
42        host: http_credential_host(remote),
43        username: Some(username),
44        password: remote.password.clone(),
45        ..GitCredential::default()
46    })
47}
48
49/// The lookup key a credential helper is asked to fill for this remote.
50pub fn credential_request_for_url(remote: &RemoteUrl) -> GitCredential {
51    GitCredential {
52        protocol: http_protocol_name(remote),
53        host: http_credential_host(remote),
54        username: remote.user.clone(),
55        ..GitCredential::default()
56    }
57}
58
59/// Ordered `credential.helper` values from config. An empty value resets the
60/// accumulated list, matching upstream git semantics.
61fn credential_helper_specs(config: Option<&GitConfig>) -> Vec<String> {
62    let Some(config) = config else {
63        return Vec::new();
64    };
65    let mut specs = Vec::new();
66    for section in &config.sections {
67        if section.name != "credential" || section.subsection.is_some() {
68            continue;
69        }
70        for entry in &section.entries {
71            if !entry.key.eq_ignore_ascii_case("helper") {
72                continue;
73            }
74            match entry.value.as_deref() {
75                Some("") | None => specs.clear(),
76                Some(value) => specs.push(value.to_string()),
77            }
78        }
79    }
80    specs
81}
82
83/// Resolve a `credential.helper` spec into a runnable command, appending the
84/// operation (`get`/`store`/`erase`).
85///
86/// Dispatch mirrors git 2.54's `credential_do` (`credential.c`), which
87/// classifies the helper string into exactly three forms:
88///
89/// - `!cmd` — a *shell snippet* (documented git feature). Run `cmd "$@"`
90///   through `sh -c`, with the operation as `$1`. Shell metacharacters here are
91///   evaluated by design; this is git's inherited threat model, not a
92///   sley-introduced injection.
93/// - `is_absolute_path` — a helper string whose first token *begins with* `/`.
94///   git runs `<path> <op>` through the shell; sley does the same so
95///   quoting/word-splitting of any arguments matches git exactly.
96/// - otherwise — a *bare name* (anything that does not start with `/`, including
97///   relative paths like `sub/helper`). git maps it to `git credential-<name>`;
98///   sley maps it to the standalone `git-credential-<name>` binary to honour the
99///   "no git shell-outs in src" audit constraint. Crucially the classifier keys
100///   on a *leading* `/` (git's `is_absolute_path`), NOT on `contains('/')`: a
101///   relative `sub/helper` must NOT be exec'd directly, or sley would diverge
102///   from git's dispatch.
103fn credential_helper_command(spec: &str, op: &str) -> Option<Command> {
104    let spec = spec.trim();
105    if spec.is_empty() {
106        return None;
107    }
108    if let Some(shell) = spec.strip_prefix('!') {
109        // `!`-snippet: git runs the snippet through the shell with the operation
110        // passed positionally; replicate via `sh -c '<snippet> "$@"' sh <op>`.
111        let mut command = Command::new("sh");
112        command
113            .arg("-c")
114            .arg(format!("{shell} \"$@\""))
115            .arg("sh")
116            .arg(op);
117        return Some(command);
118    }
119    let mut tokens = spec.split_whitespace();
120    let head = tokens.next()?;
121    // git's `is_absolute_path` is a leading directory separator (`/` on unix);
122    // it does NOT match relative paths that merely contain a `/`. Matching
123    // `contains('/')` here would exec `sub/helper` directly while git dispatches
124    // it as `git credential-sub/helper` — a dispatch divergence (sley#3).
125    let program = if head.starts_with('/') {
126        head.to_string()
127    } else {
128        format!("git-credential-{head}")
129    };
130    let mut command = Command::new(program);
131    for arg in tokens {
132        command.arg(arg);
133    }
134    command.arg(op);
135    Some(command)
136}
137
138/// Run a credential helper, feeding `input` on stdin. Best-effort: a missing or
139/// failing helper yields `None` rather than aborting the transfer.
140fn run_credential_helper(spec: &str, op: &str, input: &[u8]) -> Result<Option<Vec<u8>>> {
141    let Some(mut command) = credential_helper_command(spec, op) else {
142        return Ok(None);
143    };
144    command
145        .stdin(Stdio::piped())
146        .stdout(Stdio::piped())
147        .stderr(Stdio::null());
148    let mut child = match command.spawn() {
149        Ok(child) => child,
150        Err(_) => return Ok(None),
151    };
152    if let Some(mut stdin) = child.stdin.take() {
153        stdin.write_all(input)?;
154    }
155    let output = child.wait_with_output()?;
156    if !output.status.success() {
157        return Ok(None);
158    }
159    Ok(Some(output.stdout))
160}
161
162/// Fill `request` (username/password) using the configured credential helpers,
163/// returning a complete credential or `None` if it could not be completed.
164pub fn credential_fill(
165    config: Option<&GitConfig>,
166    mut request: GitCredential,
167) -> Result<Option<GitCredential>> {
168    for spec in credential_helper_specs(config) {
169        if request.username.is_some() && request.password.is_some() {
170            break;
171        }
172        let input = encode_git_credential(&request)?;
173        if let Some(stdout) = run_credential_helper(&spec, "get", &input)? {
174            let filled = parse_git_credential(&stdout)?;
175            if filled.username.is_some() {
176                request.username = filled.username;
177            }
178            if filled.password.is_some() {
179                request.password = filled.password;
180            }
181        }
182    }
183    if request.username.is_some() && request.password.is_some() {
184        Ok(Some(request))
185    } else {
186        Ok(None)
187    }
188}
189
190/// Tell the configured helpers to store (`approve = true`) or erase a credential.
191pub fn credential_store(config: Option<&GitConfig>, credential: &GitCredential, approve: bool) {
192    let Ok(input) = encode_git_credential(credential) else {
193        return;
194    };
195    let op = if approve { "store" } else { "erase" };
196    for spec in credential_helper_specs(config) {
197        let _ = run_credential_helper(&spec, op, &input);
198    }
199}
200
201/// The default [`CredentialProvider`]: fills and stores credentials via the
202/// repository's configured `credential.helper` programs.
203pub struct CredentialHelperProvider<'a> {
204    config: Option<&'a GitConfig>,
205}
206
207impl<'a> CredentialHelperProvider<'a> {
208    /// Create a provider backed by `config`'s `credential.helper` settings.
209    pub fn new(config: Option<&'a GitConfig>) -> Self {
210        Self { config }
211    }
212}
213
214impl CredentialProvider for CredentialHelperProvider<'_> {
215    fn fill(&mut self, request: GitCredential) -> Result<Option<GitCredential>> {
216        credential_fill(self.config, request)
217    }
218
219    fn approve(&mut self, credential: &GitCredential) -> Result<()> {
220        credential_store(self.config, credential, true);
221        Ok(())
222    }
223
224    fn reject(&mut self, credential: &GitCredential) -> Result<()> {
225        credential_store(self.config, credential, false);
226        Ok(())
227    }
228}
229
230#[cfg(all(test, unix))]
231mod credential_dispatch_parity_tests {
232    //! Parity harness for `credential.helper` dispatch against git 2.54.
233    //!
234    //! Threat model (inherited from git, NOT introduced by sley): git runs
235    //! credential helpers through the shell (`credential.c` `run_credential_helper`
236    //! sets `helper.use_shell = 1`). A `!`-prefixed helper is *documented* to be a
237    //! shell snippet, and an absolute-path helper string is likewise run through
238    //! the shell, so shell metacharacters in those forms are evaluated by design.
239    //! These tests pin sley's dispatch to git's exact classification so sley
240    //! neither *adds* an injection git lacks (e.g. shell-splitting a token git
241    //! would exec directly) nor *removes* the documented `!`-shell behavior.
242    //!
243    //! Git's `credential_do` (`credential.c`) classifies the helper string by:
244    //!
245    //! - `!cmd` -> strip `!`, run `cmd <op>` through the shell;
246    //! - `is_absolute_path` -> leading `/` only -> run `<path> <op>` via shell;
247    //! - otherwise -> `git credential-<helper> <op>` via shell (sley substitutes
248    //!   the standalone `git-credential-<helper>` binary to honour the "no git
249    //!   shell-outs in src" audit constraint).
250    //!
251    //! All three were confirmed empirically against git 2.54 via `GIT_TRACE`.
252
253    use std::fs;
254    use std::os::unix::fs::PermissionsExt;
255    use std::path::Path;
256
257    use sley_config::GitConfig;
258    use sley_transport::GitCredential;
259
260    use super::{credential_fill, credential_helper_command};
261
262    /// Build a `GitConfig` carrying a single `credential.helper` value.
263    ///
264    /// The value is wrapped in git-config double quotes (escaping `\` and `"`)
265    /// so that `;`/`#`/whitespace inside a helper snippet survive parsing
266    /// exactly as they do in a real `~/.gitconfig` — git treats an unquoted
267    /// `;`/`#` as a comment, so a `!`-snippet helper must be quoted in practice.
268    fn config_with_helper(helper: &str) -> GitConfig {
269        // Hermetic: no host gitconfig, identity is irrelevant here.
270        let escaped = helper.replace('\\', "\\\\").replace('"', "\\\"");
271        let body = format!("[credential]\n\thelper = \"{escaped}\"\n");
272        GitConfig::parse(body.as_bytes()).expect("config parses")
273    }
274
275    /// Write an executable shell script and return its path.
276    fn write_script(dir: &Path, name: &str, body: &str) -> std::path::PathBuf {
277        let path = dir.join(name);
278        fs::write(&path, body).expect("write script");
279        let mut perms = fs::metadata(&path).expect("metadata").permissions();
280        perms.set_mode(0o755);
281        fs::set_permissions(&path, perms).expect("chmod");
282        path
283    }
284
285    fn base_request() -> GitCredential {
286        GitCredential {
287            protocol: Some("https".to_string()),
288            host: Some("example.com".to_string()),
289            ..GitCredential::default()
290        }
291    }
292
293    /// Absolute-path form: git runs `<abs> <op>` through the shell, so a helper
294    /// that records its args sees exactly `--flag get` (op appended). Sley must
295    /// match: a single positional arg plus the operation, with shell word-split.
296    #[test]
297    fn absolute_path_form_passes_args_and_op() {
298        let tmp = tempdir();
299        let marker = tmp.path().join("abs.out");
300        let script = write_script(
301            tmp.path(),
302            "abs-helper.sh",
303            &format!(
304                "#!/bin/sh\ncat >/dev/null\nprintf 'ARGS:[%s]\\n' \"$*\" >> '{}'\necho username=abs-user\necho password=abs-pass\n",
305                marker.display()
306            ),
307        );
308        let cfg = config_with_helper(&format!("{} --flag", script.display()));
309        let filled = credential_fill(Some(&cfg), base_request())
310            .expect("fill ok")
311            .expect("credential filled");
312        assert_eq!(filled.username.as_deref(), Some("abs-user"));
313        assert_eq!(filled.password.as_deref(), Some("abs-pass"));
314        let recorded = fs::read_to_string(&marker).expect("marker written");
315        // git 2.54 produces exactly: ARGS:[--flag get]
316        assert_eq!(recorded.trim(), "ARGS:[--flag get]");
317    }
318
319    /// `!shell` form: git runs the snippet through the shell with the operation
320    /// passed as `$1` (documented behavior). A snippet of `f` (a function) must
321    /// observe `get` as its first positional argument.
322    #[test]
323    fn shell_snippet_form_runs_through_shell_with_op_arg() {
324        let tmp = tempdir();
325        let marker = tmp.path().join("snip.out");
326        let helper = format!(
327            "!f() {{ cat >/dev/null; printf 'GOT:[%s]\\n' \"$*\" >> '{}'; echo username=snip-user; echo password=snip-pass; }}; f",
328            marker.display()
329        );
330        let cfg = config_with_helper(&helper);
331        let filled = credential_fill(Some(&cfg), base_request())
332            .expect("fill ok")
333            .expect("credential filled");
334        assert_eq!(filled.username.as_deref(), Some("snip-user"));
335        assert_eq!(filled.password.as_deref(), Some("snip-pass"));
336        let recorded = fs::read_to_string(&marker).expect("marker written");
337        // git 2.54: the operation is the snippet's $1 -> GOT:[get]
338        assert_eq!(recorded.trim(), "GOT:[get]");
339    }
340
341    /// Bare-name form: git classifies a name containing a `/` that is NOT a
342    /// leading-`/` absolute path as a *bare name* and dispatches to
343    /// `git credential-<name>` (sley: `git-credential-<name>`). The classifier
344    /// must key on `is_absolute_path` (leading `/`), NOT `contains('/')`.
345    ///
346    /// This is the divergence the fix closes: the old `head.contains('/')` test
347    /// would directly exec a relative `sub/helper`, while git prefixes it to
348    /// `git-credential-sub/helper`.
349    #[test]
350    fn relative_slash_name_is_bare_not_path() {
351        let cmd = credential_helper_command("sub/relhelper", "get").expect("command built");
352        // git 2.54: `git credential-sub/relhelper get` -> standalone binary
353        // `git-credential-sub/relhelper`. The program sley resolves must be the
354        // prefixed credential binary, never the raw relative path `sub/relhelper`.
355        let program = command_program(&cmd);
356        assert_ne!(
357            program, "sub/relhelper",
358            "relative slash name must not be exec'd directly (git would prefix it)"
359        );
360        assert!(
361            program.contains("git-credential-sub/relhelper")
362                || program == "sh"
363                || program == "/bin/sh",
364            "expected git-credential-<name> dispatch, got program {program:?}"
365        );
366    }
367
368    /// Plain bare name maps to the standalone `git-credential-<name>` binary,
369    /// matching git's `git credential-<name>` dispatch (we substitute the
370    /// standalone form to avoid shelling out to `git`). The dispatch must
371    /// resolve `git-credential-myhelper` and pass `--opt val get` as arguments
372    /// (git 2.54: `git credential-myhelper --opt val get`). We assert on the
373    /// resolved program + argv rather than mutating PATH (this crate forbids
374    /// `unsafe`, so `std::env::set_var` is unavailable in tests).
375    #[test]
376    fn plain_bare_name_maps_to_credential_binary() {
377        let cmd = credential_helper_command("myhelper --opt val", "get").expect("command built");
378        let argv = command_argv(&cmd);
379        // First exec target must be the standalone credential binary, never a
380        // bare exec of the raw token, and never `git`.
381        assert!(
382            argv[0].contains("git-credential-myhelper") || argv[0] == "sh" || argv[0] == "/bin/sh",
383            "expected git-credential-<name> dispatch, got argv {argv:?}"
384        );
385        assert_ne!(argv[0], "git", "must not shell out to the git binary");
386        // git 2.54 appends `--opt val get`; assert the args are carried through
387        // (either as direct argv or inside the shell command string).
388        let rendered = argv.join(" ");
389        assert!(
390            rendered.contains("git-credential-myhelper")
391                && rendered.contains("--opt")
392                && rendered.contains("val")
393                && rendered.contains("get"),
394            "expected `git-credential-myhelper --opt val get` dispatch, got {rendered:?}"
395        );
396    }
397
398    // --- minimal helpers (no external test deps) ---
399
400    /// Read the resolved program (argv[0]) out of a `Command`.
401    fn command_program(cmd: &std::process::Command) -> String {
402        cmd.get_program().to_string_lossy().into_owned()
403    }
404
405    /// Read program + args out of a `Command` as a flat `Vec<String>`.
406    fn command_argv(cmd: &std::process::Command) -> Vec<String> {
407        let mut out = vec![cmd.get_program().to_string_lossy().into_owned()];
408        out.extend(cmd.get_args().map(|a| a.to_string_lossy().into_owned()));
409        out
410    }
411
412    /// Tiny self-contained tempdir (avoid adding a dev-dependency).
413    struct TempDir {
414        path: std::path::PathBuf,
415    }
416    impl TempDir {
417        fn path(&self) -> &Path {
418            &self.path
419        }
420    }
421    impl Drop for TempDir {
422        fn drop(&mut self) {
423            let _ = fs::remove_dir_all(&self.path);
424        }
425    }
426    fn tempdir() -> TempDir {
427        use std::sync::atomic::{AtomicU64, Ordering};
428        static COUNTER: AtomicU64 = AtomicU64::new(0);
429        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
430        let pid = std::process::id();
431        let path = std::env::temp_dir().join(format!("sley-cred-parity-{pid}-{n}"));
432        fs::create_dir_all(&path).expect("mkdir tempdir");
433        TempDir { path }
434    }
435}