fix: Reset inherited Git credential helpers #7

Manually merged
day01 merged 1 commits from fix/0.6-reset-git-credential-chain into develop 2026-08-30 17:31:31 +00:00
+53 -16
View File
@@ -1,225 +1,262 @@
use std::process::Stdio;
use std::time::Duration;

use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use url::Url;
use uuid::Uuid;

use crate::config::{Host, normalize_host};
use crate::error::{Error, Result};
use crate::ipc::{self, Request};

#[derive(Serialize)]
struct ExchangeRequest<'a> {
code: &'a str,
verifier: &'a str,
}

#[derive(Deserialize)]
struct ExchangeResponse {
session_token: String,
}

pub async fn login(host: &str, provider: &str) -> Result<()> {
validate_provider(provider)?;
let (forge_url, identity_url) = normalize_host(host)?;
let verifier = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let listener = TcpListener::bind("127.0.0.1:0").await?;
let redirect = format!("http://{}/callback", listener.local_addr()?);
let mut start = Url::parse(&format!("{identity_url}/auth/{provider}/start"))?;
start
.query_pairs_mut()
.append_pair("redirect_to", &redirect)
.append_pair("mode", "cli")
.append_pair("code_challenge", &challenge);
open_browser(start.as_str())?;
let code = receive_code(listener).await?;
let exchanged = reqwest::Client::new()
.post(format!("{identity_url}/auth/cli/exchange"))
.json(&ExchangeRequest {
code: &code,
verifier: &verifier,
})
.send()
.await?
.error_for_status()?
.json::<ExchangeResponse>()
.await?;
let mut config = crate::config::load()?;
config.hosts.insert(
forge_url.clone(),
Host {
forge_url: forge_url.clone(),
identity_url,
session_token: exchanged.session_token,
active_agent: config
.hosts
.get(&forge_url)
.and_then(|host| host.active_agent.clone()),
},
);
crate::config::save(&config)?;
ensure_daemon().await?;
install_credential_helper(&forge_url)?;
println!("Logged in to {forge_url}");
Ok(())
}

async fn receive_code(listener: TcpListener) -> Result<String> {
let (mut stream, _) = listener.accept().await?;
let mut buffer = vec![0_u8; 8192];
let size = stream.read(&mut buffer).await?;
let request = std::str::from_utf8(&buffer[..size])
.map_err(|_| Error::Configuration("OAuth callback is not UTF-8".to_owned()))?;
let target = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.ok_or_else(|| Error::Configuration("OAuth callback request is malformed".to_owned()))?;
let callback = Url::parse(&format!("http://127.0.0.1{target}"))?;
let code = callback
.query_pairs()
.find_map(|(name, value)| (name == "code").then(|| value.into_owned()))
.ok_or_else(|| Error::Configuration("OAuth callback has no login code".to_owned()))?;
let body = "SynCode login complete. You may close this tab.";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await?;
stream.shutdown().await?;
Ok(code)
}

fn validate_provider(provider: &str) -> Result<()> {
if provider.is_empty()
|| provider.len() > 64
|| !provider
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
{
return Err(Error::Configuration("OAuth provider is invalid".to_owned()));
}
Ok(())
}

pub async fn ensure_daemon() -> Result<()> {
if ipc::send(&Request::Status).await.is_ok() {
return Ok(());
}
let executable = std::env::current_exe()?;
let state_directory = crate::config::state_directory()?;
std::fs::create_dir_all(&state_directory)?;
let log = open_daemon_log(&state_directory.join("daemon.log"))?;
let stderr = log.try_clone()?;
std::process::Command::new(executable)
.arg("daemon")
.stdin(Stdio::null())
.stdout(Stdio::from(log))
.stderr(Stdio::from(stderr))
.spawn()?;
for _ in 0..30 {
tokio::time::sleep(Duration::from_millis(100)).await;
if ipc::send(&Request::Status).await.is_ok() {
return Ok(());
}
}
Err(Error::Daemon(format!(
"daemon did not start; inspect {}",
state_directory.join("daemon.log").display()
)))
}

#[cfg(unix)]
fn open_daemon_log(path: &std::path::Path) -> Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;

Ok(std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(path)?)
}

#[cfg(windows)]
fn open_daemon_log(path: &std::path::Path) -> Result<std::fs::File> {
Ok(std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?)
}

fn install_credential_helper(forge_url: &str) -> Result<()> {
let host = Url::parse(forge_url)?
.host_str()
.ok_or_else(|| Error::Configuration("forge URL has no hostname".to_owned()))?
.to_owned();
let executable = std::env::current_exe()?;
let helper = format!("!\"{}\" credential", executable.display());
for (key, value) in [
(format!("credential.https://{host}.helper"), helper),
(
format!("credential.https://{host}.useHttpPath"),
"true".to_owned(),
),
] {
let status = std::process::Command::new("git")
.args(["config", "--global", &key, &value])
.status()?;
if !status.success() {
return Err(Error::Command(
"git config rejected the credential helper".to_owned(),
));
}
}
Ok(())
}

#[cfg(target_os = "macos")]
fn open_browser(url: &str) -> Result<()> {
spawn_opener("open", url)
}

#[cfg(all(unix, not(target_os = "macos")))]
fn open_browser(url: &str) -> Result<()> {
spawn_opener("xdg-open", url)
}

#[cfg(windows)]
fn open_browser(url: &str) -> Result<()> {
let status = std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.status()?;
if !status.success() {
return Err(Error::Command("cannot open the browser".to_owned()));
}
Ok(())
}

#[cfg(unix)]
fn spawn_opener(command: &str, url: &str) -> Result<()> {
let status = std::process::Command::new(command).arg(url).status()?;
if !status.success() {
return Err(Error::Command("cannot open the browser".to_owned()));
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::validate_provider;

#[test]
fn provider_is_a_safe_path_segment() {
assert!(validate_provider("github").is_ok());
assert!(validate_provider("gitlab-self_hosted").is_ok());
assert!(validate_provider("").is_err());
assert!(validate_provider("../callback").is_err());
}
}
use std::process::Stdio;
use std::time::Duration;

use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use url::Url;
use uuid::Uuid;

use crate::config::{Host, normalize_host};
use crate::error::{Error, Result};
use crate::ipc::{self, Request};

#[derive(Serialize)]
struct ExchangeRequest<'a> {
code: &'a str,
verifier: &'a str,
}

#[derive(Deserialize)]
struct ExchangeResponse {
session_token: String,
}

pub async fn login(host: &str, provider: &str) -> Result<()> {
validate_provider(provider)?;
let (forge_url, identity_url) = normalize_host(host)?;
let verifier = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let listener = TcpListener::bind("127.0.0.1:0").await?;
let redirect = format!("http://{}/callback", listener.local_addr()?);
let mut start = Url::parse(&format!("{identity_url}/auth/{provider}/start"))?;
start
.query_pairs_mut()
.append_pair("redirect_to", &redirect)
.append_pair("mode", "cli")
.append_pair("code_challenge", &challenge);
open_browser(start.as_str())?;
let code = receive_code(listener).await?;
let exchanged = reqwest::Client::new()
.post(format!("{identity_url}/auth/cli/exchange"))
.json(&ExchangeRequest {
code: &code,
verifier: &verifier,
})
.send()
.await?
.error_for_status()?
.json::<ExchangeResponse>()
.await?;
let mut config = crate::config::load()?;
config.hosts.insert(
forge_url.clone(),
Host {
forge_url: forge_url.clone(),
identity_url,
session_token: exchanged.session_token,
active_agent: config
.hosts
.get(&forge_url)
.and_then(|host| host.active_agent.clone()),
},
);
crate::config::save(&config)?;
ensure_daemon().await?;
install_credential_helper(&forge_url)?;
println!("Logged in to {forge_url}");
Ok(())
}

async fn receive_code(listener: TcpListener) -> Result<String> {
let (mut stream, _) = listener.accept().await?;
let mut buffer = vec![0_u8; 8192];
let size = stream.read(&mut buffer).await?;
let request = std::str::from_utf8(&buffer[..size])
.map_err(|_| Error::Configuration("OAuth callback is not UTF-8".to_owned()))?;
let target = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.ok_or_else(|| Error::Configuration("OAuth callback request is malformed".to_owned()))?;
let callback = Url::parse(&format!("http://127.0.0.1{target}"))?;
let code = callback
.query_pairs()
.find_map(|(name, value)| (name == "code").then(|| value.into_owned()))
.ok_or_else(|| Error::Configuration("OAuth callback has no login code".to_owned()))?;
let body = "SynCode login complete. You may close this tab.";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await?;
stream.shutdown().await?;
Ok(code)
}

fn validate_provider(provider: &str) -> Result<()> {
if provider.is_empty()
|| provider.len() > 64
|| !provider
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
{
return Err(Error::Configuration("OAuth provider is invalid".to_owned()));
}
Ok(())
}

pub async fn ensure_daemon() -> Result<()> {
if ipc::send(&Request::Status).await.is_ok() {
return Ok(());
}
let executable = std::env::current_exe()?;
let state_directory = crate::config::state_directory()?;
std::fs::create_dir_all(&state_directory)?;
let log = open_daemon_log(&state_directory.join("daemon.log"))?;
let stderr = log.try_clone()?;
std::process::Command::new(executable)
.arg("daemon")
.stdin(Stdio::null())
.stdout(Stdio::from(log))
.stderr(Stdio::from(stderr))
.spawn()?;
for _ in 0..30 {
tokio::time::sleep(Duration::from_millis(100)).await;
if ipc::send(&Request::Status).await.is_ok() {
return Ok(());
}
}
Err(Error::Daemon(format!(
"daemon did not start; inspect {}",
state_directory.join("daemon.log").display()
)))
}

#[cfg(unix)]
fn open_daemon_log(path: &std::path::Path) -> Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;

Ok(std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(path)?)
}

#[cfg(windows)]
fn open_daemon_log(path: &std::path::Path) -> Result<std::fs::File> {
Ok(std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?)
}

fn install_credential_helper(forge_url: &str) -> Result<()> {
let host = Url::parse(forge_url)?
.host_str()
.ok_or_else(|| Error::Configuration("forge URL has no hostname".to_owned()))?
.to_owned();
let executable = std::env::current_exe()?;
for (key, values) in credential_helper_settings(&host, &executable) {
for (index, value) in values.iter().enumerate() {
let operation = if index == 0 { "--replace-all" } else { "--add" };
let status = std::process::Command::new("git")
.args(["config", "--global", operation, &key, value])
.status()?;
if !status.success() {
return Err(Error::Command(
"git config rejected the credential helper".to_owned(),
));
}
}
}
Ok(())
}

fn credential_helper_settings(
host: &str,
executable: &std::path::Path,
) -> Vec<(String, Vec<String>)> {
vec![
(
format!("credential.https://{host}.helper"),
vec![
String::new(),
format!("!\"{}\" credential", executable.display()),
],
),
(
format!("credential.https://{host}.useHttpPath"),
vec!["true".to_owned()],
),
]
}

#[cfg(target_os = "macos")]
fn open_browser(url: &str) -> Result<()> {
spawn_opener("open", url)
}

#[cfg(all(unix, not(target_os = "macos")))]
fn open_browser(url: &str) -> Result<()> {
spawn_opener("xdg-open", url)
}

#[cfg(windows)]
fn open_browser(url: &str) -> Result<()> {
let status = std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.status()?;
if !status.success() {
return Err(Error::Command("cannot open the browser".to_owned()));
}
Ok(())
}

#[cfg(unix)]
fn spawn_opener(command: &str, url: &str) -> Result<()> {
let status = std::process::Command::new(command).arg(url).status()?;
if !status.success() {
return Err(Error::Command("cannot open the browser".to_owned()));
}
Ok(())
}

#[cfg(test)]
mod tests {
use std::path::Path;

use super::{credential_helper_settings, validate_provider};

#[test]
fn provider_is_a_safe_path_segment() {
assert!(validate_provider("github").is_ok());
assert!(validate_provider("gitlab-self_hosted").is_ok());
assert!(validate_provider("").is_err());
assert!(validate_provider("../callback").is_err());
}

#[test]
fn host_helper_resets_inherited_helpers() {
assert_eq!(
credential_helper_settings("dev.syncode.sh", Path::new("/usr/local/bin/syn")),
vec![
(
"credential.https://dev.syncode.sh.helper".to_owned(),
vec![
String::new(),
"!\"/usr/local/bin/syn\" credential".to_owned(),
],
),
(
"credential.https://dev.syncode.sh.useHttpPath".to_owned(),
vec!["true".to_owned()],
),
]
);
}
}