use std::ffi::OsString;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::git_workspace::{CloneProtocol, RepoCoords};
pub const GH_TIMEOUT: Duration = Duration::from_secs(20);
pub const FORK_TIMEOUT: Duration = Duration::from_secs(90);
const GIT_CONFIG_TIMEOUT: Duration = Duration::from_secs(10);
pub const MARKER_KEY: &str = "openvtc.forgeCredential";
pub const AUTHOR_MARKER_KEY: &str = "openvtc.forgeAuthor";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommitAuthor {
pub name: String,
pub email: String,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ForgeCredential {
GitDefault,
SshKey { path: PathBuf },
GhAccount {
login: String,
#[serde(default, skip_serializing_if = "is_false")]
keep_author: bool,
},
}
impl ForgeCredential {
#[must_use]
pub fn gh(login: String) -> Self {
ForgeCredential::GhAccount {
login,
keep_author: false,
}
}
#[must_use]
pub fn same_account(&self, other: &Self) -> bool {
match (self, other) {
(
ForgeCredential::GhAccount { login: a, .. },
ForgeCredential::GhAccount { login: b, .. },
) => a == b,
_ => self == other,
}
}
#[must_use]
pub fn gh_login(&self) -> Option<&str> {
match self {
ForgeCredential::GhAccount { login, .. } => Some(login),
_ => None,
}
}
#[must_use]
pub fn author(&self, host: &str) -> Option<Result<CommitAuthor, String>> {
match self {
ForgeCredential::GhAccount {
login,
keep_author: false,
} => Some(gh_noreply_author(host, login, GH_TIMEOUT)),
_ => None,
}
}
#[must_use]
pub fn label(&self) -> String {
match self {
ForgeCredential::GitDefault => "git default".into(),
ForgeCredential::SshKey { path } => {
format!("SSH key {}", crate::git_workspace::display_path(path))
}
ForgeCredential::GhAccount { login, .. } => format!("gh account {login}"),
}
}
#[must_use]
pub fn protocol(&self, fallback: CloneProtocol) -> CloneProtocol {
match self {
ForgeCredential::GitDefault => fallback,
ForgeCredential::SshKey { .. } => CloneProtocol::Ssh,
ForgeCredential::GhAccount { .. } => CloneProtocol::Https,
}
}
#[must_use]
pub fn marker(&self) -> Option<String> {
match self {
ForgeCredential::GitDefault => None,
ForgeCredential::SshKey { path } => Some(format!("ssh:{}", path.display())),
ForgeCredential::GhAccount { login, .. } => Some(format!("gh:{login}")),
}
}
#[must_use]
pub fn from_marker(marker: &str) -> Option<Self> {
if let Some(path) = marker.strip_prefix("ssh:") {
return (!path.is_empty()).then(|| ForgeCredential::SshKey {
path: PathBuf::from(path),
});
}
marker
.strip_prefix("gh:")
.filter(|l| valid_login(l))
.map(|login| ForgeCredential::gh(login.to_string()))
}
pub fn check(&self, host: &str) -> Result<(), String> {
match self {
ForgeCredential::GitDefault => Ok(()),
ForgeCredential::SshKey { path } => validate_key_path(path).map(|_| ()),
ForgeCredential::GhAccount { login, .. } => gh_check_account(host, login, GH_TIMEOUT),
}
}
}
#[must_use]
pub fn valid_login(login: &str) -> bool {
!login.is_empty()
&& login.len() <= 39
&& !login.starts_with('-')
&& !login.ends_with('-')
&& !login.contains("--")
&& login.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
pub fn validate_key_path(path: &Path) -> Result<PathBuf, String> {
let shown = crate::git_workspace::display_path(path);
let Some(text) = path.to_str() else {
return Err(format!(
"{shown} is not a UTF-8 path openvtc can pass to ssh."
));
};
if text
.chars()
.any(|c| c == '\'' || c == '"' || c.is_control())
{
return Err(format!(
"{shown} contains a quote or control character; rename the key file."
));
}
if !path.is_absolute() {
return Err("Use an absolute path, or one starting with ~/.".into());
}
if text.ends_with(".pub") {
return Err(format!(
"{shown} is the public half of the key; choose the private key file (no .pub)."
));
}
match std::fs::metadata(path) {
Ok(m) if m.is_file() => Ok(path.to_path_buf()),
Ok(_) => Err(format!("{shown} is not a file.")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(format!("The key file {shown} is missing."))
}
Err(e) => Err(format!("couldn't read the key file {shown}: {e}")),
}
}
#[must_use]
pub fn ssh_keys_in(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut keys: Vec<PathBuf> = entries
.filter_map(Result::ok)
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name.starts_with("id_") && !name.ends_with(".pub")
})
.map(|e| e.path())
.filter(|p| p.is_file() && validate_key_path(p).is_ok())
.collect();
keys.sort();
keys
}
#[must_use]
pub fn ssh_keys() -> Vec<PathBuf> {
dirs::home_dir()
.map(|h| ssh_keys_in(&h.join(".ssh")))
.unwrap_or_default()
}
fn helper_key(host: &str) -> String {
format!("credential.https://{host}.helper")
}
fn username_key(host: &str) -> String {
format!("credential.https://{host}.username")
}
fn ssh_command(path: &Path, batch: bool) -> String {
format!(
"ssh -i '{}' -o IdentitiesOnly=yes{}",
path.display(),
if batch { " -o BatchMode=yes" } else { "" }
)
}
fn gh_helper(host: &str, login: &str) -> String {
format!(
"!f() {{ test \"$1\" = get || exit 0; \
t=$(gh auth token --hostname {host} --user {login}) || exit 1; \
echo username={login}; echo \"password=$t\"; }}; f"
)
}
pub fn local_settings(
credential: &ForgeCredential,
host: &str,
author: Option<&CommitAuthor>,
) -> Result<Vec<(String, String)>, String> {
let Some(marker) = credential.marker() else {
return Ok(Vec::new());
};
let mut out = match credential {
ForgeCredential::GitDefault => Vec::new(),
ForgeCredential::SshKey { path } => {
check_key_text(path)?;
vec![("core.sshCommand".to_string(), ssh_command(path, false))]
}
ForgeCredential::GhAccount { login, .. } => {
if !valid_login(login) {
return Err(format!("'{login}' is not a forge login openvtc will use."));
}
vec![
(helper_key(host), String::new()),
(helper_key(host), gh_helper(host, login)),
(username_key(host), login.clone()),
]
}
};
if let Some(a) = author {
if [&a.name, &a.email]
.iter()
.any(|v| v.is_empty() || v.chars().any(char::is_control))
{
return Err("the commit author has an empty or control-character field.".into());
}
out.push(("user.name".into(), a.name.clone()));
out.push(("user.email".into(), a.email.clone()));
out.push((AUTHOR_MARKER_KEY.into(), "true".into()));
}
out.push((MARKER_KEY.to_string(), marker));
Ok(out)
}
fn check_key_text(path: &Path) -> Result<(), String> {
match path.to_str() {
Some(t)
if path.has_root() && !t.chars().any(|c| c == '\'' || c == '"' || c.is_control()) =>
{
Ok(())
}
_ => Err(format!(
"{} is not a key path openvtc will put on a command line.",
crate::git_workspace::display_path(path)
)),
}
}
pub fn clone_args(
coords: &RepoCoords,
protocol: CloneProtocol,
credential: &ForgeCredential,
author: Option<&CommitAuthor>,
dest: &Path,
) -> Result<Vec<OsString>, String> {
let mut args: Vec<OsString> = Vec::new();
if let ForgeCredential::SshKey { path } = credential {
args.push("-c".into());
args.push(format!("core.sshCommand={}", ssh_command(path, true)).into());
}
args.extend(["clone".into(), "--quiet".into()]);
for (key, value) in local_settings(credential, &coords.host, author)? {
args.push("--config".into());
args.push(format!("{key}={value}").into());
}
args.push("--".into());
args.push(coords.clone_url(credential.protocol(protocol)).into());
args.push(dest.as_os_str().to_owned());
Ok(args)
}
fn git_config(dir: &Path, args: &[&str]) -> Result<Output, String> {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(dir)
.args(["config", "--local"])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0");
run_bounded(cmd, GIT_CONFIG_TIMEOUT).map_err(|e| match e {
RunError::NotInstalled => "git is not installed (or not on PATH).".to_string(),
RunError::TimedOut => "git config did not answer in time.".to_string(),
RunError::Io(e) => format!("couldn't run git: {e}"),
})
}
#[must_use]
pub fn applied_in(dir: &Path) -> Option<ForgeCredential> {
let out = git_config(dir, &["--get", MARKER_KEY]).ok()?;
if !out.status.success() {
return None;
}
ForgeCredential::from_marker(String::from_utf8_lossy(&out.stdout).trim())
}
pub fn apply_to_checkout(
dir: &Path,
host: &str,
credential: &ForgeCredential,
author: Option<&CommitAuthor>,
) -> Result<(), String> {
let settings = local_settings(credential, host, author)?;
let unset = |key: &str| -> Result<(), String> {
let out = git_config(dir, &["--unset-all", "--", key])?;
match out.status.code() {
Some(0 | 5) => Ok(()),
_ => Err(format!(
"git could not remove {key} from {}: {}",
crate::git_workspace::display_path(dir),
String::from_utf8_lossy(&out.stderr).trim()
)),
}
};
match applied_in(dir) {
Some(ForgeCredential::SshKey { .. }) => unset("core.sshCommand")?,
Some(ForgeCredential::GhAccount { .. }) => {
unset(&helper_key(host))?;
unset(&username_key(host))?;
}
Some(ForgeCredential::GitDefault) | None => {}
}
if git_config(dir, &["--get", AUTHOR_MARKER_KEY]).is_ok_and(|o| o.status.success()) {
unset("user.name")?;
unset("user.email")?;
unset(AUTHOR_MARKER_KEY)?;
}
unset(MARKER_KEY)?;
if matches!(credential, ForgeCredential::SshKey { .. }) {
unset("core.sshCommand")?;
}
for (key, value) in &settings {
let out = git_config(dir, &["--add", "--", key, value])?;
if !out.status.success() {
return Err(format!(
"git could not set {key} in {}: {}",
crate::git_workspace::display_path(dir),
String::from_utf8_lossy(&out.stderr).trim()
));
}
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GhAccount {
pub host: String,
pub login: String,
pub active: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GhError {
NotInstalled,
TimedOut,
Failed(String),
}
impl std::fmt::Display for GhError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GhError::NotInstalled => write!(f, "gh is not installed (or not on PATH)"),
GhError::TimedOut => write!(
f,
"gh did not answer within {} seconds",
GH_TIMEOUT.as_secs()
),
GhError::Failed(why) => write!(f, "gh could not list its accounts: {why}"),
}
}
}
#[derive(Deserialize)]
struct GhStatusJson {
hosts: std::collections::BTreeMap<String, Vec<GhStatusEntry>>,
}
#[derive(Deserialize)]
struct GhStatusEntry {
login: Option<String>,
#[serde(default)]
active: bool,
}
#[must_use]
pub fn parse_gh_status_json(text: &str) -> Option<Vec<GhAccount>> {
let parsed: GhStatusJson = serde_json::from_str(text).ok()?;
let mut out = Vec::new();
for (host, entries) in parsed.hosts {
for e in entries {
if let Some(login) = e.login.filter(|l| valid_login(l)) {
out.push(GhAccount {
host: host.clone(),
login,
active: e.active,
});
}
}
}
Some(out)
}
#[must_use]
pub fn parse_gh_status_text(text: &str) -> Vec<GhAccount> {
let mut out: Vec<GhAccount> = Vec::new();
for line in text.lines() {
let line = line.trim();
if let Some(rest) = line.split_once("Logged in to ").map(|(_, r)| r) {
let mut words = rest.split_whitespace();
let (Some(host), Some(_), Some(login)) = (words.next(), words.next(), words.next())
else {
continue;
};
if valid_login(login) {
out.push(GhAccount {
host: host.to_string(),
login: login.to_string(),
active: false,
});
}
} else if line.contains("Active account: true")
&& let Some(last) = out.last_mut()
{
last.active = true;
}
}
out
}
pub fn gh_accounts(timeout: Duration) -> Result<Vec<GhAccount>, GhError> {
let run = |args: &[&str]| -> Result<Output, GhError> {
let mut cmd = Command::new("gh");
cmd.args(args).env("GH_PROMPT_DISABLED", "1");
run_bounded(cmd, timeout).map_err(|e| match e {
RunError::NotInstalled => GhError::NotInstalled,
RunError::TimedOut => GhError::TimedOut,
RunError::Io(e) => GhError::Failed(e),
})
};
let out = run(&["auth", "status", "--json", "hosts"])?;
if out.status.success()
&& let Some(accounts) = parse_gh_status_json(&String::from_utf8_lossy(&out.stdout))
{
return Ok(accounts);
}
let out = run(&["auth", "status"])?;
let text = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let accounts = parse_gh_status_text(&text);
if accounts.is_empty() && !out.status.success() {
let why = text
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("no accounts")
.to_string();
if why.contains("not logged in") {
return Ok(Vec::new());
}
return Err(GhError::Failed(why));
}
Ok(accounts)
}
#[derive(Deserialize)]
struct GhUser {
id: u64,
login: String,
}
#[must_use]
pub fn noreply_author(host: &str, id: u64, login: &str) -> CommitAuthor {
CommitAuthor {
name: login.to_string(),
email: format!("{id}+{login}@users.noreply.{host}"),
}
}
pub fn gh_noreply_author(
host: &str,
login: &str,
timeout: Duration,
) -> Result<CommitAuthor, String> {
if !valid_login(login) {
return Err(format!("'{login}' is not a forge login openvtc will use."));
}
let mut cmd = Command::new("gh");
cmd.args(["api", "--hostname", host, &format!("users/{login}")])
.env("GH_PROMPT_DISABLED", "1");
let out = match run_bounded(cmd, timeout) {
Ok(out) => out,
Err(RunError::NotInstalled) => return Err("gh is not installed (or not on PATH).".into()),
Err(RunError::TimedOut) => {
return Err(format!(
"gh did not answer within {} seconds.",
timeout.as_secs()
));
}
Err(RunError::Io(e)) => return Err(format!("couldn't run gh: {e}")),
};
if !out.status.success() {
let why = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(format!("couldn't look {login} up on {host}: {why}"));
}
parse_gh_user(&String::from_utf8_lossy(&out.stdout), host, login)
}
fn parse_gh_user(text: &str, host: &str, login: &str) -> Result<CommitAuthor, String> {
let user: GhUser = serde_json::from_str(text).map_err(|e| {
format!("{host} answered the lookup of {login} in a shape openvtc does not read: {e}")
})?;
if !user.login.eq_ignore_ascii_case(login) {
return Err(format!(
"{host} answered for {} when asked for {login}.",
user.login
));
}
Ok(noreply_author(host, user.id, &user.login))
}
#[must_use]
pub fn gh_can_push(coords: &RepoCoords, login: &str, timeout: Duration) -> Option<bool> {
if !valid_login(login) {
return None;
}
let mut cmd = Command::new("gh");
cmd.args(["auth", "token", "--hostname", &coords.host, "--user", login])
.env("GH_PROMPT_DISABLED", "1");
let out = run_bounded(cmd, timeout)
.ok()
.filter(|o| o.status.success())?;
let token = String::from_utf8(out.stdout).ok()?.trim().to_string();
let mut cmd = Command::new("gh");
cmd.args([
"api",
"--hostname",
&coords.host,
&format!("repos/{}/{}", coords.owner, coords.repo),
])
.env("GH_PROMPT_DISABLED", "1")
.env(
if coords.host == "github.com" {
"GH_TOKEN"
} else {
"GH_ENTERPRISE_TOKEN"
},
token,
);
let out = run_bounded(cmd, timeout)
.ok()
.filter(|o| o.status.success())?;
parse_push_permission(&String::from_utf8_lossy(&out.stdout))
}
fn parse_push_permission(text: &str) -> Option<bool> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
v.get("permissions")?.get("push")?.as_bool()
}
pub fn gh_fork_for_push(
dir: &Path,
coords: &RepoCoords,
login: &str,
timeout: Duration,
) -> Result<(), String> {
if !valid_login(login) {
return Err(format!("'{login}' is not a forge login openvtc will use."));
}
let mut cmd = Command::new("gh");
cmd.args(["auth", "token", "--hostname", &coords.host, "--user", login])
.env("GH_PROMPT_DISABLED", "1");
let out = run_bounded(cmd, timeout).map_err(|_| "gh could not give a token.".to_string())?;
if !out.status.success() {
return Err(explain_gh_token_failure(
&String::from_utf8_lossy(&out.stderr),
&coords.host,
login,
));
}
let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
let mut cmd = Command::new("gh");
cmd.current_dir(dir)
.args([
"repo",
"fork",
"--remote",
"--remote-name",
"fork",
])
.env("GH_PROMPT_DISABLED", "1")
.env(
if coords.host == "github.com" {
"GH_TOKEN"
} else {
"GH_ENTERPRISE_TOKEN"
},
token,
);
let out = run_bounded(cmd, timeout).map_err(|e| match e {
RunError::NotInstalled => "gh is not installed (or not on PATH).".to_string(),
RunError::TimedOut => format!(
"gh repo fork did not finish within {} seconds.",
timeout.as_secs()
),
RunError::Io(e) => format!("couldn't run gh: {e}"),
})?;
if !out.status.success() {
return Err(format!(
"gh could not fork {} as {login}: {}",
coords.resource(),
String::from_utf8_lossy(&out.stderr).trim()
));
}
let out = git_config(dir, &["remote.pushDefault", "fork"])?;
if !out.status.success() {
return Err(format!(
"forked, but git could not set remote.pushDefault: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(())
}
fn explain_gh_token_failure(stderr: &str, host: &str, login: &str) -> String {
if stderr.contains("unknown flag") {
return "this gh cannot choose between accounts; update gh to 2.40 or later, or use an \
SSH key for this repository."
.into();
}
if stderr.contains("no oauth token") || stderr.contains("not logged") {
return format!(
"gh has no account {login} logged in on {host}. Log it in with `gh auth login \
--hostname {host}`, or choose another account (f)."
);
}
let last = stderr
.lines()
.map(str::trim)
.rfind(|l| !l.is_empty())
.unwrap_or("no reason given");
format!("gh could not give a token for {login} on {host}: {last}")
}
pub fn gh_check_account(host: &str, login: &str, timeout: Duration) -> Result<(), String> {
if !valid_login(login) {
return Err(format!("'{login}' is not a forge login openvtc will use."));
}
let mut cmd = Command::new("gh");
cmd.args(["auth", "token", "--hostname", host, "--user", login])
.env("GH_PROMPT_DISABLED", "1");
match run_bounded(cmd, timeout) {
Ok(out) if out.status.success() && !out.stdout.trim_ascii().is_empty() => Ok(()),
Ok(out) => Err(explain_gh_token_failure(
&String::from_utf8_lossy(&out.stderr),
host,
login,
)),
Err(RunError::NotInstalled) => Err(
"gh is not installed (or not on PATH), so its accounts cannot be used. Install the \
GitHub CLI, or choose an SSH key (f)."
.into(),
),
Err(RunError::TimedOut) => Err(format!(
"gh did not answer within {} seconds.",
timeout.as_secs()
)),
Err(RunError::Io(e)) => Err(format!("couldn't run gh: {e}")),
}
}
pub const GH_CONFIG_TIMEOUT: Duration = Duration::from_secs(5);
#[must_use]
pub fn gh_git_protocol(host: &str, timeout: Duration) -> Option<CloneProtocol> {
gh_git_protocol_with(std::ffi::OsStr::new("gh"), host, timeout)
}
fn gh_git_protocol_with(
program: &std::ffi::OsStr,
host: &str,
timeout: Duration,
) -> Option<CloneProtocol> {
let ask = |args: &[&str]| -> Option<CloneProtocol> {
let mut cmd = Command::new(program);
cmd.args(["config", "get", "git_protocol"])
.args(args)
.env("GH_PROMPT_DISABLED", "1");
let out = run_bounded(cmd, timeout).ok()?;
if !out.status.success() {
return None;
}
CloneProtocol::from_gh(&String::from_utf8_lossy(&out.stdout))
};
ask(&["-h", host]).or_else(|| ask(&[]))
}
#[must_use]
pub fn global_https_helper(host: &str) -> bool {
let mut cmd = Command::new("git");
cmd.current_dir(std::env::temp_dir())
.args([
"config",
"--get-urlmatch",
"credential.helper",
&format!("https://{host}"),
])
.env("GIT_CEILING_DIRECTORIES", std::env::temp_dir())
.env("GIT_TERMINAL_PROMPT", "0");
run_bounded(cmd, GIT_CONFIG_TIMEOUT)
.is_ok_and(|o| o.status.success() && !o.stdout.trim_ascii().is_empty())
}
#[must_use]
pub fn forge_facts(host: &str) -> crate::git_workspace::ForgeFacts {
crate::git_workspace::ForgeFacts {
gh_protocol: gh_git_protocol(host, GH_CONFIG_TIMEOUT),
https_helper: global_https_helper(host),
}
}
#[derive(Debug)]
enum RunError {
NotInstalled,
TimedOut,
Io(String),
}
fn run_bounded(mut cmd: Command, timeout: Duration) -> Result<Output, RunError> {
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
RunError::NotInstalled
} else {
RunError::Io(e.to_string())
}
})?;
let drain = |pipe: Option<Box<dyn Read + Send>>| {
std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut p) = pipe {
let _ = p.read_to_end(&mut buf);
}
buf
})
};
let out = drain(
child
.stdout
.take()
.map(|p| Box::new(p) as Box<dyn Read + Send>),
);
let err = drain(
child
.stderr
.take()
.map(|p| Box::new(p) as Box<dyn Read + Send>),
);
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if started.elapsed() >= timeout => {
let _ = child.kill();
let _ = child.wait();
return Err(RunError::TimedOut);
}
Ok(None) => std::thread::sleep(Duration::from_millis(25)),
Err(e) => return Err(RunError::Io(e.to_string())),
}
};
Ok(Output {
status,
stdout: out.join().unwrap_or_default(),
stderr: err.join().unwrap_or_default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn widgets() -> RepoCoords {
RepoCoords::parse("github.com/acme/widgets").unwrap()
}
fn strings(args: &[OsString]) -> Vec<String> {
args.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
#[test]
fn a_missing_or_failing_gh_says_nothing_about_the_protocol() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("no-such-gh");
assert_eq!(
gh_git_protocol_with(missing.as_os_str(), "github.com", Duration::from_secs(5)),
None
);
if cfg!(unix) {
assert_eq!(
gh_git_protocol_with(
std::ffi::OsStr::new("false"),
"github.com",
Duration::from_secs(5)
),
None
);
}
}
#[test]
fn logins_are_held_to_the_forge_alphabet() {
for ok in ["alice", "a", "Alice-Work", "a1-b2", &"x".repeat(39)] {
assert!(valid_login(ok), "{ok}");
}
for bad in [
"",
"-alice",
"alice-",
"al--ice",
"al ice",
"al;ice",
"$(id)",
"a'b",
&"x".repeat(40),
] {
assert!(!valid_login(bad), "{bad:?}");
}
}
#[test]
fn key_paths_are_checked_in_words() {
let dir = tempfile::tempdir().unwrap();
let key = dir.path().join("id_work");
std::fs::write(&key, "k").unwrap();
std::fs::write(dir.path().join("id_work.pub"), "p").unwrap();
assert_eq!(validate_key_path(&key).unwrap(), key);
let missing = validate_key_path(&dir.path().join("id_gone")).unwrap_err();
assert!(missing.contains("is missing"), "{missing}");
let public = validate_key_path(&dir.path().join("id_work.pub")).unwrap_err();
assert!(public.contains("public half"), "{public}");
let quoted = validate_key_path(&dir.path().join("id_'x")).unwrap_err();
assert!(quoted.contains("quote"), "{quoted}");
let relative = validate_key_path(Path::new("id_work")).unwrap_err();
assert!(relative.contains("absolute"), "{relative}");
assert!(
validate_key_path(dir.path())
.unwrap_err()
.contains("not a file")
);
}
#[test]
fn keys_are_found_without_their_public_halves() {
let dir = tempfile::tempdir().unwrap();
for f in [
"id_ed25519",
"id_ed25519.pub",
"id_work",
"known_hosts",
"config",
] {
std::fs::write(dir.path().join(f), "x").unwrap();
}
assert_eq!(
ssh_keys_in(dir.path()),
vec![dir.path().join("id_ed25519"), dir.path().join("id_work")]
);
assert!(ssh_keys_in(&dir.path().join("none")).is_empty());
}
#[test]
fn markers_round_trip() {
for c in [
ForgeCredential::SshKey {
path: PathBuf::from("/home/a/.ssh/id_work"),
},
ForgeCredential::gh("alice".into()),
] {
assert_eq!(ForgeCredential::from_marker(&c.marker().unwrap()), Some(c));
}
assert_eq!(ForgeCredential::GitDefault.marker(), None);
assert_eq!(ForgeCredential::from_marker("gh:$(id)"), None);
assert_eq!(ForgeCredential::from_marker("other"), None);
}
#[test]
fn a_default_clone_is_unchanged() {
let args = clone_args(
&widgets(),
CloneProtocol::Https,
&ForgeCredential::GitDefault,
None,
Path::new("/w/widgets"),
)
.unwrap();
assert_eq!(
strings(&args),
[
"clone",
"--quiet",
"--",
"https://github.com/acme/widgets.git",
"/w/widgets"
]
);
}
#[test]
fn an_ssh_key_clone_uses_that_key_over_ssh() {
let cred = ForgeCredential::SshKey {
path: PathBuf::from("/home/a/.ssh/id_work"),
};
let args = strings(
&clone_args(
&widgets(),
CloneProtocol::Https,
&cred,
None,
Path::new("/w/x"),
)
.unwrap(),
);
assert_eq!(
args,
[
"-c",
"core.sshCommand=ssh -i '/home/a/.ssh/id_work' -o IdentitiesOnly=yes -o BatchMode=yes",
"clone",
"--quiet",
"--config",
"core.sshCommand=ssh -i '/home/a/.ssh/id_work' -o IdentitiesOnly=yes",
"--config",
"openvtc.forgeCredential=ssh:/home/a/.ssh/id_work",
"--",
"git@github.com:acme/widgets.git",
"/w/x"
]
);
}
#[test]
fn a_gh_clone_uses_that_account_over_https() {
let cred = ForgeCredential::gh("alice-work".into());
let args = strings(
&clone_args(
&widgets(),
CloneProtocol::Ssh,
&cred,
None,
Path::new("/w/x"),
)
.unwrap(),
);
assert_eq!(args[..2], ["clone", "--quiet"]);
assert_eq!(
args[2..4],
["--config", "credential.https://github.com.helper="]
);
assert!(args[5].starts_with("credential.https://github.com.helper=!f() {"));
assert!(
args[5].contains("gh auth token --hostname github.com --user alice-work"),
"{}",
args[5]
);
assert_eq!(args[7], "credential.https://github.com.username=alice-work");
assert_eq!(args[9], "openvtc.forgeCredential=gh:alice-work");
assert_eq!(
args[10..],
["--", "https://github.com/acme/widgets.git", "/w/x"]
);
}
#[test]
fn unsafe_choices_are_refused_before_git_sees_them() {
let bad_login = ForgeCredential::gh("a;rm -rf".into());
assert!(
clone_args(
&widgets(),
CloneProtocol::Https,
&bad_login,
None,
Path::new("/w")
)
.is_err()
);
let bad_key = ForgeCredential::SshKey {
path: PathBuf::from("/tmp/k'; touch x"),
};
assert!(
clone_args(
&widgets(),
CloneProtocol::Ssh,
&bad_key,
None,
Path::new("/w")
)
.is_err()
);
}
const GH_JSON: &str = r#"{"hosts":{"github.com":[
{"state":"success","active":true,"host":"github.com","login":"alice","tokenSource":"keyring","scopes":"repo","gitProtocol":"https"},
{"state":"success","active":false,"host":"github.com","login":"alice-work","tokenSource":"keyring"}],
"ghe.acme.com":[{"state":"error","active":true,"host":"ghe.acme.com","login":"al","error":"bad"}]}}"#;
#[test]
fn gh_status_json_is_read() {
let accounts = parse_gh_status_json(GH_JSON).unwrap();
assert_eq!(
accounts,
vec![
GhAccount {
host: "ghe.acme.com".into(),
login: "al".into(),
active: true
},
GhAccount {
host: "github.com".into(),
login: "alice".into(),
active: true
},
GhAccount {
host: "github.com".into(),
login: "alice-work".into(),
active: false
},
]
);
assert!(parse_gh_status_json("not json").is_none());
assert_eq!(parse_gh_status_json(r#"{"hosts":{}}"#), Some(vec![]));
}
#[test]
fn gh_status_text_is_read_for_an_older_gh() {
let text = "github.com\n ✓ Logged in to github.com account alice (keyring)\n \
- Active account: true\n - Git operations protocol: https\n\n \
✓ Logged in to github.com account alice-work (keyring)\n \
- Active account: false\n";
assert_eq!(
parse_gh_status_text(text),
vec![
GhAccount {
host: "github.com".into(),
login: "alice".into(),
active: true
},
GhAccount {
host: "github.com".into(),
login: "alice-work".into(),
active: false
},
]
);
let older = "github.com\n ✓ Logged in to github.com as bob (oauth_token)\n";
assert_eq!(parse_gh_status_text(older)[0].login, "bob");
assert!(parse_gh_status_text("You are not logged into any GitHub hosts.").is_empty());
}
#[test]
fn gh_token_failures_say_what_to_do() {
let none = explain_gh_token_failure(
"no oauth token found for github.com account bob",
"github.com",
"bob",
);
assert!(none.contains("no account bob logged in"), "{none}");
let old = explain_gh_token_failure("unknown flag: --user", "github.com", "bob");
assert!(old.contains("2.40"), "{old}");
}
fn isolated_git(dir: &Path, args: &[&str]) -> bool {
Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", dir.join("empty.gitconfig"))
.env("GIT_CONFIG_NOSYSTEM", "1")
.output()
.is_ok_and(|o| o.status.success())
}
fn local_all(dir: &Path, key: &str) -> Vec<String> {
git_config(dir, &["--get-all", key])
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[test]
fn local_config_is_written_read_back_and_replaced() {
if Command::new("git").arg("--version").output().is_err() {
return;
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("empty.gitconfig"), "").unwrap();
let repo = dir.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
assert!(isolated_git(&repo, &["init", "-q"]));
let key = dir.path().join("id_work");
std::fs::write(&key, "k").unwrap();
assert_eq!(applied_in(&repo), None);
let ssh = ForgeCredential::SshKey { path: key.clone() };
apply_to_checkout(&repo, "github.com", &ssh, None).unwrap();
assert_eq!(applied_in(&repo), Some(ssh));
assert_eq!(
local_all(&repo, "core.sshCommand"),
[format!("ssh -i '{}' -o IdentitiesOnly=yes", key.display())]
);
let gh = ForgeCredential::gh("alice".into());
apply_to_checkout(&repo, "github.com", &gh, None).unwrap();
assert_eq!(applied_in(&repo), Some(gh.clone()));
assert!(
local_all(&repo, "core.sshCommand").is_empty(),
"openvtc's key is removed"
);
let helpers = local_all(&repo, "credential.https://github.com.helper");
assert_eq!(helpers.len(), 2);
assert_eq!(helpers[0], "");
assert!(helpers[1].contains("--user alice"));
apply_to_checkout(&repo, "github.com", &gh, None).unwrap();
assert_eq!(
local_all(&repo, "credential.https://github.com.helper").len(),
2
);
assert!(git_config(&repo, &["core.sshCommand", "ssh -i /mine"]).is_ok());
apply_to_checkout(&repo, "github.com", &ForgeCredential::GitDefault, None).unwrap();
assert_eq!(applied_in(&repo), None);
assert!(local_all(&repo, "credential.https://github.com.helper").is_empty());
assert!(local_all(&repo, "credential.https://github.com.username").is_empty());
assert_eq!(local_all(&repo, "core.sshCommand"), ["ssh -i /mine"]);
}
#[test]
fn a_clone_writes_the_choice_into_the_checkout() {
if Command::new("git").arg("--version").output().is_err() {
return;
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("empty.gitconfig"), "").unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
assert!(isolated_git(&src, &["init", "-q"]));
let gh = ForgeCredential::gh("alice".into());
let mut args = clone_args(
&widgets(),
CloneProtocol::Https,
&gh,
Some(&noreply_author("github.com", 7, "alice")),
&dir.path().join("dst"),
)
.unwrap();
let n = args.len();
args[n - 2] = src.as_os_str().to_owned();
let ok = Command::new("git")
.args(&args)
.env("GIT_CONFIG_GLOBAL", dir.path().join("empty.gitconfig"))
.env("GIT_CONFIG_NOSYSTEM", "1")
.output()
.unwrap()
.status
.success();
assert!(ok);
let dst = dir.path().join("dst");
assert_eq!(applied_in(&dst), Some(gh));
assert_eq!(
local_all(&dst, "user.email"),
["7+alice@users.noreply.github.com"]
);
}
#[test]
fn the_noreply_author_is_read_from_the_forge() {
let a = parse_gh_user(
r#"{"login":"Alice-Work","id":12345,"type":"User"}"#,
"github.com",
"alice-work",
)
.unwrap();
assert_eq!(a.name, "Alice-Work");
assert_eq!(a.email, "12345+Alice-Work@users.noreply.github.com");
assert!(parse_gh_user(r#"{"login":"bob","id":1}"#, "github.com", "alice").is_err());
assert!(parse_gh_user("{}", "github.com", "alice").is_err());
}
#[test]
fn push_permission_is_read() {
assert_eq!(
parse_push_permission(r#"{"permissions":{"admin":false,"push":false,"pull":true}}"#),
Some(false)
);
assert_eq!(
parse_push_permission(r#"{"permissions":{"push":true}}"#),
Some(true)
);
assert_eq!(parse_push_permission(r#"{"name":"x"}"#), None);
}
#[test]
fn a_gh_choice_sets_the_author_unless_kept() {
let author = noreply_author("github.com", 9, "alice");
let settings = local_settings(
&ForgeCredential::gh("alice".into()),
"github.com",
Some(&author),
)
.unwrap();
assert!(settings.contains(&("user.name".into(), "alice".into())));
assert!(settings.contains(&(
"user.email".into(),
"9+alice@users.noreply.github.com".into()
)));
let kept = ForgeCredential::GhAccount {
login: "alice".into(),
keep_author: true,
};
assert!(kept.author("github.com").is_none(), "nothing to look up");
assert!(ForgeCredential::gh("alice".into()).same_account(&kept));
let bad = CommitAuthor {
name: "a\nb".into(),
email: "x".into(),
};
assert!(local_settings(&kept, "github.com", Some(&bad)).is_err());
}
#[test]
fn the_author_is_written_and_removed_with_the_choice() {
if Command::new("git").arg("--version").output().is_err() {
return;
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("empty.gitconfig"), "").unwrap();
let repo = dir.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
assert!(isolated_git(&repo, &["init", "-q"]));
let author = noreply_author("github.com", 9, "alice");
let gh = ForgeCredential::gh("alice".into());
apply_to_checkout(&repo, "github.com", &gh, Some(&author)).unwrap();
assert_eq!(local_all(&repo, "user.name"), ["alice"]);
apply_to_checkout(&repo, "github.com", &gh, Some(&author)).unwrap();
assert_eq!(local_all(&repo, "user.email").len(), 1, "not stacked");
apply_to_checkout(&repo, "github.com", &ForgeCredential::GitDefault, None).unwrap();
assert!(local_all(&repo, "user.name").is_empty());
assert!(local_all(&repo, "user.email").is_empty());
assert!(git_config(&repo, &["user.email", "me@example.com"]).is_ok());
let kept = ForgeCredential::GhAccount {
login: "alice".into(),
keep_author: true,
};
apply_to_checkout(&repo, "github.com", &kept, None).unwrap();
apply_to_checkout(&repo, "github.com", &ForgeCredential::GitDefault, None).unwrap();
assert_eq!(local_all(&repo, "user.email"), ["me@example.com"]);
}
#[test]
fn a_global_helper_is_not_consulted_for_a_chosen_account() {
if Command::new("git").arg("--version").output().is_err() {
return;
}
let dir = tempfile::tempdir().unwrap();
let global = dir.path().join("global.gitconfig");
std::fs::write(
&global,
"[credential]\n\thelper = \"!f() { echo username=cached-other; echo password=stale; }; f\"\n",
)
.unwrap();
let repo = dir.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
assert!(
Command::new("git")
.args(["init", "-q"])
.current_dir(&repo)
.env("GIT_CONFIG_GLOBAL", &global)
.env("GIT_CONFIG_NOSYSTEM", "1")
.output()
.unwrap()
.status
.success()
);
let fill = |repo: &Path| -> String {
use std::io::Write;
let mut child = Command::new("git")
.args(["credential", "fill"])
.current_dir(repo)
.env("GIT_CONFIG_GLOBAL", &global)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(b"protocol=https\nhost=github.com\n\n")
.unwrap();
String::from_utf8_lossy(&child.wait_with_output().unwrap().stdout).into_owned()
};
assert!(
fill(&repo).contains("username=cached-other"),
"the global helper answers by default"
);
apply_to_checkout(
&repo,
"github.com",
&ForgeCredential::gh("alice".into()),
None,
)
.unwrap();
assert!(
!fill(&repo).contains("cached-other"),
"the chosen account's helper is the only one"
);
}
}