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