From 290c7734d96a60e30a326b874c2aaf661054e8d2 Mon Sep 17 00:00:00 2001 From: Przemyslaw Olszewski Date: Sun, 30 Aug 2026 19:31:17 +0200 Subject: [PATCH] fix: reset inherited Git credential helpers --- diff --git a/src/login.rs b/src/login.rs --- a/src/login.rs +++ b/src/login.rs @@ -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::() - .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 { - 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 { - 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 { - 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::() + .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 { + 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 { + 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 { + 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)> { + 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()], + ), + ] + ); + } +} -- SynCode