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::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
139/// Which backend/tool is asking for a credential — lets a provider return
140/// different secrets per service. `#[non_exhaustive]`: new backends may be added.
141#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
142#[non_exhaustive]
143pub enum CredentialService {
144 /// A `git` remote operation (fetch/push/clone over HTTPS).
145 Git,
146 /// A GitHub (`gh`) API operation.
147 GitHub,
148 /// A GitLab (`glab`) API operation.
149 GitLab,
150 /// A Gitea (`tea`) API operation. Reserved: `tea` has no per-operation token
151 /// mechanism today, so no backend currently emits this — it exists so a
152 /// provider can be written against it once `tea` gains support.
153 Gitea,
154}
155
156/// The context of a credential request: which service, and the remote host if
157/// the backend knows it (forge calls often defer host resolution to the CLI, so
158/// `host` is frequently `None`). `#[non_exhaustive]`: more context may be added.
159#[derive(Clone, Copy, Debug)]
160#[non_exhaustive]
161pub struct CredentialRequest<'a> {
162 /// The backend/tool making the request.
163 pub service: CredentialService,
164 /// The remote host (e.g. `github.com`), if known.
165 pub host: Option<&'a str>,
166}
167
168impl<'a> CredentialRequest<'a> {
169 /// A request for `service` with no known host.
170 #[must_use]
171 pub fn new(service: CredentialService) -> Self {
172 Self {
173 service,
174 host: None,
175 }
176 }
177
178 /// Attach a known remote host.
179 #[must_use]
180 pub fn with_host(mut self, host: &'a str) -> Self {
181 self.host = Some(host);
182 self
183 }
184}
185
186/// Supplies a [`Credential`] for a [`CredentialRequest`], just-in-time. Returning
187/// `Ok(None)` means "I have nothing for this request" — the backend then falls
188/// back to its ambient CLI auth, exactly as if no provider were configured.
189///
190/// Implement this for a vault/keychain lookup, per-account routing, or token
191/// rotation; for simple cases use [`StaticCredential`], [`EnvToken`], or
192/// [`provider_fn`]. The trait is async and dyn-compatible, so a backend can hold
193/// an `Arc<dyn CredentialProvider>`.
194#[async_trait]
195pub trait CredentialProvider: Send + Sync {
196 /// Resolve the credential for `request`, or `Ok(None)` to defer to ambient
197 /// auth. An `Err` aborts the operation (e.g. the vault was unreachable).
198 ///
199 /// A returned credential whose secret is **empty** is treated as `None`
200 /// (ambient) by the clients — an empty token can't authenticate, and injecting
201 /// one would override the ambient login with nothing rather than defer to it.
202 async fn credential(&self, request: &CredentialRequest<'_>) -> Result<Option<Credential>>;
203}
204
205/// A provider that always yields the same [`Credential`] for every request — the
206/// common "use this one token" case.
207#[derive(Clone, Debug)]
208pub struct StaticCredential(Credential);
209
210impl StaticCredential {
211 /// Always supply `credential`.
212 #[must_use]
213 pub fn new(credential: Credential) -> Self {
214 Self(credential)
215 }
216
217 /// Always supply a bare token.
218 #[must_use]
219 pub fn token(secret: impl Into<Secret>) -> Self {
220 Self(Credential::token(secret))
221 }
222}
223
224#[async_trait]
225impl CredentialProvider for StaticCredential {
226 async fn credential(&self, _request: &CredentialRequest<'_>) -> Result<Option<Credential>> {
227 Ok(Some(self.0.clone()))
228 }
229}
230
231/// A provider that reads a bare token from a named **environment variable**, at
232/// request time. If the variable is unset/empty it yields `None` (fall back to
233/// ambient auth) rather than erroring — handy for "use `$MY_TOKEN` if present".
234#[derive(Clone, Debug)]
235pub struct EnvToken {
236 var: String,
237 username: Option<String>,
238}
239
240impl EnvToken {
241 /// Read the token from environment variable `var`.
242 #[must_use]
243 pub fn new(var: impl Into<String>) -> Self {
244 Self {
245 var: var.into(),
246 username: None,
247 }
248 }
249
250 /// Pair the token with a username (for git HTTPS).
251 #[must_use]
252 pub fn with_username(mut self, username: impl Into<String>) -> Self {
253 self.username = Some(username.into());
254 self
255 }
256}
257
258#[async_trait]
259impl CredentialProvider for EnvToken {
260 async fn credential(&self, _request: &CredentialRequest<'_>) -> Result<Option<Credential>> {
261 match std::env::var(&self.var) {
262 // A set-but-blank (or whitespace-only) variable is treated as unset →
263 // `None` (defer to ambient auth), not an empty token that would override
264 // the ambient login with nothing.
265 Ok(value) if !value.trim().is_empty() => Ok(Some(match &self.username {
266 Some(user) => Credential::userpass(user.clone(), value),
267 None => Credential::token(value),
268 })),
269 _ => Ok(None),
270 }
271 }
272}
273
274/// Adapt a synchronous closure into a [`CredentialProvider`]. The closure runs at
275/// request time and returns the credential (or `None` to defer to ambient auth).
276/// For async sources (a network vault), implement [`CredentialProvider`] directly.
277#[must_use]
278pub fn provider_fn<F>(f: F) -> FnProvider<F>
279where
280 F: Fn(&CredentialRequest<'_>) -> Result<Option<Credential>> + Send + Sync,
281{
282 FnProvider(f)
283}
284
285/// A [`CredentialProvider`] backed by a synchronous closure (see [`provider_fn`]).
286pub struct FnProvider<F>(F);
287
288impl<F> fmt::Debug for FnProvider<F> {
289 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290 f.debug_struct("FnProvider").finish_non_exhaustive()
291 }
292}
293
294#[async_trait]
295impl<F> CredentialProvider for FnProvider<F>
296where
297 F: Fn(&CredentialRequest<'_>) -> Result<Option<Credential>> + Send + Sync,
298{
299 async fn credential(&self, request: &CredentialRequest<'_>) -> Result<Option<Credential>> {
300 (self.0)(request)
301 }
302}
303
304/// The default username git uses when a [`Credential`] supplies none. GitHub (and
305/// GitLab) accept any username when the password is a personal-access token, so a
306/// fixed placeholder works; `git` still requires *a* username.
307const DEFAULT_GIT_USERNAME: &str = "x-access-token";
308
309/// Environment-variable name carrying the username for [`git_credential_helper`].
310const GIT_USERNAME_VAR: &str = "VCS_TOOLKIT_GIT_USERNAME";
311/// Environment-variable name carrying the secret for [`git_credential_helper`].
312const GIT_PASSWORD_VAR: &str = "VCS_TOOLKIT_GIT_PASSWORD";
313/// Environment-variable name carrying the *expected host* for
314/// [`git_credential_helper`]. When set (non-empty), the helper releases the
315/// credential only for a request whose `host` matches — so an HTTP redirect or a
316/// submodule fetch to a **different** host never receives the token. Empty →
317/// ungated (the helper answers for any host, the pre-host-scoping behavior).
318const GIT_HOST_VAR: &str = "VCS_TOOLKIT_GIT_HOST";
319
320/// Extract the `host[:port]` from an HTTPS git URL
321/// (`https://[user[:pass]@]host[:port]/…`), **verbatim** — original case and port
322/// preserved — to scope a credential helper to the host an operation targets. git
323/// carries the same `host[:port]` in its credential request and compares it
324/// byte-for-byte, so normalizing here would withhold a legitimate credential.
325/// Returns `None` for a non-HTTPS URL (an SSH remote never invokes the HTTPS
326/// credential helper, so gating it is moot), an IPv6-literal authority, or an
327/// unparseable one — in which case the helper stays **ungated**, no worse than
328/// before host scoping existed.
329#[must_use]
330pub fn https_host(url: &str) -> Option<String> {
331 let rest = url.strip_prefix("https://")?;
332 // The authority ends at the first `/`, `?`, or `#`. Drop any `user:pass@`
333 // userinfo, but keep the host **and its port**, with the **original case**:
334 // git's credential request carries `host=` verbatim from the URL — it
335 // includes the port when one was given (`example.com:8443`) and does not
336 // lower-case the host — and the snippet compares it byte-for-byte, so what
337 // we scope to must match exactly (stripping the port or normalizing case
338 // would withhold a legitimate credential and break auth).
339 let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
340 let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
341 // An IPv6 literal (`[::1]:443`) — git formats `host=` for these idiosyncratically;
342 // rather than risk withholding a valid credential, stay ungated (return `None`)
343 // so auth still works, just without host scoping for that rare case.
344 if host_port.is_empty() || host_port.starts_with('[') {
345 return None;
346 }
347 Some(host_port.to_string())
348}
349
350/// The pieces needed to authenticate a `git` HTTPS operation with a [`Credential`]
351/// **without putting the secret in `argv`**. See [`git_credential_helper`].
352///
353/// `#[non_exhaustive]`: only [`git_credential_helper`] constructs it, so new fields
354/// can be added without breaking callers (who read the fields, never build it).
355#[derive(Clone, Debug)]
356#[non_exhaustive]
357pub struct GitCredentialHelper {
358 /// `-c key=value` global options to place **before** the git subcommand. They
359 /// reference the secret only by environment-variable *name*, never by value.
360 pub config_args: Vec<String>,
361 /// Environment variables (name → value) to set on the command. This is where
362 /// the actual secret lives — in the child's environment, not its arguments.
363 pub env: Vec<(String, Secret)>,
364}
365
366/// Build a git `credential.helper` invocation that supplies `cred` over HTTPS
367/// while keeping the secret out of `argv` (which is broadly observable). The
368/// returned [`config_args`](GitCredentialHelper::config_args) install an inline
369/// helper that prints the credential read from two environment variables; the
370/// secret value appears only in [`env`](GitCredentialHelper::env), i.e. the child
371/// process environment. A leading empty `credential.helper=` first clears any
372/// inherited helper so only ours runs.
373///
374/// The helper is a tiny POSIX-shell snippet: git runs `credential.helper` values
375/// that begin with `!` via the shell it ships with (so this works on Windows too,
376/// where Git for Windows bundles its own `sh` — it never goes through `cmd.exe`).
377/// It applies to **HTTPS remotes only**: git invokes a credential helper just for
378/// HTTP(S) user/password auth, so an SSH remote ignores it and falls through to
379/// the SSH agent. It is opt-in — built only when a [`CredentialProvider`] yields a
380/// credential — so the default path is unchanged. The helper answers only git's
381/// `get` action (never `store`/`erase`), so the secret is never written to a
382/// credential cache or config; it lives only in the child's environment.
383///
384/// The username/secret must not contain a newline: git's credential protocol is
385/// line-based, so an embedded `\n` is read as the end of the value (git truncates
386/// there). Real tokens and usernames never contain one.
387///
388/// `expect_host` scopes the credential to a host: when `Some`, the helper reads
389/// git's request (which names the host git is about to authenticate to) and
390/// releases the secret only if that host matches — so a cross-host redirect or a
391/// submodule fetch to another host can't extract the token. `None` (or an
392/// unknown host) leaves the helper ungated. Callers that know the operation's
393/// target (e.g. `clone` from its URL) pass [`https_host`] of it.
394#[must_use]
395pub fn git_credential_helper(cred: &Credential, expect_host: Option<&str>) -> GitCredentialHelper {
396 let username = cred.username().unwrap_or(DEFAULT_GIT_USERNAME).to_string();
397 // Reference the values by env-var NAME inside the snippet, so `argv` never
398 // carries the secret. Respond only to git's `get` action; ignore store/erase.
399 // Read git's request from stdin (key=value lines, terminated by a blank line)
400 // to learn the host, then release the credential only when:
401 // - the password var is non-empty (`test -n`): if `config_args` is applied
402 // without `env`, the helper emits nothing and git falls through to ambient
403 // auth, rather than overriding it with an empty credential that fails; and
404 // - the host is unscoped (`$…_HOST` empty) or matches the request's host, so
405 // a redirect/submodule to a different host never receives the secret.
406 let helper = format!(
407 "!f() {{ test \"$1\" = get || return; h=; \
408 while IFS= read -r l; do case \"$l\" in \"\") break ;; host=*) h=${{l#host=}} ;; esac; done; \
409 test -n \"${GIT_PASSWORD_VAR}\" || return; \
410 test -z \"${GIT_HOST_VAR}\" || test \"$h\" = \"${GIT_HOST_VAR}\" || return; \
411 printf 'username=%s\\npassword=%s\\n' \
412 \"${GIT_USERNAME_VAR}\" \"${GIT_PASSWORD_VAR}\"; }}; f"
413 );
414 GitCredentialHelper {
415 config_args: vec![
416 "-c".to_string(),
417 "credential.helper=".to_string(),
418 "-c".to_string(),
419 format!("credential.helper={helper}"),
420 ],
421 env: vec![
422 (GIT_USERNAME_VAR.to_string(), Secret::new(username)),
423 (GIT_PASSWORD_VAR.to_string(), cred.secret().clone()),
424 (
425 GIT_HOST_VAR.to_string(),
426 Secret::new(expect_host.unwrap_or_default()),
427 ),
428 ],
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn secret_redacts_in_debug_and_display() {
438 let s = Secret::new("hunter2");
439 assert_eq!(format!("{s:?}"), "Secret(\"***\")");
440 assert_eq!(format!("{s}"), "***");
441 // The value is only reachable through `expose`.
442 assert_eq!(s.expose(), "hunter2");
443 // A Credential's Debug must not leak the secret either.
444 let c = Credential::userpass("alice", "hunter2");
445 let dbg = format!("{c:?}");
446 assert!(!dbg.contains("hunter2"), "secret leaked in Debug: {dbg}");
447 assert!(dbg.contains("alice"), "username should be visible: {dbg}");
448 }
449
450 #[tokio::test]
451 async fn static_and_env_and_fn_providers() {
452 let req = CredentialRequest::new(CredentialService::GitHub);
453
454 let s = StaticCredential::token("tok");
455 assert_eq!(
456 s.credential(&req).await.unwrap().unwrap().secret().expose(),
457 "tok"
458 );
459
460 // EnvToken: absent → None; present → the token.
461 let env = EnvToken::new("VCS_TOOLKIT_TEST_TOKEN_UNSET_XYZ");
462 assert!(env.credential(&req).await.unwrap().is_none());
463
464 // provider_fn routes on the request.
465 let p = provider_fn(|r: &CredentialRequest<'_>| {
466 Ok(match r.service {
467 CredentialService::GitHub => Some(Credential::token("gh")),
468 _ => None,
469 })
470 });
471 assert_eq!(
472 p.credential(&req).await.unwrap().unwrap().secret().expose(),
473 "gh"
474 );
475 let gl = CredentialRequest::new(CredentialService::GitLab);
476 assert!(p.credential(&gl).await.unwrap().is_none());
477 }
478
479 // EnvToken's present-variable path: a set variable yields the token (the most
480 // common "use $CI_TOKEN" provider); the username pairs through `with_username`.
481 #[tokio::test]
482 async fn env_token_reads_a_present_variable() {
483 let req = CredentialRequest::new(CredentialService::Git);
484 // A unique name so no other (parallel) test reads or writes it.
485 let var = "VCS_TOOLKIT_TEST_ENV_TOKEN_PRESENT_4f2a";
486 // SAFETY: edition-2024 requires `unsafe` for env mutation; the name is
487 // unique to this test, so there is no concurrent reader of it.
488 unsafe { std::env::set_var(var, "tok-from-env") };
489 let provider = EnvToken::new(var).with_username("alice");
490 let cred = provider
491 .credential(&req)
492 .await
493 .unwrap()
494 .expect("present variable yields a credential");
495 assert_eq!(cred.secret().expose(), "tok-from-env");
496 assert_eq!(cred.username(), Some("alice"));
497 // Once removed, it falls back to None (ambient).
498 unsafe { std::env::remove_var(var) };
499 assert!(provider.credential(&req).await.unwrap().is_none());
500 }
501
502 #[test]
503 fn git_credential_helper_keeps_secret_out_of_argv() {
504 let cred = Credential::userpass("alice", "s3cr3t");
505 let h = git_credential_helper(&cred, None);
506 // The secret value must NOT appear in any config arg (only the env-var name).
507 for a in &h.config_args {
508 assert!(!a.contains("s3cr3t"), "secret leaked into argv: {a}");
509 }
510 assert!(
511 h.config_args
512 .iter()
513 .any(|a| a.contains("VCS_TOOLKIT_GIT_PASSWORD"))
514 );
515 // A leading empty helper clears inherited helpers.
516 assert!(h.config_args.iter().any(|a| a == "credential.helper="));
517 // The secret + username live in the env, keyed by the helper's var names.
518 let pw = h
519 .env
520 .iter()
521 .find(|(k, _)| k == "VCS_TOOLKIT_GIT_PASSWORD")
522 .unwrap();
523 assert_eq!(pw.1.expose(), "s3cr3t");
524 let user = h
525 .env
526 .iter()
527 .find(|(k, _)| k == "VCS_TOOLKIT_GIT_USERNAME")
528 .unwrap();
529 assert_eq!(user.1.expose(), "alice");
530 }
531
532 #[test]
533 fn git_credential_helper_defaults_username() {
534 let h = git_credential_helper(&Credential::token("t"), None);
535 let user = h
536 .env
537 .iter()
538 .find(|(k, _)| k == "VCS_TOOLKIT_GIT_USERNAME")
539 .unwrap();
540 assert_eq!(user.1.expose(), DEFAULT_GIT_USERNAME);
541 }
542
543 #[test]
544 fn git_credential_helper_scopes_to_expected_host() {
545 // Ungated: the host env is present but empty, and the snippet's host
546 // check is skipped — the credential is released for any host.
547 let ungated = git_credential_helper(&Credential::token("t"), None);
548 let host_env = ungated
549 .env
550 .iter()
551 .find(|(k, _)| k == "VCS_TOOLKIT_GIT_HOST")
552 .expect("host env var is always set");
553 assert_eq!(host_env.1.expose(), "", "None => empty (ungated) host");
554
555 // Gated: the expected host travels in the env (never argv), and the
556 // snippet gates on it — the host value is not baked into the shell text.
557 let gated = git_credential_helper(&Credential::token("t"), Some("github.com"));
558 assert_eq!(
559 gated
560 .env
561 .iter()
562 .find(|(k, _)| k == "VCS_TOOLKIT_GIT_HOST")
563 .unwrap()
564 .1
565 .expose(),
566 "github.com"
567 );
568 assert!(
569 gated.config_args.iter().all(|a| !a.contains("github.com")),
570 "the expected host stays in env, out of argv: {:?}",
571 gated.config_args
572 );
573 // The snippet references the host var by name and reads git's request.
574 assert!(
575 gated
576 .config_args
577 .iter()
578 .any(|a| a.contains("VCS_TOOLKIT_GIT_HOST") && a.contains("host=")),
579 "snippet gates on the request host: {:?}",
580 gated.config_args
581 );
582 }
583
584 #[test]
585 fn https_host_extracts_hostname() {
586 assert_eq!(
587 https_host("https://github.com/o/r.git").as_deref(),
588 Some("github.com")
589 );
590 // Userinfo is stripped, but the port and case are PRESERVED — git's
591 // `host=` request carries `host[:port]` verbatim from the URL and matches
592 // it case-sensitively, so scoping to a normalized host would withhold the
593 // credential and break auth for a non-default port / uppercase host.
594 assert_eq!(
595 https_host("https://x-access-token:tok@Git.Example.COM:8443/g/p").as_deref(),
596 Some("Git.Example.COM:8443"),
597 "userinfo dropped; port + case kept"
598 );
599 assert_eq!(
600 https_host("https://host.io?x=1").as_deref(),
601 Some("host.io"),
602 "authority ends at ? or #"
603 );
604 // Non-HTTPS (SSH) never invokes the helper → no host to scope.
605 assert_eq!(https_host("git@github.com:o/r.git"), None);
606 assert_eq!(https_host("ssh://git@github.com/o/r"), None);
607 assert_eq!(https_host("https://"), None);
608 // IPv6 literal → ungated (None) rather than a wrong match that breaks auth.
609 assert_eq!(https_host("https://[::1]:8443/x"), None);
610 }
611
612 #[test]
613 fn git_credential_helper_is_immune_to_shell_metacharacters() {
614 // A hostile username/secret must stay inert: they're carried as env
615 // VALUES, and the helper snippet references them only by env-var NAME
616 // (double-quoted), so the user-controlled bytes never enter the argv.
617 let cred = Credential::userpass("$(rm -rf /); x", "tok'; echo pwned");
618 let h = git_credential_helper(&cred, Some("github.com"));
619 for a in &h.config_args {
620 assert!(
621 !a.contains("rm -rf"),
622 "username metachars reached argv: {a}"
623 );
624 assert!(!a.contains("pwned"), "secret reached argv: {a}");
625 }
626 // They are preserved verbatim in the env, where the shell only ever
627 // expands them as a quoted variable value.
628 let user = h
629 .env
630 .iter()
631 .find(|(k, _)| k == "VCS_TOOLKIT_GIT_USERNAME")
632 .unwrap();
633 assert_eq!(user.1.expose(), "$(rm -rf /); x");
634 }
635}