Skip to main content

grit_lib/
credentials.rs

1//! Credential layer — Git-compatible credential filling/approval/rejection for
2//! library embedders.
3//!
4//! It exposes:
5//!
6//! - [`Credential`] — a structured credential with the standard Git fields
7//!   ([`protocol`](Credential::protocol), [`host`](Credential::host),
8//!   [`path`](Credential::path), [`username`](Credential::username),
9//!   [`password`](Credential::password), [`url`](Credential::url)) plus
10//!   parsing/serialization in Git's `key=value\n…\n\n` credential wire format
11//!   ([`Credential::parse`] / [`Credential::serialize`]).
12//! - [`CredentialProvider`] — the pluggable seam an embedder implements (or
13//!   wraps) to supply credentials.
14//! - [`HelperCredentialProvider`] — the Git-compatible default that runs the
15//!   configured `credential.helper` / `credential.<url>.helper` programs
16//!   (shell `!cmd`, the built-in `store`/`cache` helpers, and external
17//!   `git-credential-*` binaries), then falls back to an askpass program.
18//!
19//! ## Askpass, never a terminal
20//!
21//! When the configured helpers cannot supply a usable username/password,
22//! [`HelperCredentialProvider`] asks the askpass program set with
23//! [`HelperCredentialProvider::with_askpass`] for the missing fields, as Git
24//! does. [`askpass_program`] resolves that program from `GIT_ASKPASS`,
25//! `core.askPass` and `SSH_ASKPASS`; this is how CI systems inject tokens
26//! non-interactively. `credential.interactive=false` (or `never`) disables
27//! the fallback.
28//!
29//! The provider **never** prompts on a TTY. Without an askpass program, or
30//! when it is disabled, [`fill`](CredentialProvider::fill) returns the typed
31//! [`Error::CredentialsUnavailable`] (displayed as [`NON_INTERACTIVE_MESSAGE`],
32//! listing the helpers that were asked) rather than blocking on `/dev/tty`.
33//! Callers that want a person to supply a username and password store it up
34//! front with [`CredentialProvider::approve`], as `grit auth <url> --username
35//! <user> --token <token>` does.
36
37use std::collections::HashSet;
38use std::ffi::OsString;
39use std::path::{Path, PathBuf};
40use std::sync::{Arc, OnceLock};
41
42use crate::command_runner::{
43    system_command_runner, CommandEnvironment, CommandRunner, CommandSpec, CommandStdin,
44    CommandStdio, ShellInvocation,
45};
46use crate::config::{parse_bool, parse_path, ConfigSet};
47use crate::environment::Environment;
48use crate::error::{Error, Result};
49
50/// How [`Error::CredentialsUnavailable`] displays: credentials are required but
51/// no configured helper could supply a complete username/password and no
52/// askpass program is available to ask for them.
53pub const NON_INTERACTIVE_MESSAGE: &str = "credentials required but unavailable (non-interactive)";
54
55/// The askpass program Git would run to ask for a missing username or
56/// password.
57///
58/// The first of these that is set wins: the `GIT_ASKPASS` environment
59/// variable, the `core.askPass` config value (with `~/` expanded), then the
60/// `SSH_ASKPASS` environment variable. As in Git, a source that is set to an
61/// empty value stops the search and disables askpass.
62///
63/// # Parameters
64///
65/// * `env` — the environment to read `GIT_ASKPASS` / `SSH_ASKPASS` (and `HOME`
66///   for `~/`) from; pass [`Environment::empty`] to consult config only.
67/// * `config` — merged config holding `core.askPass`.
68///
69/// # Returns
70///
71/// The program to pass to [`HelperCredentialProvider::with_askpass`], or
72/// `None` when askpass is not configured.
73#[must_use]
74#[cfg_attr(not(feature = "http-ureq"), allow(dead_code))]
75pub(crate) fn askpass_program(env: &Environment, config: &ConfigSet) -> Option<PathBuf> {
76    let program = env
77        .var_os("GIT_ASKPASS")
78        .or_else(|| {
79            config
80                .get("core.askPass")
81                .map(|value| parse_path(env, &value).into())
82        })
83        .or_else(|| env.var_os("SSH_ASKPASS"))?;
84    (!program.is_empty()).then(|| PathBuf::from(program))
85}
86
87/// A structured Git credential.
88///
89/// Mirrors the fields Git's credential protocol exchanges. Round-trips through
90/// the `key=value\n…\n\n` wire format via [`Credential::parse`] and
91/// [`Credential::serialize`]. Any keys outside the named fields below
92/// (`capability[]`, `authtype`, `password_expiry_utc`, …) are preserved in
93/// [`extra`](Credential::extra) so they survive a parse/serialize round-trip
94/// and are forwarded to helpers unchanged.
95#[derive(Clone, Debug, Default, PartialEq, Eq)]
96pub struct Credential {
97    /// `protocol` field (e.g. `https`, `http`, `ssh`).
98    pub protocol: Option<String>,
99    /// `host` field, optionally including a `:port` suffix.
100    pub host: Option<String>,
101    /// `path` field (repository path on the host).
102    pub path: Option<String>,
103    /// `username` field.
104    pub username: Option<String>,
105    /// `password` field (the secret).
106    pub password: Option<String>,
107    /// `url` field — a full URL Git can decompose into the fields above.
108    ///
109    /// [`HelperCredentialProvider`] uses it to match `credential.<url>.*`
110    /// config (see [`target_url`](Credential::target_url)) and to fill a
111    /// missing protocol/host, but never writes it to a helper: helpers such as
112    /// `git-credential-store` would otherwise key the lookup on its path.
113    pub url: Option<String>,
114    /// Any additional `key=value` pairs, in wire order. Multi-valued keys such
115    /// as `capability[]` may appear more than once.
116    pub extra: Vec<(String, String)>,
117}
118
119impl Credential {
120    /// Parse a credential from Git's `key=value\n…` wire format.
121    ///
122    /// Parsing stops at the first blank line (Git's record terminator) or at
123    /// EOF. Trailing `\r` is stripped from each line so the parser accepts both
124    /// LF and CRLF input. Lines without an `=` are ignored.
125    pub fn parse(input: &str) -> Self {
126        let mut cred = Credential::default();
127        for line in input.lines() {
128            let line = line.trim_end_matches('\r');
129            if line.is_empty() {
130                break;
131            }
132            let Some((key, value)) = line.split_once('=') else {
133                continue;
134            };
135            cred.set(key, value);
136        }
137        cred
138    }
139
140    /// The credential Git requests for the repository at `url`.
141    ///
142    /// `protocol` and `host` (with any `:port`) come from `url`, and
143    /// [`url`](Credential::url) keeps `url` itself so `credential.<url>.*`
144    /// config still matches on the full repository path. For `http` and
145    /// `https` the `path` is only included when `credential.useHttpPath`
146    /// applies to `url`, so one host-level entry (as written by
147    /// `git-credential-store` or a keychain helper) serves every repository on
148    /// the host. Other protocols always carry the path. The path is
149    /// percent-decoded and has surrounding slashes removed, as in Git.
150    ///
151    /// A `user[:password]@` in the URL becomes the percent-decoded
152    /// [`username`](Credential::username) and
153    /// [`password`](Credential::password), so a URL with both is already
154    /// complete and a username-only URL is only asked for its password. The
155    /// stored [`url`](Credential::url) has the userinfo removed, so the secret
156    /// never reaches config matching or messages built from it.
157    ///
158    /// Pass the repository URL (`https://host/owner/repo.git`), not a
159    /// smart-HTTP request URL with `/info/refs` and a query appended.
160    ///
161    /// # Returns
162    ///
163    /// `None` when `url` does not parse or has no host.
164    #[must_use]
165    pub fn from_url(url: &str, config: &ConfigSet) -> Option<Self> {
166        let parsed = url::Url::parse(url).ok()?;
167        let host = parsed.host_str().filter(|host| !host.is_empty())?;
168        let host = match parsed.port() {
169            Some(port) => format!("{host}:{port}"),
170            None => host.to_owned(),
171        };
172        let protocol = parsed.scheme();
173        let url = url_without_userinfo(url);
174        let is_http = matches!(protocol, "http" | "https");
175        let path = (!is_http || use_http_path(config, Some(&url)))
176            .then(|| percent_decode_lossy(parsed.path().trim_matches('/')))
177            .filter(|path| !path.is_empty());
178        let username = Some(parsed.username())
179            .filter(|user| !user.is_empty())
180            .map(percent_decode_lossy);
181        Some(Credential {
182            protocol: Some(protocol.to_owned()),
183            host: Some(host),
184            path,
185            password: parsed.password().map(percent_decode_lossy),
186            username,
187            url: Some(url),
188            ..Default::default()
189        })
190    }
191
192    /// Parse from raw bytes (lossy UTF-8); convenience for helper stdout.
193    pub fn parse_bytes(bytes: &[u8]) -> Self {
194        Self::parse(&String::from_utf8_lossy(bytes))
195    }
196
197    /// Serialize to Git's `key=value\n` wire format (no trailing blank line).
198    ///
199    /// Fields are emitted in Git's canonical order
200    /// (protocol, host, path, username, password, url) followed by any
201    /// [`extra`](Credential::extra) entries in their stored order.
202    pub fn serialize(&self) -> String {
203        let mut out = String::new();
204        for (key, value) in self.iter_pairs() {
205            out.push_str(&key);
206            out.push('=');
207            out.push_str(&value);
208            out.push('\n');
209        }
210        out
211    }
212
213    /// Set a field by its Git key name. Unknown keys land in
214    /// [`extra`](Credential::extra). Multi-valued keys (those ending in `[]`)
215    /// always append; named keys overwrite.
216    fn set(&mut self, key: &str, value: &str) {
217        match key {
218            "protocol" => self.protocol = Some(value.to_string()),
219            "host" => self.host = Some(value.to_string()),
220            "path" => self.path = Some(value.to_string()),
221            "username" => self.username = Some(value.to_string()),
222            "password" => self.password = Some(value.to_string()),
223            "url" => self.url = Some(value.to_string()),
224            _ => {
225                if key.ends_with("[]") {
226                    self.extra.push((key.to_string(), value.to_string()));
227                } else if let Some(slot) = self
228                    .extra
229                    .iter_mut()
230                    .find(|(k, _)| k == key)
231                    .map(|(_, v)| v)
232                {
233                    *slot = value.to_string();
234                } else {
235                    self.extra.push((key.to_string(), value.to_string()));
236                }
237            }
238        }
239    }
240
241    /// Look up an `extra` key (first match).
242    fn extra_get(&self, key: &str) -> Option<&str> {
243        self.extra
244            .iter()
245            .find(|(k, _)| k == key)
246            .map(|(_, v)| v.as_str())
247    }
248
249    /// Iterate the credential's key/value pairs in Git's canonical wire order.
250    fn iter_pairs(&self) -> Vec<(String, String)> {
251        let mut pairs = Vec::new();
252        if let Some(v) = &self.protocol {
253            pairs.push(("protocol".to_string(), v.clone()));
254        }
255        if let Some(v) = &self.host {
256            pairs.push(("host".to_string(), v.clone()));
257        }
258        if let Some(v) = &self.path {
259            pairs.push(("path".to_string(), v.clone()));
260        }
261        if let Some(v) = &self.username {
262            pairs.push(("username".to_string(), v.clone()));
263        }
264        if let Some(v) = &self.password {
265            pairs.push(("password".to_string(), v.clone()));
266        }
267        if let Some(v) = &self.url {
268            pairs.push(("url".to_string(), v.clone()));
269        }
270        for (k, v) in &self.extra {
271            pairs.push((k.clone(), v.clone()));
272        }
273        pairs
274    }
275
276    /// True when the credential carries a usable username **and** password.
277    pub fn is_complete(&self) -> bool {
278        self.username.as_deref().is_some_and(|s| !s.is_empty())
279            && self.password.as_deref().is_some_and(|s| !s.is_empty())
280    }
281
282    /// The URL this credential authenticates against, for matching
283    /// `credential.<url>.helper` config entries. Prefers an explicit
284    /// [`url`](Credential::url); otherwise reconstructs it from
285    /// protocol/host/path (matching Git's `credential_apply_config`).
286    pub fn target_url(&self) -> Option<String> {
287        if let Some(u) = self.url.as_deref().filter(|u| !u.trim().is_empty()) {
288            return Some(u.to_string());
289        }
290        let protocol = self.protocol.as_deref()?;
291        let host = self.host.as_deref()?;
292        let mut url = format!("{protocol}://");
293        if let Some(username) = self.username.as_deref().filter(|u| !u.is_empty()) {
294            url.push_str(username);
295            url.push('@');
296        }
297        url.push_str(host);
298        if let Some(path) = self.path.as_deref().filter(|p| !p.is_empty()) {
299            if !path.starts_with('/') {
300                url.push('/');
301            }
302            url.push_str(path);
303        }
304        Some(url)
305    }
306
307    /// The askpass prompt for `field` (`Username` or `Password`), in the
308    /// `<field> for '<protocol>://[<user>@]<host>[/<path>]': ` form askpass
309    /// scripts match on. With `sanitize` (Git's `credential.sanitizePrompt`,
310    /// which may be URL-scoped and is on by default) control and URL-unsafe characters in the user, host and
311    /// path are percent-encoded.
312    fn prompt(&self, field: &str, sanitize: bool) -> String {
313        let Some(protocol) = self.protocol.as_deref() else {
314            return format!("{field}: ");
315        };
316        let part = |value: &str, escape: fn(u8) -> bool| {
317            if sanitize {
318                percent_encode_where(value, escape)
319            } else {
320                value.to_owned()
321            }
322        };
323        let mut target = format!("{protocol}://");
324        if let Some(user) = self.username.as_deref().filter(|u| !u.is_empty()) {
325            target.push_str(&part(user, |b| {
326                b == b'/' || URL_UNSAFE_PROMPT_BYTES.contains(&b)
327            }));
328            target.push('@');
329        }
330        if let Some(host) = self.host.as_deref() {
331            target.push_str(&part(host, |b| {
332                !b.is_ascii_alphanumeric() && !b"-.:[]".contains(&b)
333            }));
334        }
335        if let Some(path) = self.path.as_deref() {
336            target.push('/');
337            target.push_str(&part(path, |b| URL_UNSAFE_PROMPT_BYTES.contains(&b)));
338        }
339        format!("{field} for '{target}': ")
340    }
341
342    /// Merge a helper's `get` response into this credential: fill any missing
343    /// username/password (and other recognized fields) without clobbering
344    /// values we already hold. `quit` and `capability[]` are tracked in
345    /// `extra` for the caller to inspect.
346    fn merge_response(&mut self, response: &Credential) {
347        if self.username.is_none() {
348            self.username = response.username.clone();
349        }
350        if self.password.is_none() {
351            self.password = response.password.clone();
352        }
353        if self.protocol.is_none() {
354            self.protocol = response.protocol.clone();
355        }
356        if self.host.is_none() {
357            self.host = response.host.clone();
358        }
359        if self.path.is_none() {
360            self.path = response.path.clone();
361        }
362        for (k, v) in &response.extra {
363            // Preserve quit signalling for callers; other extras are advisory.
364            if k == "quit" {
365                self.set(k, v);
366            }
367        }
368    }
369
370    /// Did a helper signal `quit=1`/`quit=true` (stop querying further helpers)?
371    fn wants_quit(&self) -> bool {
372        matches!(self.extra_get("quit"), Some("1") | Some("true"))
373    }
374}
375
376/// The pluggable credential seam an embedder implements (or wraps).
377///
378/// All three methods take a (partial) [`Credential`] describing the target and
379/// return a result; transports call [`fill`](CredentialProvider::fill) before a
380/// request and [`approve`](CredentialProvider::approve) /
381/// [`reject`](CredentialProvider::reject) after, mirroring Git's
382/// `credential_fill` / `credential_approve` / `credential_reject`.
383pub trait CredentialProvider {
384    /// Fill in missing fields (typically username/password) for `input`,
385    /// returning a more-complete [`Credential`]. Implementations that cannot
386    /// supply a usable credential should return a typed [`Error`] rather than
387    /// block on interactive input.
388    fn fill(&self, input: &Credential) -> Result<Credential>;
389
390    /// Mark `cred` as known-good (helpers `store` it).
391    fn approve(&self, cred: &Credential) -> Result<()>;
392
393    /// Mark `cred` as known-bad (helpers `erase` it).
394    fn reject(&self, cred: &Credential) -> Result<()>;
395}
396
397/// Git-compatible [`CredentialProvider`] that runs the configured
398/// `credential.helper` programs.
399///
400/// Built from a [`ConfigSet`]; it resolves the helper list per the target URL
401/// (so `credential.<url>.helper` entries are honored) and invokes each helper
402/// with `get` (for [`fill`](CredentialProvider::fill)), `store` (for
403/// [`approve`](CredentialProvider::approve)), or `erase` (for
404/// [`reject`](CredentialProvider::reject)) exactly as Git does.
405///
406/// If no helper yields a complete credential, [`fill`](CredentialProvider::fill)
407/// asks the [askpass program](HelperCredentialProvider::with_askpass) for the
408/// missing username and password. It never prompts on a terminal: without an
409/// askpass program it returns [`Error::CredentialsUnavailable`] with the
410/// helpers it asked.
411pub struct HelperCredentialProvider {
412    config: ConfigSet,
413    command_runner: Arc<dyn CommandRunner>,
414    probed_git_exec_path: OnceLock<Vec<PathBuf>>,
415    askpass: Option<PathBuf>,
416}
417
418impl HelperCredentialProvider {
419    /// Build a provider from a loaded [`ConfigSet`], with no askpass program.
420    pub fn new(config: ConfigSet) -> Self {
421        Self {
422            config,
423            command_runner: system_command_runner(),
424            probed_git_exec_path: OnceLock::new(),
425            askpass: None,
426        }
427    }
428
429    /// Override the subprocess runner (tests and embedders).
430    #[must_use]
431    pub fn with_command_runner(mut self, runner: Arc<dyn CommandRunner>) -> Self {
432        self.command_runner = runner;
433        self
434    }
435
436    /// Ask `program` for a username and password the helpers could not
437    /// supply, usually the result of [`askpass_program`].
438    ///
439    /// `program` runs once per missing field with the prompt (for example
440    /// `Username for 'https://example.com': `) as its only argument, and its
441    /// stdout up to the first line break is the answer. Credentials obtained
442    /// this way are stored with the helpers when the transport
443    /// [approves](CredentialProvider::approve) them. `None` disables the
444    /// fallback. `credential.interactive=false` or `never` also disables it.
445    #[must_use]
446    pub fn with_askpass(mut self, program: Option<PathBuf>) -> Self {
447        self.askpass = program;
448        self
449    }
450
451    /// The `credential.helper` programs this provider runs for `cred`, in the
452    /// order [`fill`](CredentialProvider::fill),
453    /// [`approve`](CredentialProvider::approve) and
454    /// [`reject`](CredentialProvider::reject) invoke them.
455    ///
456    /// Includes `credential.<url>.helper` entries whose URL pattern matches
457    /// [`Credential::target_url`]; an empty value resets the list, as in Git.
458    /// An empty result means storing `cred` would be a no-op.
459    #[must_use]
460    pub fn helpers_for(&self, cred: &Credential) -> Vec<String> {
461        self.helpers(cred.target_url().as_deref())
462    }
463
464    /// The ordered helper list applicable to `target_url`.
465    fn helpers(&self, target_url: Option<&str>) -> Vec<String> {
466        credential_helpers(&self.config, target_url)
467    }
468
469    /// The fields a helper receives for `cred`: an explicit
470    /// [`url`](Credential::url) fills a missing protocol/host/path the way
471    /// [`Credential::from_url`] does and is then dropped, because Git never
472    /// writes `url` to helpers.
473    fn helper_request(&self, cred: &Credential) -> Credential {
474        let mut request = cred.clone();
475        let Some(url) = request.url.take() else {
476            return request;
477        };
478        if request.protocol.is_none() && request.host.is_none() {
479            if let Some(from_url) = Credential::from_url(&url, &self.config) {
480                request.protocol = from_url.protocol;
481                request.host = from_url.host;
482                request.path = request.path.or(from_url.path);
483            }
484        }
485        request
486    }
487
488    /// False when `credential.interactive` is `false` or `never`.
489    fn prompts_allowed(&self) -> bool {
490        self.config
491            .get("credential.interactive")
492            .is_none_or(|value| value != "never" && parse_bool(&value).unwrap_or(true))
493    }
494
495    fn exec_path_candidates(&self) -> Vec<PathBuf> {
496        credential_helper_exec_path_candidates(
497            self.command_runner.as_ref(),
498            &self.probed_git_exec_path,
499        )
500    }
501
502    /// Whether the helper program for the bare helper name `name` (so
503    /// `git-credential-<name>`) is installed in Git's exec-path or on `PATH`.
504    #[must_use]
505    pub fn helper_installed(&self, name: &str) -> bool {
506        let program = format!("git-credential-{name}");
507        let on_path = crate::environment::Environment::empty()
508            .var_os("PATH")
509            .map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
510            .unwrap_or_default();
511        self.exec_path_candidates()
512            .iter()
513            .chain(on_path.iter())
514            .any(|dir| credential_helper_executable_at(&dir.join(&program)).is_some())
515    }
516
517    /// The helper to configure when `credential.helper` is unset: the first of
518    /// [`SUGGESTED_HELPERS`] that is installed.
519    ///
520    /// That is the platform's secure store when it is available (the macOS
521    /// keychain, libsecret on Linux), otherwise `store`, which keeps
522    /// credentials in a plaintext file. `None` when none is installed.
523    #[must_use]
524    pub fn suggested_helper(&self) -> Option<&'static str> {
525        SUGGESTED_HELPERS
526            .iter()
527            .copied()
528            .find(|name| self.helper_installed(name))
529    }
530}
531
532/// Helper names [`HelperCredentialProvider::suggested_helper`] tries, best
533/// first: the platform's secure store, then the plaintext `store`.
534#[cfg(target_os = "macos")]
535pub const SUGGESTED_HELPERS: &[&str] = &["osxkeychain", "store"];
536/// Helper names [`HelperCredentialProvider::suggested_helper`] tries, best
537/// first: the platform's secure store, then the plaintext `store`.
538#[cfg(all(unix, not(target_os = "macos")))]
539pub const SUGGESTED_HELPERS: &[&str] = &["libsecret", "store"];
540/// Helper names [`HelperCredentialProvider::suggested_helper`] tries, best
541/// first: the platform's secure store, then the plaintext `store`.
542#[cfg(not(unix))]
543pub const SUGGESTED_HELPERS: &[&str] = &["manager", "store"];
544
545impl CredentialProvider for HelperCredentialProvider {
546    fn fill(&self, input: &Credential) -> Result<Credential> {
547        let mut filled = input.clone();
548        if filled.is_complete() {
549            return Ok(filled);
550        }
551        let target_url = filled.target_url();
552        let helper_list = self.helpers(target_url.as_deref());
553        for helper in &helper_list {
554            let response = invoke_helper(
555                self.command_runner.as_ref(),
556                helper,
557                "get",
558                &self.helper_request(&filled),
559                &self.exec_path_candidates(),
560            )?;
561            if response.wants_quit() {
562                return Err(Error::Message(format!(
563                    "credential helper '{helper}' told us to quit"
564                )));
565            }
566            filled.merge_response(&response);
567            if filled.is_complete() {
568                return Ok(filled);
569            }
570        }
571        let Some(askpass) = self.askpass.as_deref().filter(|_| self.prompts_allowed()) else {
572            return Err(Error::CredentialsUnavailable {
573                helpers: helper_list,
574            });
575        };
576        let sanitize =
577            credential_config_value(&self.config, target_url.as_deref(), "sanitizePrompt")
578                .is_none_or(|value| parse_bool(&value).unwrap_or(true));
579        if filled.username.is_none() {
580            let prompt = filled.prompt("Username", sanitize);
581            filled.username = Some(run_askpass(self.command_runner.as_ref(), askpass, &prompt)?);
582        }
583        if filled.password.is_none() {
584            let prompt = filled.prompt("Password", sanitize);
585            filled.password = Some(run_askpass(self.command_runner.as_ref(), askpass, &prompt)?);
586        }
587        Ok(filled)
588    }
589
590    fn approve(&self, cred: &Credential) -> Result<()> {
591        let target_url = cred.target_url();
592        let request = self.helper_request(cred);
593        for helper in self.helpers(target_url.as_deref()) {
594            invoke_helper(
595                self.command_runner.as_ref(),
596                &helper,
597                "store",
598                &request,
599                &self.exec_path_candidates(),
600            )?;
601        }
602        Ok(())
603    }
604
605    fn reject(&self, cred: &Credential) -> Result<()> {
606        let target_url = cred.target_url();
607        let request = self.helper_request(cred);
608        for helper in self.helpers(target_url.as_deref()) {
609            invoke_helper(
610                self.command_runner.as_ref(),
611                &helper,
612                "erase",
613                &request,
614                &self.exec_path_candidates(),
615            )?;
616        }
617        Ok(())
618    }
619}
620
621/// Build the effective `credential.helper` list in Git order.
622///
623/// Git walks every `credential.helper` and `credential.<URL>.helper` config
624/// entry in load order. URL-scoped entries only apply when the subsection
625/// pattern matches `target_url` (per Git's URL-match rules). For every
626/// applicable entry, a non-empty value is appended to the helper list and an
627/// empty value resets it (Git's `string_list_clear` semantics in
628/// `credential_apply_config_cb`).
629///
630/// `target_url` is the URL we're authenticating against (e.g.
631/// `https://github.com/owner/repo.git`). When `None`, only unscoped
632/// `credential.helper` entries contribute.
633fn credential_helpers(config: &ConfigSet, target_url: Option<&str>) -> Vec<String> {
634    let mut out = Vec::new();
635    for entry in config.entries() {
636        let key = &entry.key;
637        if key.contains('\n') || key.to_ascii_lowercase().contains("%0a") {
638            continue;
639        }
640        let Some(first_dot) = key.find('.') else {
641            continue;
642        };
643        let Some(last_dot) = key.rfind('.') else {
644            continue;
645        };
646        let section = &key[..first_dot];
647        let variable = &key[last_dot + 1..];
648        if !section.eq_ignore_ascii_case("credential") || !variable.eq_ignore_ascii_case("helper") {
649            continue;
650        }
651        if first_dot != last_dot {
652            let subsection = &key[first_dot + 1..last_dot];
653            if percent_decode_lossy(subsection).contains('\n') {
654                continue;
655            }
656            let Some(target) = target_url else {
657                continue;
658            };
659            if !credential_url_matches(subsection, target) {
660                continue;
661            }
662        }
663        let value = entry.value.as_deref().unwrap_or("");
664        if value.trim().is_empty() {
665            out.clear();
666        } else {
667            out.push(value.to_string());
668        }
669    }
670    out
671}
672
673fn credential_url_matches(pattern: &str, target: &str) -> bool {
674    let pattern = percent_decode_lossy(pattern);
675    if pattern.contains('\n') {
676        return false;
677    }
678    let pattern = pattern.trim_end_matches('/');
679    let pattern_no_user = url_without_userinfo(pattern);
680    let pattern_after_user = pattern.rsplit_once('@').map(|(_, host)| host);
681    let target = target.trim_end_matches('/');
682    let target_no_user = url_without_userinfo(target);
683    let target_no_scheme = strip_url_scheme(target);
684    let target_no_scheme_no_user = strip_url_scheme(&target_no_user);
685    let target_path = target_path_component(target);
686
687    let matches = |pattern: &str| {
688        if pattern.starts_with('/') {
689            return credential_prefix_matches(pattern, target_path);
690        }
691        if pattern.ends_with("://") {
692            return target.starts_with(pattern) || target_no_user.starts_with(pattern);
693        }
694        if pattern.contains('*') {
695            return credential_wildcard_matches(pattern, target)
696                || credential_wildcard_matches(pattern, &target_no_user)
697                || credential_wildcard_matches(pattern, target_no_scheme)
698                || credential_wildcard_matches(pattern, target_no_scheme_no_user);
699        }
700        credential_prefix_matches(pattern, target)
701            || credential_prefix_matches(pattern, &target_no_user)
702            || credential_prefix_matches(pattern, target_no_scheme)
703            || credential_prefix_matches(pattern, target_no_scheme_no_user)
704    };
705    matches(pattern)
706        || (pattern_no_user != pattern && matches(&pattern_no_user))
707        || pattern_after_user.is_some_and(matches)
708}
709
710fn credential_prefix_matches(pattern: &str, candidate: &str) -> bool {
711    candidate
712        .strip_prefix(pattern)
713        .is_some_and(|rest| rest.is_empty() || rest.starts_with('/') || pattern.ends_with("://"))
714}
715
716fn credential_wildcard_matches(pattern: &str, candidate: &str) -> bool {
717    let Some((prefix, suffix)) = pattern.split_once('*') else {
718        return false;
719    };
720    let Some(rest) = candidate.strip_prefix(prefix) else {
721        return false;
722    };
723    rest.find(suffix).is_some_and(|idx| {
724        let after = &rest[idx + suffix.len()..];
725        after.is_empty() || after.starts_with('/')
726    })
727}
728
729fn strip_url_scheme(url: &str) -> &str {
730    url.split_once("://").map_or(url, |(_, rest)| rest)
731}
732
733/// Return `url` with any `user:pass@` userinfo removed.
734///
735/// Handles `scheme://user:pass@host/...` and scp-style `user:pass@host:path`
736/// (no scheme). Userinfo is stripped only from the URL authority (the part
737/// before the first `/`, `?`, or `#`); `@` in paths, queries, and fragments is
738/// preserved. The result is safe to show in errors and logs.
739pub(crate) fn url_without_userinfo(url: &str) -> String {
740    let Some((scheme, rest)) = url.split_once("://") else {
741        let (authority, suffix) = split_authority_suffix(url);
742        let authority = strip_userinfo_from_authority(authority);
743        return format!("{authority}{suffix}");
744    };
745    let (authority, suffix) = split_authority_suffix(rest);
746    let authority = strip_userinfo_from_authority(authority);
747    format!("{scheme}://{authority}{suffix}")
748}
749
750fn split_authority_suffix(url: &str) -> (&str, &str) {
751    let authority_end = url
752        .char_indices()
753        .find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
754        .map(|(idx, _)| idx)
755        .unwrap_or(url.len());
756    url.split_at(authority_end)
757}
758
759fn strip_userinfo_from_authority(authority: &str) -> &str {
760    authority
761        .rsplit_once('@')
762        .map_or(authority, |(_, host_port)| host_port)
763}
764
765/// Redact credentials from URL-like `http://` / `https://` substrings in `message`.
766#[cfg_attr(not(feature = "http-ureq"), allow(dead_code))]
767pub(crate) fn redact_embedded_url_credentials(message: &str) -> String {
768    let mut out = String::with_capacity(message.len());
769    let mut rest = message;
770    while let Some(scheme_sep) = rest.find("://") {
771        let before = &rest[..scheme_sep];
772        let scheme_start = before
773            .char_indices()
774            .rfind(|(_, c)| !c.is_ascii_alphanumeric() && *c != '-' && *c != '+' && *c != '.')
775            .map(|(i, c)| i + c.len_utf8())
776            .unwrap_or(0);
777        out.push_str(&rest[..scheme_start]);
778        let url_part = &rest[scheme_start..];
779        let url_end = url_part
780            .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ')' | ']' | ','))
781            .unwrap_or(url_part.len());
782        let url = &url_part[..url_end];
783        out.push_str(&url_without_userinfo(url));
784        rest = &url_part[url_end..];
785    }
786    out.push_str(rest);
787    out
788}
789
790fn target_path_component(url: &str) -> &str {
791    let rest = strip_url_scheme(url);
792    let idx = rest
793        .char_indices()
794        .find_map(|(idx, ch)| matches!(ch, '/' | '?' | '#').then_some(idx))
795        .unwrap_or(rest.len());
796    &rest[idx..]
797}
798
799fn percent_decode_lossy(input: &str) -> String {
800    let mut out = Vec::with_capacity(input.len());
801    let bytes = input.as_bytes();
802    let mut idx = 0;
803    while idx < bytes.len() {
804        if bytes[idx] == b'%' && idx + 2 < bytes.len() {
805            if let (Some(hi), Some(lo)) = (hex_value(bytes[idx + 1]), hex_value(bytes[idx + 2])) {
806                out.push((hi << 4) | lo);
807                idx += 3;
808                continue;
809            }
810        }
811        out.push(bytes[idx]);
812        idx += 1;
813    }
814    String::from_utf8_lossy(&out).into_owned()
815}
816
817/// Bytes a sanitized askpass prompt percent-encodes in the user and path.
818const URL_UNSAFE_PROMPT_BYTES: &[u8] = b" <>\"%{}|\\^`:?#[]@!$&'()*+,;=";
819
820/// Percent-encode control and non-ASCII bytes of `value`, plus every byte for
821/// which `escape` returns true.
822fn percent_encode_where(value: &str, escape: fn(u8) -> bool) -> String {
823    use std::fmt::Write as _;
824    let mut out = String::with_capacity(value.len());
825    for byte in value.bytes() {
826        if byte <= 0x1f || byte >= 0x7f || escape(byte) {
827            let _ = write!(out, "%{byte:02X}");
828        } else {
829            out.push(char::from(byte));
830        }
831    }
832    out
833}
834
835fn hex_value(byte: u8) -> Option<u8> {
836    match byte {
837        b'0'..=b'9' => Some(byte - b'0'),
838        b'a'..=b'f' => Some(byte - b'a' + 10),
839        b'A'..=b'F' => Some(byte - b'A' + 10),
840        _ => None,
841    }
842}
843
844/// Extra `bin` directories to search for credential helpers given Git's `--exec-path`.
845///
846/// Git for Windows uses `{root}/mingw64/libexec/git-core` for exec-path while Git
847/// Credential Manager lives in `{root}/mingw64/bin`, not `{root}/mingw64/libexec/bin`.
848fn credential_helper_bin_dirs_from_exec_path(exec_path: &Path) -> Vec<PathBuf> {
849    let mut bins = Vec::new();
850    let Some(parent) = exec_path.parent() else {
851        return bins;
852    };
853    bins.push(parent.join("bin"));
854    if exec_path.file_name().is_some_and(|name| name == "git-core")
855        && parent.file_name().is_some_and(|name| name == "libexec")
856    {
857        if let Some(toolchain_root) = parent.parent() {
858            bins.push(toolchain_root.join("bin"));
859        }
860    }
861    bins
862}
863
864fn push_exec_path_candidate(out: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>, dir: PathBuf) {
865    if !dir.is_dir() || !seen.insert(dir.clone()) {
866        return;
867    }
868    out.push(dir.clone());
869    for bin in credential_helper_bin_dirs_from_exec_path(&dir) {
870        if bin.is_dir() && seen.insert(bin.clone()) {
871            out.push(bin);
872        }
873    }
874}
875
876/// Directories to search for `git-credential-*` the way Git does
877/// (exec-path before `PATH`). Git installs helpers under e.g.
878/// `/usr/libexec/git-core`, which is not on `PATH`.
879fn credential_helper_exec_path_candidates(
880    runner: &dyn CommandRunner,
881    probed_git_exec_path: &OnceLock<Vec<PathBuf>>,
882) -> Vec<PathBuf> {
883    let mut v = Vec::new();
884    let mut seen = HashSet::new();
885    if let Some(ep) = crate::environment::Environment::empty().var("GIT_EXEC_PATH") {
886        push_exec_path_candidate(&mut v, &mut seen, PathBuf::from(ep.trim()));
887    }
888    for dir in probed_git_exec_path_dirs(runner, probed_git_exec_path) {
889        push_exec_path_candidate(&mut v, &mut seen, dir);
890    }
891    for candidate in [
892        "/usr/libexec/git-core",
893        "/Library/Developer/CommandLineTools/usr/libexec/git-core",
894        "/opt/homebrew/opt/git/libexec/git-core",
895        "/opt/homebrew/libexec/git-core",
896        "/usr/lib/git-core",
897        "/usr/local/libexec/git-core",
898    ] {
899        push_exec_path_candidate(&mut v, &mut seen, PathBuf::from(candidate));
900    }
901    v
902}
903
904fn probed_git_exec_path_dirs(
905    runner: &dyn CommandRunner,
906    probed_git_exec_path: &OnceLock<Vec<PathBuf>>,
907) -> Vec<PathBuf> {
908    probed_git_exec_path
909        .get_or_init(|| probe_git_exec_path(runner))
910        .clone()
911}
912
913fn probe_git_exec_path(runner: &dyn CommandRunner) -> Vec<PathBuf> {
914    let spec = CommandSpec {
915        program: PathBuf::from("git"),
916        args: vec![OsString::from("--exec-path")],
917        shell: None,
918        cwd: None,
919        env: CommandEnvironment::inherit_process_only(),
920        stdin: CommandStdin::Null,
921        stdout: CommandStdio::Pipe,
922        stderr: CommandStdio::Null,
923    };
924    let Ok(child) = runner.spawn(&spec) else {
925        return Vec::new();
926    };
927    let Ok(output) = child.wait_with_output() else {
928        return Vec::new();
929    };
930    if !output.status.success {
931        return Vec::new();
932    }
933    let dir = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
934    if dir.is_dir() {
935        vec![dir]
936    } else {
937        Vec::new()
938    }
939}
940
941fn credential_helper_executable_at(path: &Path) -> Option<PathBuf> {
942    if path.is_file() {
943        return Some(path.to_path_buf());
944    }
945    #[cfg(windows)]
946    {
947        let with_exe = path.with_extension("exe");
948        if with_exe.is_file() {
949            return Some(with_exe);
950        }
951    }
952    None
953}
954
955/// Resolve a helper program name to an executable path. A bare
956/// `git-credential-<name>` is looked up across Git's exec-path candidates
957/// before falling back to `PATH`.
958fn resolve_credential_helper_executable(
959    helper_program: &str,
960    exec_path_candidates: &[PathBuf],
961) -> PathBuf {
962    if helper_program.contains('/') {
963        return PathBuf::from(helper_program);
964    }
965    if let Some(suffix) = helper_program.strip_prefix("git-credential-") {
966        let exe_name = format!("git-credential-{suffix}");
967        for ep in exec_path_candidates {
968            let candidate = ep.join(&exe_name);
969            if let Some(resolved) = credential_helper_executable_at(&candidate) {
970                return resolved;
971            }
972        }
973    }
974    PathBuf::from(helper_program)
975}
976
977/// Invoke an external credential helper program.
978///
979/// The helper may be:
980/// - shell form: `!command ...` (executed by `sh -c`)
981/// - absolute/relative path containing `/`
982/// - bare helper name (expanded to `git-credential-<name>`)
983/// - already-expanded binary (`git-credential-...`)
984///
985/// Bare helper names such as `store` and `cache` expand to `git-credential-*`
986/// and are resolved via Git's exec-path (and `PATH`) like other helpers.
987///
988/// The helper is invoked with one action argument (`get`, `store`, `erase`)
989/// after any arguments from the configured helper string. Credential fields are
990/// written to stdin as `key=value` lines followed by a blank line; stdout is
991/// parsed back into a [`Credential`].
992/// The fields sent to a helper for `creds`.
993///
994/// Like Git, a credential that already has its protocol and host goes to
995/// helpers without `url`: the URL is only used to pick helpers. Helpers parse a
996/// `url` into protocol, host *and path*, so sending the request URL (say
997/// `…/repo.git/info/refs?service=…`) would make every host-wide stored
998/// credential miss.
999fn helper_request(creds: &Credential) -> std::borrow::Cow<'_, Credential> {
1000    if creds.url.is_none() || creds.protocol.is_none() || creds.host.is_none() {
1001        return std::borrow::Cow::Borrowed(creds);
1002    }
1003    let mut request = creds.clone();
1004    request.url = None;
1005    std::borrow::Cow::Owned(request)
1006}
1007
1008fn invoke_helper(
1009    runner: &dyn CommandRunner,
1010    helper: &str,
1011    action: &str,
1012    creds: &Credential,
1013    exec_path_candidates: &[PathBuf],
1014) -> Result<Credential> {
1015    let helper_words = shell_words::split(helper)
1016        .map_err(|e| Error::Message(format!("invalid credential.helper '{helper}': {e}")))?;
1017    let (first_word, extra_args) = match helper_words.split_first() {
1018        Some((first, rest)) => (first.as_str(), rest),
1019        None => ("", &[][..]),
1020    };
1021
1022    let mut stdin_body = helper_request(creds).serialize();
1023    stdin_body.push('\n');
1024    let stdin_bytes = stdin_body.into_bytes();
1025
1026    let spec = if let Some(shell_cmd) = helper.strip_prefix('!') {
1027        CommandSpec {
1028            program: "sh".into(),
1029            args: Vec::new(),
1030            shell: Some(ShellInvocation::DashC {
1031                script: format!("{shell_cmd} {action}"),
1032                argv0: None,
1033                args: Vec::new(),
1034            }),
1035            cwd: None,
1036            env: CommandEnvironment::inherit_process_only(),
1037            stdin: CommandStdin::Pipe(stdin_bytes),
1038            stdout: CommandStdio::Pipe,
1039            stderr: CommandStdio::Inherit,
1040        }
1041    } else {
1042        let helper_program =
1043            if first_word.contains('/') || first_word.starts_with("git-credential-") {
1044                first_word.to_string()
1045            } else {
1046                format!("git-credential-{first_word}")
1047            };
1048        let resolved = resolve_credential_helper_executable(&helper_program, exec_path_candidates);
1049        let mut args: Vec<OsString> = extra_args
1050            .iter()
1051            .map(|a| OsString::from(a.as_str()))
1052            .collect();
1053        args.push(OsString::from(action));
1054        CommandSpec {
1055            program: resolved,
1056            args,
1057            shell: None,
1058            cwd: None,
1059            env: CommandEnvironment::inherit_process_only(),
1060            stdin: CommandStdin::Pipe(stdin_bytes),
1061            stdout: CommandStdio::Pipe,
1062            stderr: CommandStdio::Inherit,
1063        }
1064    };
1065
1066    let output = runner
1067        .spawn(&spec)
1068        .map_err(|e| Error::Message(format!("failed to run credential helper '{helper}': {e}")))?
1069        .wait_with_output()
1070        .map_err(|e| Error::Message(format!("credential helper '{helper}' failed: {e}")))?;
1071    if !output.status.success {
1072        return Err(Error::Message(format!(
1073            "credential helper '{helper}' exited with status {}",
1074            output.status.code.unwrap_or(-1)
1075        )));
1076    }
1077
1078    Ok(Credential::parse_bytes(&output.stdout))
1079}
1080
1081/// Run the askpass `program` with `prompt` as its argument and return its
1082/// answer: stdout up to the first `\r` or `\n`.
1083fn run_askpass(runner: &dyn CommandRunner, program: &Path, prompt: &str) -> Result<String> {
1084    let spec = CommandSpec {
1085        program: program.to_path_buf(),
1086        args: vec![OsString::from(prompt)],
1087        shell: None,
1088        cwd: None,
1089        env: CommandEnvironment::inherit_process_only(),
1090        stdin: CommandStdin::Null,
1091        stdout: CommandStdio::Pipe,
1092        stderr: CommandStdio::Inherit,
1093    };
1094    let output = runner
1095        .spawn(&spec)
1096        .and_then(|child| child.wait_with_output())
1097        .map_err(|e| {
1098            Error::Message(format!(
1099                "could not run askpass program '{}': {e}",
1100                program.display()
1101            ))
1102        })?;
1103    if !output.status.success {
1104        return Err(Error::Message(format!(
1105            "askpass program '{}' exited with status {}",
1106            program.display(),
1107            output.status.code.unwrap_or(-1)
1108        )));
1109    }
1110    let answer = String::from_utf8_lossy(&output.stdout);
1111    Ok(answer
1112        .split(['\r', '\n'])
1113        .next()
1114        .unwrap_or_default()
1115        .to_owned())
1116}
1117
1118/// Whether `credential.useHttpPath` (optionally URL-scoped) is enabled.
1119///
1120/// Exposed so embedders can decide whether to include the `path` field when
1121/// constructing a [`Credential`] for an HTTP(S) target, matching Git.
1122pub fn use_http_path(config: &ConfigSet, target_url: Option<&str>) -> bool {
1123    credential_config_value(config, target_url, "useHttpPath")
1124        .as_deref()
1125        .map(|value| parse_bool(value).unwrap_or(false))
1126        .unwrap_or(false)
1127}
1128
1129fn credential_config_value(
1130    config: &ConfigSet,
1131    target_url: Option<&str>,
1132    variable_name: &str,
1133) -> Option<String> {
1134    let mut out = None;
1135    for entry in config.entries() {
1136        let key = &entry.key;
1137        if key.contains('\n') || key.to_ascii_lowercase().contains("%0a") {
1138            continue;
1139        }
1140        let Some(first_dot) = key.find('.') else {
1141            continue;
1142        };
1143        let Some(last_dot) = key.rfind('.') else {
1144            continue;
1145        };
1146        let section = &key[..first_dot];
1147        let variable = &key[last_dot + 1..];
1148        if !section.eq_ignore_ascii_case("credential")
1149            || !variable.eq_ignore_ascii_case(variable_name)
1150        {
1151            continue;
1152        }
1153        if first_dot != last_dot {
1154            let subsection = &key[first_dot + 1..last_dot];
1155            if percent_decode_lossy(subsection).contains('\n') {
1156                continue;
1157            }
1158            let Some(target) = target_url else {
1159                continue;
1160            };
1161            if !credential_url_matches(subsection, target) {
1162                continue;
1163            }
1164        }
1165        out = entry.value.clone();
1166    }
1167    out
1168}
1169
1170/// Windows Credential Manager backing store for the built-in `manager`
1171/// credential helper (`gs manager`).
1172///
1173/// Git for Windows bundles Git Credential Manager (the `manager` helper), but it
1174/// is only present when Git for Windows is installed. These functions let `gs`
1175/// store and retrieve secrets directly from the Windows Credential Manager
1176/// without that dependency. They are only compiled on Windows; the `gs manager`
1177/// command errors elsewhere.
1178///
1179/// Secrets are stored as `CRED_TYPE_GENERIC` entries keyed by a target name of
1180/// the form `git:<protocol>://<host>`, matching Git's own `wincred` helper so
1181/// the entries are recognizable in the Credential Manager UI.
1182#[cfg(windows)]
1183pub mod windows_store {
1184    use std::ffi::OsStr;
1185    use std::os::windows::ffi::OsStrExt;
1186
1187    use windows_sys::Win32::Foundation::{GetLastError, ERROR_NOT_FOUND};
1188    use windows_sys::Win32::Security::Credentials::{
1189        CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_PERSIST_LOCAL_MACHINE,
1190        CRED_TYPE_GENERIC,
1191    };
1192
1193    use super::Credential;
1194    use crate::error::{Error, Result};
1195
1196    /// The Credential Manager target name a credential is stored under.
1197    fn target_name(cred: &Credential) -> Result<String> {
1198        let protocol = cred
1199            .protocol
1200            .as_deref()
1201            .filter(|p| !p.is_empty())
1202            .ok_or_else(|| Error::Message("credential is missing its protocol".to_owned()))?;
1203        let host = cred
1204            .host
1205            .as_deref()
1206            .filter(|h| !h.is_empty())
1207            .ok_or_else(|| Error::Message("credential is missing its host".to_owned()))?;
1208        Ok(format!("git:{protocol}://{host}"))
1209    }
1210
1211    /// Encode a string as a NUL-terminated UTF-16 buffer for the wide Win32 API.
1212    fn wide(s: &str) -> Vec<u16> {
1213        OsStr::new(s)
1214            .encode_wide()
1215            .chain(std::iter::once(0))
1216            .collect()
1217    }
1218
1219    /// Read a NUL-terminated wide string from a (possibly null) pointer.
1220    ///
1221    /// # Safety
1222    /// `ptr` must be null or point to a NUL-terminated UTF-16 string owned by a
1223    /// live `CREDENTIALW` returned by `CredReadW`.
1224    unsafe fn wstr_to_string(ptr: *const u16) -> String {
1225        if ptr.is_null() {
1226            return String::new();
1227        }
1228        let mut len = 0usize;
1229        while *ptr.add(len) != 0 {
1230            len += 1;
1231        }
1232        String::from_utf16_lossy(std::slice::from_raw_parts(ptr, len))
1233    }
1234
1235    /// Look up the stored username/password for `cred`'s host, if any.
1236    pub fn get(cred: &Credential) -> Result<Option<Credential>> {
1237        let target = wide(&target_name(cred)?);
1238        let mut pcred: *mut CREDENTIALW = std::ptr::null_mut();
1239        // SAFETY: `target` is a valid NUL-terminated wide string and `pcred` is a
1240        // valid out-pointer; on success Windows allocates the credential for us.
1241        let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut pcred) };
1242        if ok == 0 {
1243            // SAFETY: `GetLastError` is always safe to call after a failed Win32 call.
1244            let err = unsafe { GetLastError() };
1245            if err == ERROR_NOT_FOUND {
1246                return Ok(None);
1247            }
1248            return Err(Error::Message(format!(
1249                "reading from the Windows Credential Manager failed (error {err})"
1250            )));
1251        }
1252
1253        // SAFETY: `CredReadW` succeeded, so `pcred` points to a live credential we own
1254        // until `CredFree`. We copy out the fields before freeing it.
1255        let (username, password) = unsafe {
1256            let c = &*pcred;
1257            let username = wstr_to_string(c.UserName);
1258            let password = if c.CredentialBlob.is_null() || c.CredentialBlobSize == 0 {
1259                String::new()
1260            } else {
1261                let bytes =
1262                    std::slice::from_raw_parts(c.CredentialBlob, c.CredentialBlobSize as usize);
1263                String::from_utf8_lossy(bytes).into_owned()
1264            };
1265            (username, password)
1266        };
1267        // SAFETY: `pcred` came from `CredReadW` and has not been freed yet.
1268        unsafe { CredFree(pcred as *const core::ffi::c_void) };
1269
1270        let mut out = Credential::default();
1271        if !username.is_empty() {
1272            out.username = Some(username);
1273        }
1274        if !password.is_empty() {
1275            out.password = Some(password);
1276        }
1277        Ok(Some(out))
1278    }
1279
1280    /// Store `cred`'s password (and username) for its host. A credential with no
1281    /// password is a no-op.
1282    pub fn store(cred: &Credential) -> Result<()> {
1283        let password = match cred.password.as_deref() {
1284            Some(p) if !p.is_empty() => p,
1285            _ => return Ok(()),
1286        };
1287        let mut target = wide(&target_name(cred)?);
1288        let mut username = wide(cred.username.as_deref().unwrap_or(""));
1289        let blob = password.as_bytes();
1290
1291        // SAFETY: zeroing is valid for this plain-old-data struct; we populate the
1292        // fields the API reads below.
1293        let mut credential: CREDENTIALW = unsafe { std::mem::zeroed() };
1294        credential.Type = CRED_TYPE_GENERIC;
1295        credential.TargetName = target.as_mut_ptr();
1296        credential.CredentialBlobSize = blob.len() as u32;
1297        credential.CredentialBlob = blob.as_ptr() as *mut u8;
1298        credential.Persist = CRED_PERSIST_LOCAL_MACHINE;
1299        credential.UserName = username.as_mut_ptr();
1300
1301        // SAFETY: every pointer field references a buffer that outlives the call.
1302        let ok = unsafe { CredWriteW(&credential as *const CREDENTIALW, 0) };
1303        if ok == 0 {
1304            // SAFETY: always safe after a failed Win32 call.
1305            let err = unsafe { GetLastError() };
1306            return Err(Error::Message(format!(
1307                "writing to the Windows Credential Manager failed (error {err})"
1308            )));
1309        }
1310        Ok(())
1311    }
1312
1313    /// Delete the stored credential for `cred`'s host. Succeeds if none exists.
1314    pub fn erase(cred: &Credential) -> Result<()> {
1315        let target = wide(&target_name(cred)?);
1316        // SAFETY: `target` is a valid NUL-terminated wide string.
1317        let ok = unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) };
1318        if ok == 0 {
1319            // SAFETY: always safe after a failed Win32 call.
1320            let err = unsafe { GetLastError() };
1321            if err != ERROR_NOT_FOUND {
1322                return Err(Error::Message(format!(
1323                    "deleting from the Windows Credential Manager failed (error {err})"
1324                )));
1325            }
1326        }
1327        Ok(())
1328    }
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333    use super::*;
1334    use crate::command_runner::{RecordedResponse, RecordingRunner};
1335
1336    #[test]
1337    fn helpers_get_decomposed_fields_without_the_request_url() {
1338        let request = Credential {
1339            protocol: Some("http".into()),
1340            host: Some("127.0.0.1:8080".into()),
1341            url: Some("http://127.0.0.1:8080/app.git/info/refs?service=git-upload-pack".into()),
1342            ..Default::default()
1343        };
1344        assert_eq!(
1345            helper_request(&request).serialize(),
1346            "protocol=http\nhost=127.0.0.1:8080\n"
1347        );
1348
1349        // A bare `url` is all a helper could go on, so it stays.
1350        let url_only = Credential {
1351            url: Some("https://example.com/".into()),
1352            ..Default::default()
1353        };
1354        assert_eq!(
1355            helper_request(&url_only).serialize(),
1356            "url=https://example.com/\n"
1357        );
1358    }
1359
1360    #[cfg(unix)]
1361    #[test]
1362    fn helper_that_exits_without_reading_stdin_still_answers() {
1363        // A request larger than any pipe buffer guarantees the write hits a closed pipe once
1364        // the helper has exited without reading it.
1365        let creds = Credential {
1366            protocol: Some("https".into()),
1367            host: Some("example.com".into()),
1368            password: Some("x".repeat(1 << 20)),
1369            ..Default::default()
1370        };
1371        let response = invoke_helper(
1372            system_command_runner().as_ref(),
1373            "!echo quit=1; exit 0; :",
1374            "get",
1375            &creds,
1376            &[],
1377        )
1378        .expect("helper");
1379        assert!(response.wants_quit());
1380    }
1381
1382    #[test]
1383    fn url_without_userinfo_strips_https_credentials() {
1384        let raw = "https://x-access-token:SECRETTOKEN@github.com/octocat/repo.git/info/refs?service=git-upload-pack";
1385        let redacted = url_without_userinfo(raw);
1386        assert_eq!(
1387            redacted,
1388            "https://github.com/octocat/repo.git/info/refs?service=git-upload-pack"
1389        );
1390        assert!(!redacted.contains("SECRETTOKEN"));
1391        assert!(!redacted.contains("x-access-token"));
1392    }
1393
1394    #[test]
1395    fn url_without_userinfo_preserves_at_in_path_query_and_fragment() {
1396        let path_at = "https://example.com/org/repo@v1.git/info/refs?service=git-upload-pack";
1397        assert_eq!(
1398            url_without_userinfo(path_at),
1399            "https://example.com/org/repo@v1.git/info/refs?service=git-upload-pack"
1400        );
1401
1402        let query_at = "https://example.com/repo.git/info/refs?notify=user@example.net";
1403        assert_eq!(
1404            url_without_userinfo(query_at),
1405            "https://example.com/repo.git/info/refs?notify=user@example.net"
1406        );
1407
1408        let fragment_at = "https://example.com/repo.git#section@anchor";
1409        assert_eq!(
1410            url_without_userinfo(fragment_at),
1411            "https://example.com/repo.git#section@anchor"
1412        );
1413    }
1414
1415    #[test]
1416    fn url_without_userinfo_strips_credentials_but_keeps_path_at() {
1417        let raw =
1418            "https://token:SECRET@127.0.0.1:8080/org/repo@v1.git/info/refs?service=git-upload-pack";
1419        let redacted = url_without_userinfo(raw);
1420        assert_eq!(
1421            redacted,
1422            "https://127.0.0.1:8080/org/repo@v1.git/info/refs?service=git-upload-pack"
1423        );
1424        assert!(!redacted.contains("SECRET"));
1425        assert!(!redacted.contains("token"));
1426    }
1427
1428    #[cfg_attr(
1429        not(feature = "full-tests"),
1430        ignore = "starts system git or is heavy; run with --features full-tests"
1431    )]
1432    #[test]
1433    fn redact_embedded_url_credentials_in_message() {
1434        let msg = "GET failed: https://user:pass@example.com/repo.git (timeout)";
1435        let out = redact_embedded_url_credentials(msg);
1436        assert!(out.contains("https://example.com/repo.git"));
1437        assert!(!out.contains("user:pass"));
1438    }
1439
1440    #[test]
1441    fn parse_round_trips_named_fields() {
1442        let input =
1443            "protocol=https\nhost=example.com\nusername=alice\npassword=secret\n\nignored=x\n";
1444        let cred = Credential::parse(input);
1445        assert_eq!(cred.protocol.as_deref(), Some("https"));
1446        assert_eq!(cred.host.as_deref(), Some("example.com"));
1447        assert_eq!(cred.username.as_deref(), Some("alice"));
1448        assert_eq!(cred.password.as_deref(), Some("secret"));
1449        // Parsing stops at the blank line.
1450        assert!(cred.extra.is_empty());
1451    }
1452
1453    #[test]
1454    fn serialize_uses_canonical_order() {
1455        let cred = Credential {
1456            protocol: Some("https".into()),
1457            host: Some("h".into()),
1458            username: Some("u".into()),
1459            password: Some("p".into()),
1460            ..Default::default()
1461        };
1462        assert_eq!(
1463            cred.serialize(),
1464            "protocol=https\nhost=h\nusername=u\npassword=p\n"
1465        );
1466    }
1467
1468    #[test]
1469    fn target_url_reconstructed_from_fields() {
1470        let cred = Credential {
1471            protocol: Some("https".into()),
1472            host: Some("github.com".into()),
1473            path: Some("o/r.git".into()),
1474            ..Default::default()
1475        };
1476        assert_eq!(
1477            cred.target_url().as_deref(),
1478            Some("https://github.com/o/r.git")
1479        );
1480    }
1481
1482    #[test]
1483    fn from_url_omits_http_path_unless_use_http_path_applies() {
1484        let plain = ConfigSet::new();
1485        let cred = Credential::from_url("http://127.0.0.1:9418/hw.git", &plain).expect("cred");
1486        assert_eq!(cred.protocol.as_deref(), Some("http"));
1487        assert_eq!(cred.host.as_deref(), Some("127.0.0.1:9418"));
1488        assert_eq!(cred.path, None);
1489        assert_eq!(cred.url.as_deref(), Some("http://127.0.0.1:9418/hw.git"));
1490
1491        let mut scoped = ConfigSet::new();
1492        scoped
1493            .add_command_override("credential.https://example.com/team.useHttpPath", "true")
1494            .unwrap();
1495        let in_scope =
1496            Credential::from_url("https://example.com/team/a%20b.git/", &scoped).expect("cred");
1497        assert_eq!(in_scope.path.as_deref(), Some("team/a b.git"));
1498        let out_of_scope =
1499            Credential::from_url("https://example.com/other/repo.git", &scoped).expect("cred");
1500        assert_eq!(out_of_scope.path, None);
1501    }
1502
1503    #[test]
1504    fn from_url_keeps_path_for_non_http_protocols_and_rejects_hostless_urls() {
1505        let cfg = ConfigSet::new();
1506        let cred = Credential::from_url("ftp://files.example/pub/repo.git", &cfg).expect("cred");
1507        assert_eq!(cred.path.as_deref(), Some("pub/repo.git"));
1508        assert_eq!(Credential::from_url("not a url", &cfg), None);
1509        assert_eq!(Credential::from_url("file:///tmp/repo.git", &cfg), None);
1510    }
1511
1512    #[test]
1513    fn helper_request_drops_url_and_fills_missing_fields_from_it() {
1514        let provider = HelperCredentialProvider::new(ConfigSet::new());
1515        let decomposed =
1516            Credential::from_url("http://127.0.0.1:9418/hw.git", &ConfigSet::new()).expect("cred");
1517        assert_eq!(
1518            provider.helper_request(&decomposed).serialize(),
1519            "protocol=http\nhost=127.0.0.1:9418\n"
1520        );
1521
1522        let url_only = Credential {
1523            url: Some("https://example.com/o/r.git".into()),
1524            username: Some("alice".into()),
1525            ..Default::default()
1526        };
1527        assert_eq!(
1528            provider.helper_request(&url_only).serialize(),
1529            "protocol=https\nhost=example.com\nusername=alice\n"
1530        );
1531    }
1532
1533    #[cfg(unix)]
1534    #[test]
1535    fn helpers_never_receive_the_url_key() {
1536        let tmp = tempfile::tempdir().expect("tempdir");
1537        let log = tmp.path().join("helper.log");
1538        let mut cfg = ConfigSet::new();
1539        cfg.add_command_override(
1540            "credential.http://127.0.0.1:9418/hw.git.helper",
1541            &format!(
1542                "!cat >>'{}'; echo username=u; echo password=p; :",
1543                log.display()
1544            ),
1545        )
1546        .unwrap();
1547        let provider = HelperCredentialProvider::new(cfg.clone());
1548        let input = Credential::from_url("http://127.0.0.1:9418/hw.git", &cfg).expect("cred");
1549
1550        let filled = provider
1551            .fill(&input)
1552            .expect("path-scoped helper still matches");
1553        assert_eq!(filled.username.as_deref(), Some("u"));
1554        provider.approve(&filled).expect("store");
1555
1556        assert_eq!(
1557            std::fs::read_to_string(&log).expect("helper log"),
1558            "protocol=http\nhost=127.0.0.1:9418\n\n\
1559             protocol=http\nhost=127.0.0.1:9418\nusername=u\npassword=p\n\n"
1560        );
1561    }
1562
1563    fn env_with(vars: &[(&str, &str)]) -> Environment {
1564        Environment::from_vars(
1565            vars.iter()
1566                .map(|(k, v)| (OsString::from(k), OsString::from(v))),
1567            PathBuf::from("/"),
1568        )
1569    }
1570
1571    fn config_with(entries: &[(&str, &str)]) -> ConfigSet {
1572        let mut cfg = ConfigSet::new();
1573        for (key, value) in entries {
1574            cfg.add_command_override(key, value).unwrap();
1575        }
1576        cfg
1577    }
1578
1579    #[test]
1580    fn askpass_program_prefers_git_askpass_then_core_askpass_then_ssh_askpass() {
1581        let cfg = config_with(&[("core.askPass", "/cfg/askpass")]);
1582        let all = env_with(&[("GIT_ASKPASS", "/env/git"), ("SSH_ASKPASS", "/env/ssh")]);
1583        assert_eq!(askpass_program(&all, &cfg), Some(PathBuf::from("/env/git")));
1584        let ssh_only = env_with(&[("SSH_ASKPASS", "/env/ssh")]);
1585        assert_eq!(
1586            askpass_program(&ssh_only, &cfg),
1587            Some(PathBuf::from("/cfg/askpass"))
1588        );
1589        assert_eq!(
1590            askpass_program(&ssh_only, &ConfigSet::new()),
1591            Some(PathBuf::from("/env/ssh"))
1592        );
1593        assert_eq!(
1594            askpass_program(&Environment::empty(), &ConfigSet::new()),
1595            None
1596        );
1597    }
1598
1599    #[test]
1600    fn askpass_program_empty_value_disables_askpass_and_core_askpass_expands_home() {
1601        let cfg = config_with(&[("core.askPass", "~/bin/askpass")]);
1602        let empty_git = env_with(&[("GIT_ASKPASS", ""), ("SSH_ASKPASS", "/env/ssh")]);
1603        assert_eq!(askpass_program(&empty_git, &cfg), None);
1604        let home = env_with(&[("HOME", "/home/ci")]);
1605        assert_eq!(
1606            askpass_program(&home, &cfg),
1607            Some(PathBuf::from("/home/ci/bin/askpass"))
1608        );
1609        let empty_cfg = config_with(&[("core.askPass", "")]);
1610        assert_eq!(
1611            askpass_program(&env_with(&[("SSH_ASKPASS", "/env/ssh")]), &empty_cfg),
1612            None
1613        );
1614    }
1615
1616    fn askpass_provider(
1617        cfg: ConfigSet,
1618        responses: Vec<RecordedResponse>,
1619    ) -> (HelperCredentialProvider, Arc<RecordingRunner>) {
1620        let runner = Arc::new(RecordingRunner::default());
1621        for response in responses {
1622            runner.push_response(response);
1623        }
1624        let provider = HelperCredentialProvider::new(cfg)
1625            .with_command_runner(runner.clone())
1626            .with_askpass(Some(PathBuf::from("/ci/askpass")));
1627        (provider, runner)
1628    }
1629
1630    fn answer(text: &str) -> RecordedResponse {
1631        RecordedResponse::Output {
1632            stdout: text.as_bytes().to_vec(),
1633            code: 0,
1634        }
1635    }
1636
1637    fn askpass_prompts(runner: &RecordingRunner) -> Vec<String> {
1638        runner
1639            .specs()
1640            .iter()
1641            .map(|spec| {
1642                assert_eq!(spec.program, PathBuf::from("/ci/askpass"));
1643                assert!(spec.shell.is_none(), "askpass runs without a shell");
1644                assert!(matches!(spec.stdin, CommandStdin::Null));
1645                assert_eq!(spec.args.len(), 1, "the prompt is the only argument");
1646                spec.args[0].to_string_lossy().into_owned()
1647            })
1648            .collect()
1649    }
1650
1651    #[test]
1652    fn fill_asks_askpass_for_username_then_password_and_keeps_the_first_line() {
1653        let (provider, runner) = askpass_provider(
1654            ConfigSet::new(),
1655            vec![answer("ci\nignored\n"), answer("s3cret\r\n")],
1656        );
1657        let input =
1658            Credential::from_url("http://127.0.0.1:9431/hello.git", &ConfigSet::new()).unwrap();
1659        let filled = provider.fill(&input).expect("askpass fills");
1660        assert_eq!(filled.username.as_deref(), Some("ci"));
1661        assert_eq!(filled.password.as_deref(), Some("s3cret"));
1662        assert_eq!(
1663            askpass_prompts(&runner),
1664            [
1665                "Username for 'http://127.0.0.1:9431': ",
1666                "Password for 'http://ci@127.0.0.1:9431': ",
1667            ]
1668        );
1669    }
1670
1671    #[test]
1672    fn fill_asks_only_for_the_password_when_the_url_names_the_user() {
1673        let cfg = config_with(&[("credential.useHttpPath", "true")]);
1674        let (provider, runner) = askpass_provider(cfg.clone(), vec![answer("tok")]);
1675        let input = Credential::from_url("https://ci@example.com/o/r.git", &cfg).unwrap();
1676        let filled = provider.fill(&input).expect("askpass fills");
1677        assert_eq!(filled.username.as_deref(), Some("ci"));
1678        assert_eq!(filled.password.as_deref(), Some("tok"));
1679        assert_eq!(
1680            askpass_prompts(&runner),
1681            ["Password for 'https://ci@example.com/o/r.git': "]
1682        );
1683    }
1684
1685    #[test]
1686    fn askpass_prompt_is_sanitized_unless_disabled() {
1687        let cred = Credential {
1688            protocol: Some("https".into()),
1689            host: Some("example.com".into()),
1690            username: Some("a b/c\x1b".into()),
1691            path: Some("o/r e.git".into()),
1692            ..Default::default()
1693        };
1694        assert_eq!(
1695            cred.prompt("Password", true),
1696            "Password for 'https://a%20b%2Fc%1B@example.com/o/r%20e.git': "
1697        );
1698        assert_eq!(
1699            cred.prompt("Password", false),
1700            "Password for 'https://a b/c\x1b@example.com/o/r e.git': "
1701        );
1702        assert_eq!(Credential::default().prompt("Username", true), "Username: ");
1703
1704        let (provider, runner) = askpass_provider(
1705            config_with(&[("credential.sanitizePrompt", "false")]),
1706            vec![answer("p")],
1707        );
1708        let input = Credential {
1709            username: Some("a b".into()),
1710            ..cred
1711        };
1712        provider.fill(&input).expect("askpass fills");
1713        assert_eq!(
1714            askpass_prompts(&runner),
1715            ["Password for 'https://a b@example.com/o/r e.git': "]
1716        );
1717    }
1718
1719    #[test]
1720    fn url_scoped_sanitize_prompt_applies_only_to_matching_urls() {
1721        let cfg = config_with(&[("credential.https://example.com.sanitizePrompt", "false")]);
1722        for (url, expected) in [
1723            (
1724                "https://a%20b@example.com/r.git",
1725                "Password for 'https://a b@example.com': ",
1726            ),
1727            (
1728                "https://a%20b@other.example/r.git",
1729                "Password for 'https://a%20b@other.example': ",
1730            ),
1731        ] {
1732            let (provider, runner) = askpass_provider(cfg.clone(), vec![answer("p")]);
1733            let input = Credential::from_url(url, &cfg).unwrap();
1734            provider.fill(&input).expect("askpass fills");
1735            assert_eq!(askpass_prompts(&runner), [expected], "{url}");
1736        }
1737    }
1738
1739    #[test]
1740    fn empty_askpass_answers_are_kept_like_git() {
1741        let (provider, runner) = askpass_provider(ConfigSet::new(), vec![answer("\n"), answer("")]);
1742        let input = Credential::from_url("https://example.com/r.git", &ConfigSet::new()).unwrap();
1743        let filled = provider
1744            .fill(&input)
1745            .expect("empty answers are still answers");
1746        assert_eq!(filled.username.as_deref(), Some(""));
1747        assert_eq!(filled.password.as_deref(), Some(""));
1748        assert_eq!(runner.specs().len(), 2, "each field is asked once");
1749    }
1750
1751    #[test]
1752    fn failing_askpass_is_an_error_naming_the_program() {
1753        let (provider, _runner) =
1754            askpass_provider(ConfigSet::new(), vec![RecordedResponse::Exit(3)]);
1755        let input = Credential::from_url("https://example.com/r.git", &ConfigSet::new()).unwrap();
1756        let err = provider.fill(&input).expect_err("askpass failed");
1757        let message = err.to_string();
1758        assert!(message.contains("/ci/askpass"), "{message}");
1759        assert!(message.contains("status 3"), "{message}");
1760    }
1761
1762    #[test]
1763    fn no_askpass_or_credential_interactive_never_keeps_fill_non_interactive() {
1764        let input = Credential::from_url("https://example.com/r.git", &ConfigSet::new()).unwrap();
1765        let err = HelperCredentialProvider::new(ConfigSet::new())
1766            .fill(&input)
1767            .expect_err("nothing can answer");
1768        assert_eq!(err.to_string(), NON_INTERACTIVE_MESSAGE);
1769
1770        for value in ["never", "false"] {
1771            let (provider, runner) = askpass_provider(
1772                config_with(&[("credential.interactive", value)]),
1773                vec![answer("unused")],
1774            );
1775            let err = provider.fill(&input).expect_err("askpass disabled");
1776            assert_eq!(err.to_string(), NON_INTERACTIVE_MESSAGE);
1777            assert!(
1778                runner.specs().is_empty(),
1779                "askpass must not run for {value}"
1780            );
1781        }
1782    }
1783
1784    #[test]
1785    fn unavailable_credentials_name_the_helpers_that_were_asked() {
1786        let input = Credential::from_url("https://example.com/r.git", &ConfigSet::new()).unwrap();
1787        let err = HelperCredentialProvider::new(ConfigSet::new())
1788            .fill(&input)
1789            .expect_err("no helper");
1790        assert!(
1791            matches!(&err, Error::CredentialsUnavailable { helpers } if helpers.is_empty()),
1792            "{err:?}"
1793        );
1794
1795        let runner = Arc::new(RecordingRunner::default());
1796        runner.push_response(answer(""));
1797        let err = HelperCredentialProvider::new(config_with(&[("credential.helper", "!empty")]))
1798            .with_command_runner(runner.clone())
1799            .fill(&input)
1800            .expect_err("the helper has nothing");
1801        assert_eq!(err.to_string(), NON_INTERACTIVE_MESSAGE);
1802        assert!(
1803            runner
1804                .specs()
1805                .iter()
1806                .any(|spec| format!("{spec:?}").contains("empty")),
1807            "the helper was asked"
1808        );
1809        let failure = crate::error::AuthFailure::from_fill_error(err);
1810        assert_eq!(
1811            failure,
1812            crate::error::AuthFailure::CredentialsUnavailable {
1813                detail: NON_INTERACTIVE_MESSAGE.to_owned(),
1814                helpers: vec!["!empty".to_owned()],
1815            }
1816        );
1817
1818        let other =
1819            crate::error::AuthFailure::from_fill_error(Error::Message("helper crashed".into()));
1820        assert!(matches!(
1821            other,
1822            crate::error::AuthFailure::CredentialsUnavailable { detail, helpers }
1823                if detail == "helper crashed" && helpers.is_empty()
1824        ));
1825    }
1826
1827    #[test]
1828    fn from_url_takes_decoded_userinfo_and_keeps_it_out_of_the_url() {
1829        let cfg = ConfigSet::new();
1830        let cred =
1831            Credential::from_url("https://ci%40corp:p%3Ass@example.com/r.git", &cfg).unwrap();
1832        assert_eq!(cred.username.as_deref(), Some("ci@corp"));
1833        assert_eq!(cred.password.as_deref(), Some("p:ss"));
1834        assert_eq!(cred.url.as_deref(), Some("https://example.com/r.git"));
1835        assert!(cred.is_complete());
1836
1837        let user_only = Credential::from_url("https://ci@example.com/r.git", &cfg).unwrap();
1838        assert_eq!(user_only.username.as_deref(), Some("ci"));
1839        assert_eq!(user_only.password, None);
1840    }
1841
1842    #[cfg(unix)]
1843    #[test]
1844    fn helpers_answer_before_askpass_runs() {
1845        let tmp = tempfile::tempdir().expect("tempdir");
1846        let askpass = tmp.path().join("askpass");
1847        let log = tmp.path().join("askpass.log");
1848        std::fs::write(
1849            &askpass,
1850            format!(
1851                "#!/bin/sh\necho \"$1\" >>'{}'\necho from-askpass\n",
1852                log.display()
1853            ),
1854        )
1855        .unwrap();
1856        let mut perms = std::fs::metadata(&askpass).unwrap().permissions();
1857        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
1858        std::fs::set_permissions(&askpass, perms).unwrap();
1859
1860        let cfg = config_with(&[("credential.helper", "!echo username=helper-user; :")]);
1861        let provider = HelperCredentialProvider::new(cfg.clone()).with_askpass(Some(askpass));
1862        let input = Credential::from_url("https://example.com/r.git", &cfg).unwrap();
1863        let filled = provider.fill(&input).expect("helper plus askpass");
1864        assert_eq!(filled.username.as_deref(), Some("helper-user"));
1865        assert_eq!(filled.password.as_deref(), Some("from-askpass"));
1866        assert_eq!(
1867            std::fs::read_to_string(&log).unwrap(),
1868            "Password for 'https://helper-user@example.com': \n"
1869        );
1870
1871        let complete =
1872            config_with(&[("credential.helper", "!echo username=u; echo password=p; :")]);
1873        let provider = HelperCredentialProvider::new(complete.clone())
1874            .with_askpass(Some(tmp.path().join("askpass")));
1875        provider.fill(&input).expect("helper answers");
1876        assert_eq!(
1877            std::fs::read_to_string(&log).unwrap().lines().count(),
1878            1,
1879            "askpass must not run when a helper completes the credential"
1880        );
1881    }
1882
1883    #[test]
1884    fn git_for_windows_exec_path_adds_mingw64_bin_not_libexec_bin() {
1885        // Forward slashes so the path parses on Unix CI as well as on Windows.
1886        let exec_path = PathBuf::from("C:/Program Files/Git/mingw64/libexec/git-core");
1887        let bins = credential_helper_bin_dirs_from_exec_path(&exec_path);
1888        let mingw_bin = PathBuf::from("C:/Program Files/Git/mingw64/bin");
1889        assert!(
1890            bins.contains(&mingw_bin),
1891            "GCM lives in mingw64/bin, got {bins:?}"
1892        );
1893        let libexec_bin = PathBuf::from("C:/Program Files/Git/mingw64/libexec/bin");
1894        assert!(
1895            bins.contains(&libexec_bin),
1896            "libexec/bin is still listed for other layouts: {bins:?}"
1897        );
1898    }
1899
1900    #[test]
1901    fn resolve_manager_finds_helper_in_git_for_windows_layout() {
1902        use std::collections::HashSet;
1903        use std::fs;
1904
1905        let tmp = tempfile::tempdir().expect("tempdir");
1906        let git_core = tmp.path().join("mingw64/libexec/git-core");
1907        let mingw_bin = tmp.path().join("mingw64/bin");
1908        fs::create_dir_all(&git_core).expect("git-core dir");
1909        fs::create_dir_all(&mingw_bin).expect("mingw bin dir");
1910        fs::write(mingw_bin.join("git-credential-manager"), b"").expect("fake gcm");
1911
1912        let mut candidates = Vec::new();
1913        let mut seen = HashSet::new();
1914        push_exec_path_candidate(&mut candidates, &mut seen, git_core);
1915
1916        let resolved = candidates.iter().find_map(|ep| {
1917            let candidate = ep.join("git-credential-manager");
1918            credential_helper_executable_at(&candidate)
1919        });
1920        assert_eq!(
1921            resolved,
1922            Some(mingw_bin.join("git-credential-manager")),
1923            "manager helper must resolve via mingw64/bin, searched {candidates:?}"
1924        );
1925    }
1926
1927    #[test]
1928    fn installed_helpers_are_found_in_the_exec_path() {
1929        let dir = tempfile::tempdir().unwrap();
1930        let program = dir.path().join("git-credential-osxkeychain");
1931        std::fs::write(&program, "#!/bin/sh\n").unwrap();
1932        let provider = HelperCredentialProvider::new(ConfigSet::default());
1933        provider
1934            .probed_git_exec_path
1935            .set(vec![dir.path().to_path_buf()])
1936            .unwrap();
1937        assert!(provider.helper_installed("osxkeychain"));
1938        assert!(!provider.helper_installed("no-such-helper-anywhere"));
1939        let suggested = provider.suggested_helper();
1940        assert!(
1941            suggested.is_none_or(|name| SUGGESTED_HELPERS.contains(&name)),
1942            "{suggested:?}"
1943        );
1944        #[cfg(target_os = "macos")]
1945        assert_eq!(suggested, Some("osxkeychain"));
1946    }
1947}