use std::fmt;
use async_trait::async_trait;
use processkit::Result;
#[derive(Clone)]
pub struct Secret(String);
impl Secret {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret(\"***\")")
}
}
impl fmt::Display for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("***")
}
}
impl From<String> for Secret {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for Secret {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
#[derive(Clone, Debug)]
pub struct Credential {
username: Option<String>,
secret: Secret,
}
impl Credential {
#[must_use]
pub fn token(secret: impl Into<Secret>) -> Self {
Self {
username: None,
secret: secret.into(),
}
}
#[must_use]
pub fn userpass(username: impl Into<String>, secret: impl Into<Secret>) -> Self {
Self {
username: Some(username.into()),
secret: secret.into(),
}
}
#[must_use]
pub fn username(&self) -> Option<&str> {
self.username.as_deref()
}
#[must_use]
pub fn secret(&self) -> &Secret {
&self.secret
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum CredentialService {
Git,
GitHub,
GitLab,
Gitea,
}
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct CredentialRequest<'a> {
pub service: CredentialService,
pub host: Option<&'a str>,
}
impl<'a> CredentialRequest<'a> {
#[must_use]
pub fn new(service: CredentialService) -> Self {
Self {
service,
host: None,
}
}
#[must_use]
pub fn with_host(mut self, host: &'a str) -> Self {
self.host = Some(host);
self
}
}
#[async_trait]
pub trait CredentialProvider: Send + Sync {
async fn credential(&self, request: &CredentialRequest<'_>) -> Result<Option<Credential>>;
}
#[derive(Clone, Debug)]
pub struct StaticCredential(Credential);
impl StaticCredential {
#[must_use]
pub fn new(credential: Credential) -> Self {
Self(credential)
}
#[must_use]
pub fn token(secret: impl Into<Secret>) -> Self {
Self(Credential::token(secret))
}
}
#[async_trait]
impl CredentialProvider for StaticCredential {
async fn credential(&self, _request: &CredentialRequest<'_>) -> Result<Option<Credential>> {
Ok(Some(self.0.clone()))
}
}
#[derive(Clone, Debug)]
pub struct EnvToken {
var: String,
username: Option<String>,
}
impl EnvToken {
#[must_use]
pub fn new(var: impl Into<String>) -> Self {
Self {
var: var.into(),
username: None,
}
}
#[must_use]
pub fn with_username(mut self, username: impl Into<String>) -> Self {
self.username = Some(username.into());
self
}
}
#[async_trait]
impl CredentialProvider for EnvToken {
async fn credential(&self, _request: &CredentialRequest<'_>) -> Result<Option<Credential>> {
match std::env::var(&self.var) {
Ok(value) if !value.trim().is_empty() => Ok(Some(match &self.username {
Some(user) => Credential::userpass(user.clone(), value),
None => Credential::token(value),
})),
_ => Ok(None),
}
}
}
#[must_use]
pub fn provider_fn<F>(f: F) -> FnProvider<F>
where
F: Fn(&CredentialRequest<'_>) -> Result<Option<Credential>> + Send + Sync,
{
FnProvider(f)
}
pub struct FnProvider<F>(F);
impl<F> fmt::Debug for FnProvider<F> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FnProvider").finish_non_exhaustive()
}
}
#[async_trait]
impl<F> CredentialProvider for FnProvider<F>
where
F: Fn(&CredentialRequest<'_>) -> Result<Option<Credential>> + Send + Sync,
{
async fn credential(&self, request: &CredentialRequest<'_>) -> Result<Option<Credential>> {
(self.0)(request)
}
}
const DEFAULT_GIT_USERNAME: &str = "x-access-token";
const GIT_USERNAME_VAR: &str = "VCS_TOOLKIT_GIT_USERNAME";
const GIT_PASSWORD_VAR: &str = "VCS_TOOLKIT_GIT_PASSWORD";
const GIT_HOST_VAR: &str = "VCS_TOOLKIT_GIT_HOST";
#[must_use]
pub fn https_host(url: &str) -> Option<String> {
let rest = url.strip_prefix("https://")?;
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
if host_port.is_empty() || host_port.starts_with('[') {
return None;
}
Some(host_port.to_string())
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct GitCredentialHelper {
pub config_args: Vec<String>,
pub env: Vec<(String, Secret)>,
}
#[must_use]
pub fn git_credential_helper(cred: &Credential, expect_host: Option<&str>) -> GitCredentialHelper {
let username = cred.username().unwrap_or(DEFAULT_GIT_USERNAME).to_string();
let helper = format!(
"!f() {{ test \"$1\" = get || return; h=; \
while IFS= read -r l; do case \"$l\" in \"\") break ;; host=*) h=${{l#host=}} ;; esac; done; \
test -n \"${GIT_PASSWORD_VAR}\" || return; \
test -z \"${GIT_HOST_VAR}\" || test \"$h\" = \"${GIT_HOST_VAR}\" || return; \
printf 'username=%s\\npassword=%s\\n' \
\"${GIT_USERNAME_VAR}\" \"${GIT_PASSWORD_VAR}\"; }}; f"
);
GitCredentialHelper {
config_args: vec![
"-c".to_string(),
"credential.helper=".to_string(),
"-c".to_string(),
format!("credential.helper={helper}"),
],
env: vec![
(GIT_USERNAME_VAR.to_string(), Secret::new(username)),
(GIT_PASSWORD_VAR.to_string(), cred.secret().clone()),
(
GIT_HOST_VAR.to_string(),
Secret::new(expect_host.unwrap_or_default()),
),
],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_redacts_in_debug_and_display() {
let s = Secret::new("hunter2");
assert_eq!(format!("{s:?}"), "Secret(\"***\")");
assert_eq!(format!("{s}"), "***");
assert_eq!(s.expose(), "hunter2");
let c = Credential::userpass("alice", "hunter2");
let dbg = format!("{c:?}");
assert!(!dbg.contains("hunter2"), "secret leaked in Debug: {dbg}");
assert!(dbg.contains("alice"), "username should be visible: {dbg}");
}
#[tokio::test]
async fn static_and_env_and_fn_providers() {
let req = CredentialRequest::new(CredentialService::GitHub);
let s = StaticCredential::token("tok");
assert_eq!(
s.credential(&req).await.unwrap().unwrap().secret().expose(),
"tok"
);
let env = EnvToken::new("VCS_TOOLKIT_TEST_TOKEN_UNSET_XYZ");
assert!(env.credential(&req).await.unwrap().is_none());
let p = provider_fn(|r: &CredentialRequest<'_>| {
Ok(match r.service {
CredentialService::GitHub => Some(Credential::token("gh")),
_ => None,
})
});
assert_eq!(
p.credential(&req).await.unwrap().unwrap().secret().expose(),
"gh"
);
let gl = CredentialRequest::new(CredentialService::GitLab);
assert!(p.credential(&gl).await.unwrap().is_none());
}
#[tokio::test]
async fn env_token_reads_a_present_variable() {
let req = CredentialRequest::new(CredentialService::Git);
let var = "VCS_TOOLKIT_TEST_ENV_TOKEN_PRESENT_4f2a";
unsafe { std::env::set_var(var, "tok-from-env") };
let provider = EnvToken::new(var).with_username("alice");
let cred = provider
.credential(&req)
.await
.unwrap()
.expect("present variable yields a credential");
assert_eq!(cred.secret().expose(), "tok-from-env");
assert_eq!(cred.username(), Some("alice"));
unsafe { std::env::remove_var(var) };
assert!(provider.credential(&req).await.unwrap().is_none());
}
#[test]
fn git_credential_helper_keeps_secret_out_of_argv() {
let cred = Credential::userpass("alice", "s3cr3t");
let h = git_credential_helper(&cred, None);
for a in &h.config_args {
assert!(!a.contains("s3cr3t"), "secret leaked into argv: {a}");
}
assert!(
h.config_args
.iter()
.any(|a| a.contains("VCS_TOOLKIT_GIT_PASSWORD"))
);
assert!(h.config_args.iter().any(|a| a == "credential.helper="));
let pw = h
.env
.iter()
.find(|(k, _)| k == "VCS_TOOLKIT_GIT_PASSWORD")
.unwrap();
assert_eq!(pw.1.expose(), "s3cr3t");
let user = h
.env
.iter()
.find(|(k, _)| k == "VCS_TOOLKIT_GIT_USERNAME")
.unwrap();
assert_eq!(user.1.expose(), "alice");
}
#[test]
fn git_credential_helper_defaults_username() {
let h = git_credential_helper(&Credential::token("t"), None);
let user = h
.env
.iter()
.find(|(k, _)| k == "VCS_TOOLKIT_GIT_USERNAME")
.unwrap();
assert_eq!(user.1.expose(), DEFAULT_GIT_USERNAME);
}
#[test]
fn git_credential_helper_scopes_to_expected_host() {
let ungated = git_credential_helper(&Credential::token("t"), None);
let host_env = ungated
.env
.iter()
.find(|(k, _)| k == "VCS_TOOLKIT_GIT_HOST")
.expect("host env var is always set");
assert_eq!(host_env.1.expose(), "", "None => empty (ungated) host");
let gated = git_credential_helper(&Credential::token("t"), Some("github.com"));
assert_eq!(
gated
.env
.iter()
.find(|(k, _)| k == "VCS_TOOLKIT_GIT_HOST")
.unwrap()
.1
.expose(),
"github.com"
);
assert!(
gated.config_args.iter().all(|a| !a.contains("github.com")),
"the expected host stays in env, out of argv: {:?}",
gated.config_args
);
assert!(
gated
.config_args
.iter()
.any(|a| a.contains("VCS_TOOLKIT_GIT_HOST") && a.contains("host=")),
"snippet gates on the request host: {:?}",
gated.config_args
);
}
#[test]
fn https_host_extracts_hostname() {
assert_eq!(
https_host("https://github.com/o/r.git").as_deref(),
Some("github.com")
);
assert_eq!(
https_host("https://x-access-token:tok@Git.Example.COM:8443/g/p").as_deref(),
Some("Git.Example.COM:8443"),
"userinfo dropped; port + case kept"
);
assert_eq!(
https_host("https://host.io?x=1").as_deref(),
Some("host.io"),
"authority ends at ? or #"
);
assert_eq!(https_host("git@github.com:o/r.git"), None);
assert_eq!(https_host("ssh://git@github.com/o/r"), None);
assert_eq!(https_host("https://"), None);
assert_eq!(https_host("https://[::1]:8443/x"), None);
}
#[test]
fn git_credential_helper_is_immune_to_shell_metacharacters() {
let cred = Credential::userpass("$(rm -rf /); x", "tok'; echo pwned");
let h = git_credential_helper(&cred, Some("github.com"));
for a in &h.config_args {
assert!(
!a.contains("rm -rf"),
"username metachars reached argv: {a}"
);
assert!(!a.contains("pwned"), "secret reached argv: {a}");
}
let user = h
.env
.iter()
.find(|(k, _)| k == "VCS_TOOLKIT_GIT_USERNAME")
.unwrap();
assert_eq!(user.1.expose(), "$(rm -rf /); x");
}
}