use std::io::Write;
use std::process::{Command, Stdio};
use crate::bail;
use crate::error::{AUTH, Result};
pub const PRIVATE_TOKEN: &str = "PRIVATE-TOKEN";
pub const OAUTH2: &str = "oauth2";
pub fn enabled() -> bool {
std::env::var("SKILLCASK_GIT_CREDENTIALS").map_or(true, |v| v != "0" && !v.eq_ignore_ascii_case("false"))
}
fn git(action: &str, input: &str, prompt: bool) -> Option<String> {
let mut c = Command::new("git");
if !prompt {
c.args(["-c", "core.askPass=", "-c", "credential.interactive=never"])
.env("GIT_TERMINAL_PROMPT", "0")
.env("GCM_INTERACTIVE", "never")
.env_remove("GIT_ASKPASS")
.env_remove("SSH_ASKPASS")
.stderr(Stdio::null());
}
let mut child = c.args(["credential", action]).stdin(Stdio::piped()).stdout(Stdio::piped()).spawn().ok()?;
child.stdin.take()?.write_all(input.as_bytes()).ok()?;
let out = child.wait_with_output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
fn describe(host: &str, username: Option<&str>, password: Option<&str>) -> String {
let mut s = format!("protocol=https\nhost={host}\n");
for (k, v) in [("username", username), ("password", password)] {
if let Some(v) = v {
s.push_str(&format!("{k}={v}\n"));
}
}
s + "\n"
}
fn field(out: &str, key: &str) -> Option<String> {
out.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix('=')).filter(|v| !v.is_empty()).map(String::from)
}
pub fn fill(host: &str) -> Option<(String, String)> {
[Some(PRIVATE_TOKEN), Some(OAUTH2), None].into_iter().find_map(|user| {
let out = git("fill", &describe(host, user, None), false)?;
Some((field(&out, "username")?, field(&out, "password")?))
})
}
pub fn store(host: &str, username: &str, token: Option<&str>) -> Result<(usize, String)> {
if username.is_empty()
|| token == Some("")
|| [Some(username), token].into_iter().flatten().any(|v| v.contains(|c: char| c.is_control()))
{
bail!(AUTH, "the username and token must be one line without control characters");
}
let helpers = Command::new("git").args(["config", "--get-all", "credential.helper"]).output();
let helpers = helpers.map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default();
if helpers.trim().is_empty() {
bail!(AUTH, "git has no credential helper; set credential.helper (osxkeychain, manager or libsecret) first");
}
if helpers.lines().any(|h| h.trim() == "store") {
crate::ui::warn("git's credential helper `store` keeps the token in plain text in ~/.git-credentials");
}
git("reject", &describe(host, Some(username), None), false);
let token = match token {
Some(t) => t.to_string(),
None => {
let out = git("fill", &describe(host, Some(username), None), true)
.ok_or_else(|| crate::error::Error::new(AUTH, "no token entered"))?;
field(&out, "password").ok_or_else(|| crate::error::Error::new(AUTH, "no token entered"))?
}
};
if git("approve", &describe(host, Some(username), Some(&token)), false).is_none() {
bail!(AUTH, "git credential approve failed for {host}");
}
let helpers: Vec<&str> = helpers.lines().map(str::trim).filter(|h| !h.is_empty()).collect();
Ok((token.chars().count(), helpers.join(", ")))
}
pub fn remove(host: &str, username: &str) -> Result<()> {
if username.is_empty() || username.contains(|c: char| c.is_control()) {
bail!(AUTH, "invalid username {username:?}");
}
if git("reject", &describe(host, Some(username), None), false).is_none() {
bail!(AUTH, "git credential reject failed for {host}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn describes_and_reads_the_protocol() {
assert_eq!(describe("h:8443", Some("u"), None), "protocol=https\nhost=h:8443\nusername=u\n\n");
let out = "protocol=https\nhost=h\nusername=oauth2\npassword=a=b\n";
assert_eq!((field(out, "username").as_deref(), field(out, "password").as_deref()), (Some("oauth2"), Some("a=b")));
assert_eq!(field("password=\n", "password"), None);
}
}