Skip to main content

grit_lib/
credentials.rs

1//! Credential layer — Git-compatible credential filling/approval/rejection for
2//! library embedders.
3//!
4//! This is the reusable core lifted from the CLI's `grit credential` command
5//! (`grit/src/commands/credential.rs`). It exposes:
6//!
7//! - [`Credential`] — a structured credential with the standard Git fields
8//!   ([`protocol`](Credential::protocol), [`host`](Credential::host),
9//!   [`path`](Credential::path), [`username`](Credential::username),
10//!   [`password`](Credential::password), [`url`](Credential::url)) plus
11//!   parsing/serialization in Git's `key=value\n…\n\n` credential wire format
12//!   ([`Credential::parse`] / [`Credential::serialize`]).
13//! - [`CredentialProvider`] — the pluggable seam an embedder implements (or
14//!   wraps) to supply credentials.
15//! - [`HelperCredentialProvider`] — the Git-compatible default that runs the
16//!   configured `credential.helper` / `credential.<url>.helper` programs
17//!   (shell `!cmd`, the built-in `store`/`cache` helpers, and external
18//!   `git-credential-*` binaries).
19//!
20//! ## Non-interactive by design
21//!
22//! Unlike the CLI, [`HelperCredentialProvider`] **never** prompts on a TTY or
23//! via askpass. When the configured helpers cannot supply a usable
24//! username/password, [`fill`](CredentialProvider::fill) returns a typed
25//! [`Error::Message`] (see [`NON_INTERACTIVE_MESSAGE`]) rather than blocking on
26//! `/dev/tty`. Interactive prompting is an explicitly opt-in concern an
27//! embedder can layer on top.
28
29use std::io::Write;
30use std::path::PathBuf;
31use std::process::{Command, Stdio};
32
33use crate::config::{parse_bool, ConfigSet};
34use crate::error::{Error, Result};
35
36/// Message returned (as [`Error::Message`]) when credentials are required but
37/// no configured helper could supply a complete username/password and
38/// interactive prompting is disallowed.
39pub const NON_INTERACTIVE_MESSAGE: &str = "credentials required but unavailable (non-interactive)";
40
41/// A structured Git credential.
42///
43/// Mirrors the fields Git's credential protocol exchanges. Round-trips through
44/// the `key=value\n…\n\n` wire format via [`Credential::parse`] and
45/// [`Credential::serialize`]. Any keys outside the named fields below
46/// (`capability[]`, `authtype`, `password_expiry_utc`, …) are preserved in
47/// [`extra`](Credential::extra) so they survive a parse/serialize round-trip
48/// and are forwarded to helpers unchanged.
49#[derive(Clone, Debug, Default, PartialEq, Eq)]
50pub struct Credential {
51    /// `protocol` field (e.g. `https`, `http`, `ssh`).
52    pub protocol: Option<String>,
53    /// `host` field, optionally including a `:port` suffix.
54    pub host: Option<String>,
55    /// `path` field (repository path on the host).
56    pub path: Option<String>,
57    /// `username` field.
58    pub username: Option<String>,
59    /// `password` field (the secret).
60    pub password: Option<String>,
61    /// `url` field — a full URL Git can decompose into the fields above.
62    pub url: Option<String>,
63    /// Any additional `key=value` pairs, in wire order. Multi-valued keys such
64    /// as `capability[]` may appear more than once.
65    pub extra: Vec<(String, String)>,
66}
67
68impl Credential {
69    /// Parse a credential from Git's `key=value\n…` wire format.
70    ///
71    /// Parsing stops at the first blank line (Git's record terminator) or at
72    /// EOF. Trailing `\r` is stripped from each line so the parser accepts both
73    /// LF and CRLF input. Lines without an `=` are ignored.
74    pub fn parse(input: &str) -> Self {
75        let mut cred = Credential::default();
76        for line in input.lines() {
77            let line = line.trim_end_matches('\r');
78            if line.is_empty() {
79                break;
80            }
81            let Some((key, value)) = line.split_once('=') else {
82                continue;
83            };
84            cred.set(key, value);
85        }
86        cred
87    }
88
89    /// Parse from raw bytes (lossy UTF-8); convenience for helper stdout.
90    pub fn parse_bytes(bytes: &[u8]) -> Self {
91        Self::parse(&String::from_utf8_lossy(bytes))
92    }
93
94    /// Serialize to Git's `key=value\n` wire format (no trailing blank line).
95    ///
96    /// Fields are emitted in Git's canonical order
97    /// (protocol, host, path, username, password, url) followed by any
98    /// [`extra`](Credential::extra) entries in their stored order.
99    pub fn serialize(&self) -> String {
100        let mut out = String::new();
101        for (key, value) in self.iter_pairs() {
102            out.push_str(&key);
103            out.push('=');
104            out.push_str(&value);
105            out.push('\n');
106        }
107        out
108    }
109
110    /// Set a field by its Git key name. Unknown keys land in
111    /// [`extra`](Credential::extra). Multi-valued keys (those ending in `[]`)
112    /// always append; named keys overwrite.
113    fn set(&mut self, key: &str, value: &str) {
114        match key {
115            "protocol" => self.protocol = Some(value.to_string()),
116            "host" => self.host = Some(value.to_string()),
117            "path" => self.path = Some(value.to_string()),
118            "username" => self.username = Some(value.to_string()),
119            "password" => self.password = Some(value.to_string()),
120            "url" => self.url = Some(value.to_string()),
121            _ => {
122                if key.ends_with("[]") {
123                    self.extra.push((key.to_string(), value.to_string()));
124                } else if let Some(slot) =
125                    self.extra.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v)
126                {
127                    *slot = value.to_string();
128                } else {
129                    self.extra.push((key.to_string(), value.to_string()));
130                }
131            }
132        }
133    }
134
135    /// Look up an `extra` key (first match).
136    fn extra_get(&self, key: &str) -> Option<&str> {
137        self.extra
138            .iter()
139            .find(|(k, _)| k == key)
140            .map(|(_, v)| v.as_str())
141    }
142
143    /// Iterate the credential's key/value pairs in Git's canonical wire order.
144    fn iter_pairs(&self) -> Vec<(String, String)> {
145        let mut pairs = Vec::new();
146        if let Some(v) = &self.protocol {
147            pairs.push(("protocol".to_string(), v.clone()));
148        }
149        if let Some(v) = &self.host {
150            pairs.push(("host".to_string(), v.clone()));
151        }
152        if let Some(v) = &self.path {
153            pairs.push(("path".to_string(), v.clone()));
154        }
155        if let Some(v) = &self.username {
156            pairs.push(("username".to_string(), v.clone()));
157        }
158        if let Some(v) = &self.password {
159            pairs.push(("password".to_string(), v.clone()));
160        }
161        if let Some(v) = &self.url {
162            pairs.push(("url".to_string(), v.clone()));
163        }
164        for (k, v) in &self.extra {
165            pairs.push((k.clone(), v.clone()));
166        }
167        pairs
168    }
169
170    /// True when the credential carries a usable username **and** password.
171    pub fn is_complete(&self) -> bool {
172        self.username.as_deref().is_some_and(|s| !s.is_empty())
173            && self.password.as_deref().is_some_and(|s| !s.is_empty())
174    }
175
176    /// The URL this credential authenticates against, for matching
177    /// `credential.<url>.helper` config entries. Prefers an explicit
178    /// [`url`](Credential::url); otherwise reconstructs it from
179    /// protocol/host/path (matching Git's `credential_apply_config`).
180    pub fn target_url(&self) -> Option<String> {
181        if let Some(u) = self.url.as_deref().filter(|u| !u.trim().is_empty()) {
182            return Some(u.to_string());
183        }
184        let protocol = self.protocol.as_deref()?;
185        let host = self.host.as_deref()?;
186        let mut url = format!("{protocol}://");
187        if let Some(username) = self.username.as_deref().filter(|u| !u.is_empty()) {
188            url.push_str(username);
189            url.push('@');
190        }
191        url.push_str(host);
192        if let Some(path) = self.path.as_deref().filter(|p| !p.is_empty()) {
193            if !path.starts_with('/') {
194                url.push('/');
195            }
196            url.push_str(path);
197        }
198        Some(url)
199    }
200
201    /// Merge a helper's `get` response into this credential: fill any missing
202    /// username/password (and other recognized fields) without clobbering
203    /// values we already hold. `quit` and `capability[]` are tracked in
204    /// `extra` for the caller to inspect.
205    fn merge_response(&mut self, response: &Credential) {
206        if self.username.is_none() {
207            self.username = response.username.clone();
208        }
209        if self.password.is_none() {
210            self.password = response.password.clone();
211        }
212        if self.protocol.is_none() {
213            self.protocol = response.protocol.clone();
214        }
215        if self.host.is_none() {
216            self.host = response.host.clone();
217        }
218        if self.path.is_none() {
219            self.path = response.path.clone();
220        }
221        for (k, v) in &response.extra {
222            // Preserve quit signalling for callers; other extras are advisory.
223            if k == "quit" {
224                self.set(k, v);
225            }
226        }
227    }
228
229    /// Did a helper signal `quit=1`/`quit=true` (stop querying further helpers)?
230    fn wants_quit(&self) -> bool {
231        matches!(self.extra_get("quit"), Some("1") | Some("true"))
232    }
233}
234
235/// The pluggable credential seam an embedder implements (or wraps).
236///
237/// All three methods take a (partial) [`Credential`] describing the target and
238/// return a result; transports call [`fill`](CredentialProvider::fill) before a
239/// request and [`approve`](CredentialProvider::approve) /
240/// [`reject`](CredentialProvider::reject) after, mirroring Git's
241/// `credential_fill` / `credential_approve` / `credential_reject`.
242pub trait CredentialProvider {
243    /// Fill in missing fields (typically username/password) for `input`,
244    /// returning a more-complete [`Credential`]. Implementations that cannot
245    /// supply a usable credential should return a typed [`Error`] rather than
246    /// block on interactive input.
247    fn fill(&self, input: &Credential) -> Result<Credential>;
248
249    /// Mark `cred` as known-good (helpers `store` it).
250    fn approve(&self, cred: &Credential) -> Result<()>;
251
252    /// Mark `cred` as known-bad (helpers `erase` it).
253    fn reject(&self, cred: &Credential) -> Result<()>;
254}
255
256/// Git-compatible [`CredentialProvider`] that runs the configured
257/// `credential.helper` programs.
258///
259/// Built from a [`ConfigSet`]; it resolves the helper list per the target URL
260/// (so `credential.<url>.helper` entries are honored) and invokes each helper
261/// with `get` (for [`fill`](CredentialProvider::fill)), `store` (for
262/// [`approve`](CredentialProvider::approve)), or `erase` (for
263/// [`reject`](CredentialProvider::reject)) exactly as Git does.
264///
265/// **Never prompts.** If no helper yields a complete credential,
266/// [`fill`](CredentialProvider::fill) returns [`Error::Message`] with
267/// [`NON_INTERACTIVE_MESSAGE`].
268pub struct HelperCredentialProvider {
269    config: ConfigSet,
270}
271
272impl HelperCredentialProvider {
273    /// Build a provider from a loaded [`ConfigSet`].
274    pub fn new(config: ConfigSet) -> Self {
275        Self { config }
276    }
277
278    /// The ordered helper list applicable to `target_url`.
279    fn helpers(&self, target_url: Option<&str>) -> Vec<String> {
280        credential_helpers(&self.config, target_url)
281    }
282}
283
284impl CredentialProvider for HelperCredentialProvider {
285    fn fill(&self, input: &Credential) -> Result<Credential> {
286        let mut filled = input.clone();
287        if filled.is_complete() {
288            return Ok(filled);
289        }
290        let target_url = filled.target_url();
291        for helper in self.helpers(target_url.as_deref()) {
292            let response = invoke_helper(&helper, "get", &filled)?;
293            if response.wants_quit() {
294                return Err(Error::Message(format!(
295                    "credential helper '{helper}' told us to quit"
296                )));
297            }
298            filled.merge_response(&response);
299            if filled.is_complete() {
300                return Ok(filled);
301            }
302        }
303        // No helper could complete the credential. The library default does NOT
304        // fall back to an interactive prompt; surface a typed error instead.
305        Err(Error::Message(NON_INTERACTIVE_MESSAGE.to_string()))
306    }
307
308    fn approve(&self, cred: &Credential) -> Result<()> {
309        let target_url = cred.target_url();
310        for helper in self.helpers(target_url.as_deref()) {
311            invoke_helper(&helper, "store", cred)?;
312        }
313        Ok(())
314    }
315
316    fn reject(&self, cred: &Credential) -> Result<()> {
317        let target_url = cred.target_url();
318        for helper in self.helpers(target_url.as_deref()) {
319            invoke_helper(&helper, "erase", cred)?;
320        }
321        Ok(())
322    }
323}
324
325/// Build the effective `credential.helper` list in Git order.
326///
327/// Git walks every `credential.helper` and `credential.<URL>.helper` config
328/// entry in load order. URL-scoped entries only apply when the subsection
329/// pattern matches `target_url` (per Git's URL-match rules). For every
330/// applicable entry, a non-empty value is appended to the helper list and an
331/// empty value resets it (Git's `string_list_clear` semantics in
332/// `credential_apply_config_cb`).
333///
334/// `target_url` is the URL we're authenticating against (e.g.
335/// `https://github.com/owner/repo.git`). When `None`, only unscoped
336/// `credential.helper` entries contribute.
337fn credential_helpers(config: &ConfigSet, target_url: Option<&str>) -> Vec<String> {
338    let mut out = Vec::new();
339    for entry in config.entries() {
340        let key = &entry.key;
341        if key.contains('\n') || key.to_ascii_lowercase().contains("%0a") {
342            continue;
343        }
344        let Some(first_dot) = key.find('.') else {
345            continue;
346        };
347        let Some(last_dot) = key.rfind('.') else {
348            continue;
349        };
350        let section = &key[..first_dot];
351        let variable = &key[last_dot + 1..];
352        if !section.eq_ignore_ascii_case("credential") || !variable.eq_ignore_ascii_case("helper") {
353            continue;
354        }
355        if first_dot != last_dot {
356            let subsection = &key[first_dot + 1..last_dot];
357            if percent_decode_lossy(subsection).contains('\n') {
358                continue;
359            }
360            let Some(target) = target_url else {
361                continue;
362            };
363            if !credential_url_matches(subsection, target) {
364                continue;
365            }
366        }
367        let value = entry.value.as_deref().unwrap_or("");
368        if value.trim().is_empty() {
369            out.clear();
370        } else {
371            out.push(value.to_string());
372        }
373    }
374    out
375}
376
377fn credential_url_matches(pattern: &str, target: &str) -> bool {
378    let pattern = percent_decode_lossy(pattern);
379    if pattern.contains('\n') {
380        return false;
381    }
382    let pattern = pattern.trim_end_matches('/');
383    let pattern_no_user = strip_url_userinfo(pattern);
384    let pattern_after_user = pattern.rsplit_once('@').map(|(_, host)| host);
385    let target = target.trim_end_matches('/');
386    let target_no_user = strip_url_userinfo(target);
387    let target_no_scheme = strip_url_scheme(target);
388    let target_no_scheme_no_user = strip_url_scheme(&target_no_user);
389    let target_path = target_path_component(target);
390
391    let matches = |pattern: &str| {
392        if pattern.starts_with('/') {
393            return credential_prefix_matches(pattern, target_path);
394        }
395        if pattern.ends_with("://") {
396            return target.starts_with(pattern) || target_no_user.starts_with(pattern);
397        }
398        if pattern.contains('*') {
399            return credential_wildcard_matches(pattern, target)
400                || credential_wildcard_matches(pattern, &target_no_user)
401                || credential_wildcard_matches(pattern, target_no_scheme)
402                || credential_wildcard_matches(pattern, target_no_scheme_no_user);
403        }
404        credential_prefix_matches(pattern, target)
405            || credential_prefix_matches(pattern, &target_no_user)
406            || credential_prefix_matches(pattern, target_no_scheme)
407            || credential_prefix_matches(pattern, target_no_scheme_no_user)
408    };
409    matches(pattern)
410        || (pattern_no_user != pattern && matches(&pattern_no_user))
411        || pattern_after_user.is_some_and(matches)
412}
413
414fn credential_prefix_matches(pattern: &str, candidate: &str) -> bool {
415    candidate
416        .strip_prefix(pattern)
417        .is_some_and(|rest| rest.is_empty() || rest.starts_with('/') || pattern.ends_with("://"))
418}
419
420fn credential_wildcard_matches(pattern: &str, candidate: &str) -> bool {
421    let Some((prefix, suffix)) = pattern.split_once('*') else {
422        return false;
423    };
424    let Some(rest) = candidate.strip_prefix(prefix) else {
425        return false;
426    };
427    rest.find(suffix).is_some_and(|idx| {
428        let after = &rest[idx + suffix.len()..];
429        after.is_empty() || after.starts_with('/')
430    })
431}
432
433fn strip_url_scheme(url: &str) -> &str {
434    url.split_once("://").map_or(url, |(_, rest)| rest)
435}
436
437fn strip_url_userinfo(url: &str) -> String {
438    let Some((scheme, rest)) = url.split_once("://") else {
439        return url
440            .rsplit_once('@')
441            .map_or(url, |(_, host)| host)
442            .to_string();
443    };
444    rest.rsplit_once('@')
445        .map_or_else(|| url.to_string(), |(_, host)| format!("{scheme}://{host}"))
446}
447
448fn target_path_component(url: &str) -> &str {
449    let rest = strip_url_scheme(url);
450    let idx = rest
451        .char_indices()
452        .find_map(|(idx, ch)| matches!(ch, '/' | '?' | '#').then_some(idx))
453        .unwrap_or(rest.len());
454    &rest[idx..]
455}
456
457fn percent_decode_lossy(input: &str) -> String {
458    let mut out = Vec::with_capacity(input.len());
459    let bytes = input.as_bytes();
460    let mut idx = 0;
461    while idx < bytes.len() {
462        if bytes[idx] == b'%' && idx + 2 < bytes.len() {
463            if let (Some(hi), Some(lo)) = (hex_value(bytes[idx + 1]), hex_value(bytes[idx + 2])) {
464                out.push((hi << 4) | lo);
465                idx += 3;
466                continue;
467            }
468        }
469        out.push(bytes[idx]);
470        idx += 1;
471    }
472    String::from_utf8_lossy(&out).into_owned()
473}
474
475fn hex_value(byte: u8) -> Option<u8> {
476    match byte {
477        b'0'..=b'9' => Some(byte - b'0'),
478        b'a'..=b'f' => Some(byte - b'a' + 10),
479        b'A'..=b'F' => Some(byte - b'A' + 10),
480        _ => None,
481    }
482}
483
484/// Directories to search for `git-credential-*` the way Git does
485/// (exec-path before `PATH`). Git installs helpers under e.g.
486/// `/usr/libexec/git-core`, which is not on `PATH`.
487fn credential_helper_exec_path_candidates() -> Vec<PathBuf> {
488    let mut v = Vec::new();
489    if let Ok(ep) = std::env::var("GIT_EXEC_PATH") {
490        let p = PathBuf::from(ep.trim());
491        if p.is_dir() {
492            v.push(p);
493        }
494    }
495    for candidate in [
496        "/usr/libexec/git-core",
497        "/Library/Developer/CommandLineTools/usr/libexec/git-core",
498        "/opt/homebrew/opt/git/libexec/git-core",
499        "/opt/homebrew/libexec/git-core",
500        "/usr/lib/git-core",
501        "/usr/local/libexec/git-core",
502    ] {
503        let p = PathBuf::from(candidate);
504        if p.is_dir() {
505            v.push(p);
506        }
507    }
508    v
509}
510
511/// Resolve a helper program name to an executable path. A bare
512/// `git-credential-<name>` is looked up across Git's exec-path candidates
513/// before falling back to `PATH`.
514fn resolve_credential_helper_executable(helper_program: &str) -> PathBuf {
515    if helper_program.contains('/') {
516        return PathBuf::from(helper_program);
517    }
518    if let Some(suffix) = helper_program.strip_prefix("git-credential-") {
519        let exe_name = format!("git-credential-{suffix}");
520        for ep in credential_helper_exec_path_candidates() {
521            let candidate = ep.join(&exe_name);
522            if candidate.is_file() {
523                return candidate;
524            }
525        }
526    }
527    PathBuf::from(helper_program)
528}
529
530/// Invoke an external credential helper program.
531///
532/// The helper may be:
533/// - shell form: `!command ...` (executed by `sh -c`)
534/// - absolute/relative path containing `/`
535/// - bare helper name (expanded to `git-credential-<name>`)
536/// - already-expanded binary (`git-credential-...`)
537///
538/// The built-in `store`/`cache` helpers are re-dispatched through the current
539/// executable's `credential-store`/`credential-cache` subcommands, matching the
540/// CLI's behavior.
541///
542/// The helper is invoked with one action argument (`get`, `store`, `erase`)
543/// after any arguments from the configured helper string. Credential fields are
544/// written to stdin as `key=value` lines followed by a blank line; stdout is
545/// parsed back into a [`Credential`].
546fn invoke_helper(helper: &str, action: &str, creds: &Credential) -> Result<Credential> {
547    let helper_words = shell_words::split(helper)
548        .map_err(|e| Error::Message(format!("invalid credential.helper '{helper}': {e}")))?;
549    let (first_word, extra_args) = match helper_words.split_first() {
550        Some((first, rest)) => (first.as_str(), rest),
551        None => ("", &[][..]),
552    };
553
554    let mut child = if let Some(shell_cmd) = helper.strip_prefix('!') {
555        Command::new("sh")
556            .arg("-c")
557            .arg(format!("{shell_cmd} {action}"))
558            .stdin(Stdio::piped())
559            .stdout(Stdio::piped())
560            .stderr(Stdio::inherit())
561            .spawn()
562            .map_err(|e| {
563                Error::Message(format!("failed to run credential helper shell '{helper}': {e}"))
564            })?
565    } else if matches!(
566        first_word,
567        "store" | "cache" | "git-credential-store" | "git-credential-cache"
568    ) {
569        let subcmd = if first_word.ends_with("store") {
570            "credential-store"
571        } else {
572            "credential-cache"
573        };
574        let exe = std::env::current_exe()
575            .map_err(|e| Error::Message(format!("resolve current executable: {e}")))?;
576        let mut cmd = Command::new(exe);
577        cmd.arg(subcmd);
578        for arg in extra_args {
579            cmd.arg(arg);
580        }
581        cmd.arg(action);
582        cmd.stdin(Stdio::piped())
583            .stdout(Stdio::piped())
584            .stderr(Stdio::inherit())
585            .spawn()
586            .map_err(|e| {
587                Error::Message(format!("failed to run built-in credential helper '{subcmd}': {e}"))
588            })?
589    } else {
590        let helper_program = if first_word.contains('/') || first_word.starts_with("git-credential-")
591        {
592            // Already a path or fully-qualified helper binary; use verbatim.
593            first_word.to_string()
594        } else {
595            // Bare helper name (e.g. `osxkeychain`) -> `git-credential-osxkeychain`.
596            format!("git-credential-{first_word}")
597        };
598        let resolved = resolve_credential_helper_executable(&helper_program);
599        let mut cmd = Command::new(&resolved);
600        for arg in extra_args {
601            cmd.arg(arg);
602        }
603        cmd.arg(action);
604        cmd.stdin(Stdio::piped())
605            .stdout(Stdio::piped())
606            .stderr(Stdio::inherit())
607            .spawn()
608            .map_err(|e| {
609                Error::Message(format!("failed to run credential helper '{helper_program}': {e}"))
610            })?
611    };
612
613    {
614        let stdin = child
615            .stdin
616            .as_mut()
617            .ok_or_else(|| Error::Message("credential helper missing stdin".to_string()))?;
618        stdin.write_all(creds.serialize().as_bytes())?;
619        // Git terminates the credential record with a blank line.
620        stdin.write_all(b"\n")?;
621    }
622
623    let output = child
624        .wait_with_output()
625        .map_err(|e| Error::Message(format!("credential helper '{helper}' failed: {e}")))?;
626    if !output.status.success() {
627        return Err(Error::Message(format!(
628            "credential helper '{helper}' exited with status {}",
629            output.status
630        )));
631    }
632
633    Ok(Credential::parse_bytes(&output.stdout))
634}
635
636/// Whether `credential.useHttpPath` (optionally URL-scoped) is enabled.
637///
638/// Exposed so embedders can decide whether to include the `path` field when
639/// constructing a [`Credential`] for an HTTP(S) target, matching Git.
640pub fn use_http_path(config: &ConfigSet, target_url: Option<&str>) -> bool {
641    credential_config_value(config, target_url, "useHttpPath")
642        .as_deref()
643        .map(|value| parse_bool(value).unwrap_or(false))
644        .unwrap_or(false)
645}
646
647fn credential_config_value(
648    config: &ConfigSet,
649    target_url: Option<&str>,
650    variable_name: &str,
651) -> Option<String> {
652    let mut out = None;
653    for entry in config.entries() {
654        let key = &entry.key;
655        if key.contains('\n') || key.to_ascii_lowercase().contains("%0a") {
656            continue;
657        }
658        let Some(first_dot) = key.find('.') else {
659            continue;
660        };
661        let Some(last_dot) = key.rfind('.') else {
662            continue;
663        };
664        let section = &key[..first_dot];
665        let variable = &key[last_dot + 1..];
666        if !section.eq_ignore_ascii_case("credential")
667            || !variable.eq_ignore_ascii_case(variable_name)
668        {
669            continue;
670        }
671        if first_dot != last_dot {
672            let subsection = &key[first_dot + 1..last_dot];
673            if percent_decode_lossy(subsection).contains('\n') {
674                continue;
675            }
676            let Some(target) = target_url else {
677                continue;
678            };
679            if !credential_url_matches(subsection, target) {
680                continue;
681            }
682        }
683        out = entry.value.clone();
684    }
685    out
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    #[test]
693    fn parse_round_trips_named_fields() {
694        let input = "protocol=https\nhost=example.com\nusername=alice\npassword=secret\n\nignored=x\n";
695        let cred = Credential::parse(input);
696        assert_eq!(cred.protocol.as_deref(), Some("https"));
697        assert_eq!(cred.host.as_deref(), Some("example.com"));
698        assert_eq!(cred.username.as_deref(), Some("alice"));
699        assert_eq!(cred.password.as_deref(), Some("secret"));
700        // Parsing stops at the blank line.
701        assert!(cred.extra.is_empty());
702    }
703
704    #[test]
705    fn serialize_uses_canonical_order() {
706        let cred = Credential {
707            protocol: Some("https".into()),
708            host: Some("h".into()),
709            username: Some("u".into()),
710            password: Some("p".into()),
711            ..Default::default()
712        };
713        assert_eq!(cred.serialize(), "protocol=https\nhost=h\nusername=u\npassword=p\n");
714    }
715
716    #[test]
717    fn target_url_reconstructed_from_fields() {
718        let cred = Credential {
719            protocol: Some("https".into()),
720            host: Some("github.com".into()),
721            path: Some("o/r.git".into()),
722            ..Default::default()
723        };
724        assert_eq!(cred.target_url().as_deref(), Some("https://github.com/o/r.git"));
725    }
726}