Skip to main content

vcs_cli_support/
credentials.rs

1//! Credential provisioning for the CLI wrappers.
2//!
3//! Remote operations (a forge API call, a `git`/`jj` fetch or push against an
4//! authenticated remote) need a secret the toolkit deliberately does **not**
5//! store. By default every backend authenticates through its CLI's *own* ambient
6//! credential system (`gh`/`glab` logins, git credential helpers, the SSH agent)
7//! — the toolkit holds nothing. This module adds an **opt-in** seam for callers
8//! that want to supply a secret *per operation* instead: a CI job minting a
9//! short-lived token, an agent acting for different accounts, a vault-backed
10//! rotation. You implement (or pick a built-in) [`CredentialProvider`]; the
11//! backend resolves it just-in-time and injects the secret through the relevant
12//! CLI's *native* non-interactive mechanism — never persisting it.
13//!
14//! How the secret reaches each CLI (chosen so the value never lands in `argv`,
15//! which is broadly observable; only an env-var *name* or a token value in the
16//! process environment is used):
17//!
18//! - **GitHub** (`gh`) → `GH_TOKEN` environment variable.
19//! - **GitLab** (`glab`) → `GITLAB_TOKEN` environment variable.
20//! - **git** (`fetch`/`push`/`clone`) → an inline `credential.helper` that emits
21//!   the secret read from an environment variable *by name* (see
22//!   [`git_credential_helper`]); the secret value is never an argument.
23//! - **Gitea** (`tea`) and **Jujutsu** (`jj`) — no per-operation injection: `tea`
24//!   authenticates only from its stored logins, and `jj`'s in-process git backend
25//!   offers no per-invocation credential override. Both stay on ambient auth.
26//!
27//! Secrets are wrapped in [`Secret`], which redacts itself in `Debug`/`Display`
28//! so a stray log line can't leak a token. (It does **not** securely zero memory
29//! on drop — that is out of scope; rely on OS-level protections for that.)
30
31use std::fmt;
32
33use async_trait::async_trait;
34use processkit::{Error, Result};
35
36/// A secret value — an API token, a password — that **redacts itself** whenever
37/// it is formatted, so it can't leak into a log line or an error message. Read
38/// the underlying value only at the point of use, via [`expose`](Secret::expose).
39///
40/// Redaction is the achievable guarantee here; this type does **not** securely
41/// scrub its memory on drop.
42///
43/// Deliberately **not** `PartialEq`/`Eq`: comparing secrets with `String`'s
44/// short-circuiting `==` is timing-variable and turns the type into an equality
45/// oracle. Compare the [`expose`](Secret::expose)d value explicitly if you must.
46#[derive(Clone)]
47pub struct Secret(String);
48
49impl Secret {
50    /// Wrap a secret value.
51    #[must_use]
52    pub fn new(value: impl Into<String>) -> Self {
53        Self(value.into())
54    }
55
56    /// Borrow the underlying secret. Call this only where the value is actually
57    /// needed (e.g. setting an environment variable on a command); don't store
58    /// or log the result.
59    #[must_use]
60    pub fn expose(&self) -> &str {
61        &self.0
62    }
63}
64
65impl fmt::Debug for Secret {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str("Secret(\"***\")")
68    }
69}
70
71impl fmt::Display for Secret {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str("***")
74    }
75}
76
77impl From<String> for Secret {
78    fn from(value: String) -> Self {
79        Self(value)
80    }
81}
82
83impl From<&str> for Secret {
84    fn from(value: &str) -> Self {
85        Self(value.to_string())
86    }
87}
88
89/// A resolved credential: a [`Secret`] plus an optional username. For a forge
90/// token only the secret is used; for git HTTPS the username pairs with the
91/// secret as the password (a personal-access token).
92///
93/// Not `PartialEq`/`Eq` (it holds a [`Secret`], which intentionally is neither).
94#[derive(Clone, Debug)]
95pub struct Credential {
96    username: Option<String>,
97    secret: Secret,
98}
99
100impl Credential {
101    /// A bare token/secret with no username (the forge case, and git HTTPS where
102    /// any username is accepted). For git HTTPS a default username
103    /// (`x-access-token`, which GitHub/GitLab personal-access tokens accept) is
104    /// supplied automatically; use [`userpass`](Credential::userpass) if your host
105    /// needs a specific one. Forge token-env injection ignores the username.
106    #[must_use]
107    pub fn token(secret: impl Into<Secret>) -> Self {
108        Self {
109            username: None,
110            secret: secret.into(),
111        }
112    }
113
114    /// A username paired with a secret (git HTTPS user/password, where the
115    /// password is typically a personal-access token). The username is used only
116    /// for **git HTTPS**; forge token-env injection (`GH_TOKEN`/`GITLAB_TOKEN`)
117    /// uses only the secret and ignores the username.
118    #[must_use]
119    pub fn userpass(username: impl Into<String>, secret: impl Into<Secret>) -> Self {
120        Self {
121            username: Some(username.into()),
122            secret: secret.into(),
123        }
124    }
125
126    /// The username, if one was supplied.
127    #[must_use]
128    pub fn username(&self) -> Option<&str> {
129        self.username.as_deref()
130    }
131
132    /// The secret (token/password).
133    #[must_use]
134    pub fn secret(&self) -> &Secret {
135        &self.secret
136    }
137
138    /// Reject values that the line-based Git credential protocol cannot carry.
139    ///
140    /// The constructors intentionally remain infallible so they can continue to
141    /// be used by non-Git credential consumers. Every path that resolves or
142    /// materializes a credential for a Git helper calls this shared check before
143    /// exposing either field to the helper.
144    pub(crate) fn validate(&self) -> Result<()> {
145        validate_field(
146            "username",
147            self.username.as_deref().unwrap_or(DEFAULT_GIT_USERNAME),
148        )?;
149        validate_field("secret", self.secret.expose())
150    }
151
152    /// Apply the helper validation while preserving the ambient-auth rule for an
153    /// empty or whitespace-only secret, which is never materialized into a helper.
154    pub(crate) fn validate_for_resolution(&self) -> Result<()> {
155        validate_field(
156            "username",
157            self.username.as_deref().unwrap_or(DEFAULT_GIT_USERNAME),
158        )?;
159        validate_field("secret", self.secret.expose())?;
160        Ok(())
161    }
162}
163
164fn validate_field(field: &str, value: &str) -> Result<()> {
165    if value.contains('\r') || value.contains('\n') {
166        return Err(Error::spawn(
167            "git",
168            std::io::Error::new(
169                std::io::ErrorKind::InvalidInput,
170                format!("credential {field} must not contain CR or LF"),
171            ),
172        ));
173    }
174    Ok(())
175}
176
177/// Which backend/tool is asking for a credential — lets a provider return
178/// different secrets per service. `#[non_exhaustive]`: new backends may be added.
179#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
180#[non_exhaustive]
181pub enum CredentialService {
182    /// A `git` remote operation (fetch/push/clone over HTTPS).
183    Git,
184    /// A GitHub (`gh`) API operation.
185    GitHub,
186    /// A GitLab (`glab`) API operation.
187    GitLab,
188    /// A Gitea (`tea`) API operation. Reserved: `tea` has no per-operation token
189    /// mechanism today, so no backend currently emits this — it exists so a
190    /// provider can be written against it once `tea` gains support.
191    Gitea,
192}
193
194/// The context of a credential request: which service, and the remote host if
195/// the backend knows it (forge calls often defer host resolution to the CLI, so
196/// `host` is frequently `None`). `#[non_exhaustive]`: more context may be added.
197#[derive(Clone, Copy, Debug)]
198#[non_exhaustive]
199pub struct CredentialRequest<'a> {
200    /// The backend/tool making the request.
201    pub service: CredentialService,
202    /// The remote host (e.g. `github.com`), if known.
203    pub host: Option<&'a str>,
204}
205
206impl<'a> CredentialRequest<'a> {
207    /// A request for `service` with no known host.
208    #[must_use]
209    pub fn new(service: CredentialService) -> Self {
210        Self {
211            service,
212            host: None,
213        }
214    }
215
216    /// Attach a known remote host.
217    #[must_use]
218    pub fn with_host(mut self, host: &'a str) -> Self {
219        self.host = Some(host);
220        self
221    }
222}
223
224/// Supplies a [`Credential`] for a [`CredentialRequest`], just-in-time. Returning
225/// `Ok(None)` means "I have nothing for this request" — the backend then falls
226/// back to its ambient CLI auth, exactly as if no provider were configured.
227///
228/// Implement this for a vault/keychain lookup, per-account routing, or token
229/// rotation; for simple cases use [`StaticCredential`], [`EnvToken`], or
230/// [`provider_fn`]. The trait is async and dyn-compatible, so a backend can hold
231/// an `Arc<dyn CredentialProvider>`.
232#[async_trait]
233pub trait CredentialProvider: Send + Sync {
234    /// Resolve the credential for `request`, or `Ok(None)` to defer to ambient
235    /// auth. An `Err` aborts the operation (e.g. the vault was unreachable).
236    ///
237    /// A returned credential whose secret is **empty** is treated as `None`
238    /// (ambient) by the clients — an empty token can't authenticate, and injecting
239    /// one would override the ambient login with nothing rather than defer to it.
240    async fn credential(&self, request: &CredentialRequest<'_>) -> Result<Option<Credential>>;
241}
242
243/// A provider that always yields the same [`Credential`] for every request — the
244/// common "use this one token" case.
245#[derive(Clone, Debug)]
246pub struct StaticCredential(Credential);
247
248impl StaticCredential {
249    /// Always supply `credential`.
250    #[must_use]
251    pub fn new(credential: Credential) -> Self {
252        Self(credential)
253    }
254
255    /// Always supply a bare token.
256    #[must_use]
257    pub fn token(secret: impl Into<Secret>) -> Self {
258        Self(Credential::token(secret))
259    }
260}
261
262#[async_trait]
263impl CredentialProvider for StaticCredential {
264    async fn credential(&self, _request: &CredentialRequest<'_>) -> Result<Option<Credential>> {
265        self.0.validate_for_resolution()?;
266        Ok(Some(self.0.clone()))
267    }
268}
269
270/// A provider that reads a bare token from a named **environment variable**, at
271/// request time. If the variable is unset/empty it yields `None` (fall back to
272/// ambient auth) rather than erroring — handy for "use `$MY_TOKEN` if present".
273#[derive(Clone, Debug)]
274pub struct EnvToken {
275    var: String,
276    username: Option<String>,
277}
278
279impl EnvToken {
280    /// Read the token from environment variable `var`.
281    #[must_use]
282    pub fn new(var: impl Into<String>) -> Self {
283        Self {
284            var: var.into(),
285            username: None,
286        }
287    }
288
289    /// Pair the token with a username (for git HTTPS).
290    #[must_use]
291    pub fn with_username(mut self, username: impl Into<String>) -> Self {
292        self.username = Some(username.into());
293        self
294    }
295}
296
297#[async_trait]
298impl CredentialProvider for EnvToken {
299    async fn credential(&self, _request: &CredentialRequest<'_>) -> Result<Option<Credential>> {
300        // Validate the username before an unset/blank variable can defer to
301        // ambient auth. The username is still an input to the helper path even
302        // when this provider has no secret to materialize.
303        validate_field(
304            "username",
305            self.username.as_deref().unwrap_or(DEFAULT_GIT_USERNAME),
306        )?;
307        match std::env::var(&self.var) {
308            // A set-but-blank (or whitespace-only) variable is treated as unset →
309            // `None` (defer to ambient auth), not an empty token that would override
310            // the ambient login with nothing.
311            Ok(value) => {
312                let credential = match &self.username {
313                    Some(user) => Credential::userpass(user.clone(), value.clone()),
314                    None => Credential::token(value.clone()),
315                };
316                credential.validate_for_resolution()?;
317                if value.trim().is_empty() {
318                    Ok(None)
319                } else {
320                    Ok(Some(credential))
321                }
322            }
323            _ => Ok(None),
324        }
325    }
326}
327
328/// Adapt a synchronous closure into a [`CredentialProvider`]. The closure runs at
329/// request time and returns the credential (or `None` to defer to ambient auth).
330/// For async sources (a network vault), implement [`CredentialProvider`] directly.
331#[must_use]
332pub fn provider_fn<F>(f: F) -> FnProvider<F>
333where
334    F: Fn(&CredentialRequest<'_>) -> Result<Option<Credential>> + Send + Sync,
335{
336    FnProvider(f)
337}
338
339/// A [`CredentialProvider`] backed by a synchronous closure (see [`provider_fn`]).
340pub struct FnProvider<F>(F);
341
342impl<F> fmt::Debug for FnProvider<F> {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        f.debug_struct("FnProvider").finish_non_exhaustive()
345    }
346}
347
348#[async_trait]
349impl<F> CredentialProvider for FnProvider<F>
350where
351    F: Fn(&CredentialRequest<'_>) -> Result<Option<Credential>> + Send + Sync,
352{
353    async fn credential(&self, request: &CredentialRequest<'_>) -> Result<Option<Credential>> {
354        let credential = (self.0)(request)?;
355        if let Some(credential) = &credential {
356            credential.validate_for_resolution()?;
357        }
358        Ok(credential)
359    }
360}
361
362/// The default username git uses when a [`Credential`] supplies none. GitHub (and
363/// GitLab) accept any username when the password is a personal-access token, so a
364/// fixed placeholder works; `git` still requires *a* username.
365const DEFAULT_GIT_USERNAME: &str = "x-access-token";
366
367/// Environment-variable name carrying the username for [`git_credential_helper`].
368const GIT_USERNAME_VAR: &str = "VCS_TOOLKIT_GIT_USERNAME";
369/// Environment-variable name carrying the secret for [`git_credential_helper`].
370const GIT_PASSWORD_VAR: &str = "VCS_TOOLKIT_GIT_PASSWORD";
371/// Environment-variable name carrying the *expected host* for
372/// [`git_credential_helper`]. When set (non-empty), the helper releases the
373/// credential only for a request whose `host` matches — so an HTTP redirect or a
374/// submodule fetch to a **different** host never receives the token. Empty →
375/// ungated (the helper answers for any host, the pre-host-scoping behavior).
376const GIT_HOST_VAR: &str = "VCS_TOOLKIT_GIT_HOST";
377
378/// Extract the `host[:port]` from an HTTPS git URL
379/// (`https://[user[:pass]@]host[:port]/…`), **verbatim** — original case and port
380/// preserved — to scope a credential helper to the host an operation targets. git
381/// carries the same `host[:port]` in its credential request and compares it
382/// byte-for-byte, so normalizing here would withhold a legitimate credential.
383/// Returns `None` for a non-HTTPS URL (an SSH remote never invokes the HTTPS
384/// credential helper, so gating it is moot), an IPv6-literal authority, or an
385/// unparseable one — in which case the helper stays **ungated**, no worse than
386/// before host scoping existed.
387#[must_use]
388pub fn https_host(url: &str) -> Option<String> {
389    let rest = url.strip_prefix("https://")?;
390    // The authority ends at the first `/`, `?`, or `#`. Drop any `user:pass@`
391    // userinfo, but keep the host **and its port**, with the **original case**:
392    // git's credential request carries `host=` verbatim from the URL — it
393    // includes the port when one was given (`example.com:8443`) and does not
394    // lower-case the host — and the snippet compares it byte-for-byte, so what
395    // we scope to must match exactly (stripping the port or normalizing case
396    // would withhold a legitimate credential and break auth).
397    let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
398    let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
399    // An IPv6 literal (`[::1]:443`) — git formats `host=` for these idiosyncratically;
400    // rather than risk withholding a valid credential, stay ungated (return `None`)
401    // so auth still works, just without host scoping for that rare case.
402    if host_port.is_empty() || host_port.starts_with('[') {
403        return None;
404    }
405    Some(host_port.to_string())
406}
407
408/// The pieces needed to authenticate a `git` HTTPS operation with a [`Credential`]
409/// **without putting the secret in `argv`**. See [`git_credential_helper`].
410///
411/// `#[non_exhaustive]`: only [`git_credential_helper`] constructs it, so new fields
412/// can be added without breaking callers (who read the fields, never build it).
413#[derive(Clone, Debug)]
414#[non_exhaustive]
415pub struct GitCredentialHelper {
416    /// `-c key=value` global options to place **before** the git subcommand. They
417    /// reference the secret only by environment-variable *name*, never by value.
418    pub config_args: Vec<String>,
419    /// Environment variables (name → value) to set on the command. This is where
420    /// the actual secret lives — in the child's environment, not its arguments.
421    pub env: Vec<(String, Secret)>,
422}
423
424/// Build a git `credential.helper` invocation that supplies `cred` over HTTPS
425/// while keeping the secret out of `argv` (which is broadly observable). The
426/// returned [`config_args`](GitCredentialHelper::config_args) install an inline
427/// helper that prints the credential read from two environment variables; the
428/// secret value appears only in [`env`](GitCredentialHelper::env), i.e. the child
429/// process environment. A leading empty `credential.helper=` first clears any
430/// inherited helper so only ours runs.
431///
432/// The helper is a tiny POSIX-shell snippet: git runs `credential.helper` values
433/// that begin with `!` via the shell it ships with (so this works on Windows too,
434/// where Git for Windows bundles its own `sh` — it never goes through `cmd.exe`).
435/// It applies to **HTTPS remotes only**: git invokes a credential helper just for
436/// HTTP(S) user/password auth, so an SSH remote ignores it and falls through to
437/// the SSH agent. It is opt-in — built only when a [`CredentialProvider`] yields a
438/// credential — so the default path is unchanged. The helper answers only git's
439/// `get` action (never `store`/`erase`), so the secret is never written to a
440/// credential cache or config; it lives only in the child's environment.
441///
442/// The username/secret must not contain `\r` or `\n`: git's credential protocol is
443/// line-based, so either embedded byte is read as the end of the value (git
444/// truncates there, and a username newline can add extra protocol fields). Invalid
445/// values return an `InvalidInput` error before the helper is emitted. Real tokens
446/// and usernames never contain one.
447///
448/// `expect_host` scopes the credential to a host: when `Some`, the helper reads
449/// git's request (which names the host git is about to authenticate to) and
450/// releases the secret only if that host matches — so a cross-host redirect or a
451/// submodule fetch to another host can't extract the token. `None` (or an
452/// unknown host) leaves the helper ungated. Callers that know the operation's
453/// target (e.g. `clone` from its URL) pass [`https_host`] of it.
454#[must_use = "handle the helper or its invalid-input error"]
455pub fn git_credential_helper(
456    cred: &Credential,
457    expect_host: Option<&str>,
458) -> Result<GitCredentialHelper> {
459    cred.validate()?;
460    let username = cred.username().unwrap_or(DEFAULT_GIT_USERNAME).to_string();
461    // Reference the values by env-var NAME inside the snippet, so `argv` never
462    // carries the secret. Respond only to git's `get` action; ignore store/erase.
463    // Read git's request from stdin (key=value lines, terminated by a blank line)
464    // to learn the host, then release the credential only when:
465    //   - the password var is non-empty (`test -n`): if `config_args` is applied
466    //     without `env`, the helper emits nothing and git falls through to ambient
467    //     auth, rather than overriding it with an empty credential that fails; and
468    //   - the host is unscoped (`$…_HOST` empty) or matches the request's host, so
469    //     a redirect/submodule to a different host never receives the secret.
470    let helper = format!(
471        "!f() {{ test \"$1\" = get || return; h=; \
472         while IFS= read -r l; do case \"$l\" in \"\") break ;; host=*) h=${{l#host=}} ;; esac; done; \
473         test -n \"${GIT_PASSWORD_VAR}\" || return; \
474         test -z \"${GIT_HOST_VAR}\" || test \"$h\" = \"${GIT_HOST_VAR}\" || return; \
475         printf 'username=%s\\npassword=%s\\n' \
476         \"${GIT_USERNAME_VAR}\" \"${GIT_PASSWORD_VAR}\"; }}; f"
477    );
478    Ok(GitCredentialHelper {
479        config_args: vec![
480            "-c".to_string(),
481            "credential.helper=".to_string(),
482            "-c".to_string(),
483            format!("credential.helper={helper}"),
484        ],
485        env: vec![
486            (GIT_USERNAME_VAR.to_string(), Secret::new(username)),
487            (GIT_PASSWORD_VAR.to_string(), cred.secret().clone()),
488            (
489                GIT_HOST_VAR.to_string(),
490                Secret::new(expect_host.unwrap_or_default()),
491            ),
492        ],
493    })
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn secret_redacts_in_debug_and_display() {
502        let s = Secret::new("hunter2");
503        assert_eq!(format!("{s:?}"), "Secret(\"***\")");
504        assert_eq!(format!("{s}"), "***");
505        // The value is only reachable through `expose`.
506        assert_eq!(s.expose(), "hunter2");
507        // A Credential's Debug must not leak the secret either.
508        let c = Credential::userpass("alice", "hunter2");
509        let dbg = format!("{c:?}");
510        assert!(!dbg.contains("hunter2"), "secret leaked in Debug: {dbg}");
511        assert!(dbg.contains("alice"), "username should be visible: {dbg}");
512    }
513
514    #[tokio::test]
515    async fn static_and_env_and_fn_providers() {
516        let req = CredentialRequest::new(CredentialService::GitHub);
517
518        let s = StaticCredential::token("tok");
519        assert_eq!(
520            s.credential(&req).await.unwrap().unwrap().secret().expose(),
521            "tok"
522        );
523
524        // EnvToken: absent → None; present → the token.
525        let env = EnvToken::new("VCS_TOOLKIT_TEST_TOKEN_UNSET_XYZ");
526        assert!(env.credential(&req).await.unwrap().is_none());
527
528        // provider_fn routes on the request.
529        let p = provider_fn(|r: &CredentialRequest<'_>| {
530            Ok(match r.service {
531                CredentialService::GitHub => Some(Credential::token("gh")),
532                _ => None,
533            })
534        });
535        assert_eq!(
536            p.credential(&req).await.unwrap().unwrap().secret().expose(),
537            "gh"
538        );
539        let gl = CredentialRequest::new(CredentialService::GitLab);
540        assert!(p.credential(&gl).await.unwrap().is_none());
541    }
542
543    // EnvToken's present-variable path: a set variable yields the token (the most
544    // common "use $CI_TOKEN" provider); the username pairs through `with_username`.
545    #[tokio::test]
546    async fn env_token_reads_a_present_variable() {
547        let req = CredentialRequest::new(CredentialService::Git);
548        // A unique name so no other (parallel) test reads or writes it.
549        let var = "VCS_TOOLKIT_TEST_ENV_TOKEN_PRESENT_4f2a";
550        // SAFETY: edition-2024 requires `unsafe` for env mutation; the name is
551        // unique to this test, so there is no concurrent reader of it.
552        unsafe { std::env::set_var(var, "tok-from-env") };
553        let provider = EnvToken::new(var).with_username("alice");
554        let cred = provider
555            .credential(&req)
556            .await
557            .unwrap()
558            .expect("present variable yields a credential");
559        assert_eq!(cred.secret().expose(), "tok-from-env");
560        assert_eq!(cred.username(), Some("alice"));
561        // Once removed, it falls back to None (ambient).
562        unsafe { std::env::remove_var(var) };
563        assert!(provider.credential(&req).await.unwrap().is_none());
564    }
565
566    fn invalid_input<T>(result: Result<T>) -> Error {
567        match result {
568            Ok(_) => panic!("credential unexpectedly accepted CR/LF"),
569            Err(error) => {
570                assert!(
571                    crate::is_invalid_input(&error),
572                    "credential rejection must be InvalidInput: {error:?}"
573                );
574                error
575            }
576        }
577    }
578
579    const CRLF_USERNAMES: &[&str] = &["alice\r", "alice\n", "alice\t\r ", "alice \n\t"];
580    const CRLF_SECRETS: &[&str] = &["\r", "\n", "\t\r ", " \n\t"];
581
582    #[test]
583    fn git_credential_helper_rejects_cr_and_lf_before_protocol_output() {
584        for &bad_username in CRLF_USERNAMES {
585            let error = invalid_input(git_credential_helper(
586                &Credential::userpass(bad_username, "secret"),
587                None,
588            ));
589            assert!(error.to_string().contains("username"));
590        }
591        for &bad_secret in CRLF_SECRETS {
592            let error = invalid_input(git_credential_helper(
593                &Credential::userpass("alice", bad_secret),
594                None,
595            ));
596            assert!(error.to_string().contains("secret"));
597        }
598
599        // A valid value still produces the helper, and the values remain in the
600        // environment rather than being interpolated into its argv/config text.
601        let helper = git_credential_helper(&Credential::userpass("alice", "secret"), None)
602            .expect("valid credential");
603        assert!(helper.config_args.iter().all(|arg| !arg.contains("secret")));
604        assert!(helper.config_args.iter().all(|arg| !arg.contains("alice")));
605
606        for blank_secret in ["", "   ", "\t"] {
607            for &bad_username in CRLF_USERNAMES {
608                let error = invalid_input(git_credential_helper(
609                    &Credential::userpass(bad_username, blank_secret),
610                    None,
611                ));
612                assert!(error.to_string().contains("username"));
613            }
614        }
615    }
616
617    #[tokio::test]
618    async fn built_in_and_closure_providers_reject_the_same_cr_and_lf_inputs() {
619        let req = CredentialRequest::new(CredentialService::Git);
620
621        for &bad_username in CRLF_USERNAMES {
622            invalid_input(
623                StaticCredential::new(Credential::userpass(bad_username, "secret"))
624                    .credential(&req)
625                    .await,
626            );
627            invalid_input(
628                provider_fn(move |_request: &CredentialRequest<'_>| {
629                    Ok(Some(Credential::userpass(bad_username, "secret")))
630                })
631                .credential(&req)
632                .await,
633            );
634        }
635
636        for &bad_secret in CRLF_SECRETS {
637            invalid_input(
638                StaticCredential::new(Credential::userpass("alice", bad_secret))
639                    .credential(&req)
640                    .await,
641            );
642            invalid_input(
643                provider_fn(move |_request: &CredentialRequest<'_>| {
644                    Ok(Some(Credential::userpass("alice", bad_secret)))
645                })
646                .credential(&req)
647                .await,
648            );
649        }
650
651        // Username validation must happen before the empty/whitespace-only
652        // secret is classified as ambient auth.
653        for blank_secret in ["", "   ", "\t"] {
654            for &bad_username in CRLF_USERNAMES {
655                invalid_input(
656                    StaticCredential::new(Credential::userpass(bad_username, blank_secret))
657                        .credential(&req)
658                        .await,
659                );
660                invalid_input(
661                    provider_fn(move |_request: &CredentialRequest<'_>| {
662                        Ok(Some(Credential::userpass(bad_username, blank_secret)))
663                    })
664                    .credential(&req)
665                    .await,
666                );
667            }
668        }
669
670        for blank_secret in ["", "   ", "\t"] {
671            assert!(
672                StaticCredential::token(blank_secret)
673                    .credential(&req)
674                    .await
675                    .unwrap()
676                    .is_some(),
677                "plain blank static secret remains a provider result"
678            );
679            assert!(
680                provider_fn(move |_request: &CredentialRequest<'_>| {
681                    Ok(Some(Credential::token(blank_secret)))
682                })
683                .credential(&req)
684                .await
685                .unwrap()
686                .is_some(),
687                "plain blank closure secret remains a provider result"
688            );
689        }
690
691        for (suffix, blank) in [("empty", ""), ("space", "   "), ("tab", "\t")] {
692            let var = format!("VCS_TOOLKIT_TEST_ENV_TOKEN_BLANK_{suffix}");
693            unsafe { std::env::set_var(&var, blank) };
694            assert!(
695                EnvToken::new(&var)
696                    .credential(&req)
697                    .await
698                    .unwrap()
699                    .is_none(),
700                "plain blank environment secret remains ambient"
701            );
702            unsafe { std::env::remove_var(&var) };
703        }
704
705        // Environment-backed secrets are validated before they can reach `printf`.
706        for (suffix, bad) in [
707            ("cr_only", "\r"),
708            ("lf_only", "\n"),
709            ("mixed_cr", "\t\r "),
710            ("mixed_lf", " \n\t"),
711        ] {
712            let var = format!("VCS_TOOLKIT_TEST_ENV_TOKEN_CRLF_{suffix}");
713            unsafe { std::env::set_var(&var, bad) };
714            invalid_input(
715                EnvToken::new(&var)
716                    .with_username("alice")
717                    .credential(&req)
718                    .await,
719            );
720            unsafe { std::env::remove_var(&var) };
721        }
722        for (suffix, bad) in [
723            ("cr", "alice\r"),
724            ("lf", "alice\n"),
725            ("mixed_cr", "alice\t\r "),
726            ("mixed_lf", "alice \n\t"),
727        ] {
728            let var = format!("VCS_TOOLKIT_TEST_ENV_USERNAME_CRLF_{suffix}");
729            unsafe { std::env::set_var(&var, "secret") };
730            invalid_input(
731                EnvToken::new(&var)
732                    .with_username(bad)
733                    .credential(&req)
734                    .await,
735            );
736            unsafe { std::env::remove_var(&var) };
737        }
738
739        for (suffix, blank_secret) in [("empty", ""), ("space", "   "), ("ws", "\t")] {
740            for &bad_username in CRLF_USERNAMES {
741                let var = format!("VCS_TOOLKIT_TEST_ENV_USERNAME_BLANK_{suffix}");
742                unsafe { std::env::set_var(&var, blank_secret) };
743                invalid_input(
744                    EnvToken::new(&var)
745                        .with_username(bad_username)
746                        .credential(&req)
747                        .await,
748                );
749                unsafe { std::env::remove_var(&var) };
750            }
751        }
752    }
753
754    #[test]
755    fn git_credential_helper_keeps_secret_out_of_argv() {
756        let cred = Credential::userpass("alice", "s3cr3t");
757        let h = git_credential_helper(&cred, None).expect("valid credential");
758        // The secret value must NOT appear in any config arg (only the env-var name).
759        for a in &h.config_args {
760            assert!(!a.contains("s3cr3t"), "secret leaked into argv: {a}");
761        }
762        assert!(
763            h.config_args
764                .iter()
765                .any(|a| a.contains("VCS_TOOLKIT_GIT_PASSWORD"))
766        );
767        // A leading empty helper clears inherited helpers.
768        assert!(h.config_args.iter().any(|a| a == "credential.helper="));
769        // The secret + username live in the env, keyed by the helper's var names.
770        let pw = h
771            .env
772            .iter()
773            .find(|(k, _)| k == "VCS_TOOLKIT_GIT_PASSWORD")
774            .unwrap();
775        assert_eq!(pw.1.expose(), "s3cr3t");
776        let user = h
777            .env
778            .iter()
779            .find(|(k, _)| k == "VCS_TOOLKIT_GIT_USERNAME")
780            .unwrap();
781        assert_eq!(user.1.expose(), "alice");
782    }
783
784    #[test]
785    fn git_credential_helper_defaults_username() {
786        let h = git_credential_helper(&Credential::token("t"), None).expect("valid credential");
787        let user = h
788            .env
789            .iter()
790            .find(|(k, _)| k == "VCS_TOOLKIT_GIT_USERNAME")
791            .unwrap();
792        assert_eq!(user.1.expose(), DEFAULT_GIT_USERNAME);
793    }
794
795    #[test]
796    fn git_credential_helper_scopes_to_expected_host() {
797        // Ungated: the host env is present but empty, and the snippet's host
798        // check is skipped — the credential is released for any host.
799        let ungated =
800            git_credential_helper(&Credential::token("t"), None).expect("valid credential");
801        let host_env = ungated
802            .env
803            .iter()
804            .find(|(k, _)| k == "VCS_TOOLKIT_GIT_HOST")
805            .expect("host env var is always set");
806        assert_eq!(host_env.1.expose(), "", "None => empty (ungated) host");
807
808        // Gated: the expected host travels in the env (never argv), and the
809        // snippet gates on it — the host value is not baked into the shell text.
810        let gated = git_credential_helper(&Credential::token("t"), Some("github.com"))
811            .expect("valid credential");
812        assert_eq!(
813            gated
814                .env
815                .iter()
816                .find(|(k, _)| k == "VCS_TOOLKIT_GIT_HOST")
817                .unwrap()
818                .1
819                .expose(),
820            "github.com"
821        );
822        assert!(
823            gated.config_args.iter().all(|a| !a.contains("github.com")),
824            "the expected host stays in env, out of argv: {:?}",
825            gated.config_args
826        );
827        // The snippet references the host var by name and reads git's request.
828        assert!(
829            gated
830                .config_args
831                .iter()
832                .any(|a| a.contains("VCS_TOOLKIT_GIT_HOST") && a.contains("host=")),
833            "snippet gates on the request host: {:?}",
834            gated.config_args
835        );
836    }
837
838    #[test]
839    fn https_host_extracts_hostname() {
840        assert_eq!(
841            https_host("https://github.com/o/r.git").as_deref(),
842            Some("github.com")
843        );
844        // Userinfo is stripped, but the port and case are PRESERVED — git's
845        // `host=` request carries `host[:port]` verbatim from the URL and matches
846        // it case-sensitively, so scoping to a normalized host would withhold the
847        // credential and break auth for a non-default port / uppercase host.
848        assert_eq!(
849            https_host("https://x-access-token:tok@Git.Example.COM:8443/g/p").as_deref(),
850            Some("Git.Example.COM:8443"),
851            "userinfo dropped; port + case kept"
852        );
853        assert_eq!(
854            https_host("https://host.io?x=1").as_deref(),
855            Some("host.io"),
856            "authority ends at ? or #"
857        );
858        // Non-HTTPS (SSH) never invokes the helper → no host to scope.
859        assert_eq!(https_host("git@github.com:o/r.git"), None);
860        assert_eq!(https_host("ssh://git@github.com/o/r"), None);
861        assert_eq!(https_host("https://"), None);
862        // IPv6 literal → ungated (None) rather than a wrong match that breaks auth.
863        assert_eq!(https_host("https://[::1]:8443/x"), None);
864    }
865
866    #[test]
867    fn git_credential_helper_is_immune_to_shell_metacharacters() {
868        // A hostile username/secret must stay inert: they're carried as env
869        // VALUES, and the helper snippet references them only by env-var NAME
870        // (double-quoted), so the user-controlled bytes never enter the argv.
871        let cred = Credential::userpass("$(rm -rf /); x", "tok'; echo pwned");
872        let h = git_credential_helper(&cred, Some("github.com")).expect("valid credential");
873        for a in &h.config_args {
874            assert!(
875                !a.contains("rm -rf"),
876                "username metachars reached argv: {a}"
877            );
878            assert!(!a.contains("pwned"), "secret reached argv: {a}");
879        }
880        // They are preserved verbatim in the env, where the shell only ever
881        // expands them as a quoted variable value.
882        let user = h
883            .env
884            .iter()
885            .find(|(k, _)| k == "VCS_TOOLKIT_GIT_USERNAME")
886            .unwrap();
887        assert_eq!(user.1.expose(), "$(rm -rf /); x");
888    }
889}