From 00b8a933176f36d5af0b659e565c7a71d60e453b Mon Sep 17 00:00:00 2001 From: Przemyslaw Olszewski Date: Sun, 30 Aug 2026 19:12:01 +0200 Subject: [PATCH] fix: issue user Git credentials --- diff --git a/src/credential.rs b/src/credential.rs --- a/src/credential.rs +++ b/src/credential.rs @@ -1,85 +1,94 @@ -use std::collections::BTreeMap; - -use tokio::io::{AsyncBufReadExt, BufReader}; -use url::Url; - -use crate::error::{Error, Result}; -use crate::ipc::{self, Request}; - -pub async fn run(operation: &str) -> Result<()> { - let input = read_input().await?; - match operation { - "get" => get(&input).await, - "store" | "erase" => Ok(()), - _ => Err(Error::Configuration(format!( - "unknown git credential operation {operation}" - ))), - } -} - -async fn get(input: &BTreeMap) -> Result<()> { - let protocol = input - .get("protocol") - .ok_or_else(|| Error::Configuration("git credential has no protocol".to_owned()))?; - if protocol != "https" { - return Err(Error::Configuration( - "SynCode credentials are only issued for HTTPS".to_owned(), - )); - } - let hostname = input - .get("host") - .ok_or_else(|| Error::Configuration("git credential has no host".to_owned()))?; - let path = input - .get("path") - .ok_or_else(|| Error::Configuration("git credential has no repository path".to_owned()))? - .trim_start_matches('/') - .trim_end_matches(".git"); - let (owner, repository) = path - .split_once('/') - .ok_or_else(|| Error::Configuration("repository path must be owner/name".to_owned()))?; - if owner.is_empty() || repository.is_empty() || repository.contains('/') { - return Err(Error::Configuration( - "repository path must be owner/name".to_owned(), - )); - } - let config = crate::config::load()?; - let mut matching_host = None; - for (key, configured) in &config.hosts { - let url = Url::parse(&configured.forge_url)?; - if url.host_str() == Some(hostname.as_str()) { - matching_host = Some(key.clone()); - break; - } - } - let host = matching_host - .ok_or_else(|| Error::Configuration(format!("not logged in to {hostname}")))?; - let response = ipc::send(&Request::RepositoryCredential { - host, - owner: owner.to_owned(), - repository: repository.to_owned(), - as_agent: true, - }) - .await?; - let token = response - .token - .ok_or_else(|| Error::Daemon("daemon returned no repository token".to_owned()))?; - println!("username=syn"); - println!("password={token}"); - println!(); - Ok(()) -} - -async fn read_input() -> Result> { - let mut input = BTreeMap::new(); - let mut lines = BufReader::new(tokio::io::stdin()).lines(); - while let Some(line) = lines.next_line().await? { - if line.is_empty() { - break; - } - let (key, value) = line - .split_once('=') - .ok_or_else(|| Error::Configuration("git credential input is malformed".to_owned()))?; - input.insert(key.to_owned(), value.to_owned()); - } - Ok(input) -} +use std::collections::BTreeMap; + +use tokio::io::{AsyncBufReadExt, BufReader}; +use url::Url; + +use crate::error::{Error, Result}; +use crate::ipc::{self, Request}; + +pub async fn run(operation: &str) -> Result<()> { + let input = read_input().await?; + match operation { + "get" => get(&input).await, + "store" | "erase" => Ok(()), + _ => Err(Error::Configuration(format!( + "unknown git credential operation {operation}" + ))), + } +} + +async fn get(input: &BTreeMap) -> Result<()> { + let protocol = input + .get("protocol") + .ok_or_else(|| Error::Configuration("git credential has no protocol".to_owned()))?; + if protocol != "https" { + return Err(Error::Configuration( + "SynCode credentials are only issued for HTTPS".to_owned(), + )); + } + let hostname = input + .get("host") + .ok_or_else(|| Error::Configuration("git credential has no host".to_owned()))?; + let path = input + .get("path") + .ok_or_else(|| Error::Configuration("git credential has no repository path".to_owned()))? + .trim_start_matches('/') + .trim_end_matches(".git"); + let (owner, repository) = path + .split_once('/') + .ok_or_else(|| Error::Configuration("repository path must be owner/name".to_owned()))?; + if owner.is_empty() || repository.is_empty() || repository.contains('/') { + return Err(Error::Configuration( + "repository path must be owner/name".to_owned(), + )); + } + let config = crate::config::load()?; + let mut matching_host = None; + for (key, configured) in &config.hosts { + let url = Url::parse(&configured.forge_url)?; + if url.host_str() == Some(hostname.as_str()) { + matching_host = Some(key.clone()); + break; + } + } + let host = matching_host + .ok_or_else(|| Error::Configuration(format!("not logged in to {hostname}")))?; + let response = ipc::send(&Request::RepositoryCredential { + host, + owner: owner.to_owned(), + repository: repository.to_owned(), + as_agent: repository_uses_agent(), + }) + .await?; + let token = response + .token + .ok_or_else(|| Error::Daemon("daemon returned no repository token".to_owned()))?; + println!("username=syn"); + println!("password={token}"); + println!(); + Ok(()) +} + +fn repository_uses_agent() -> bool { + std::process::Command::new("git") + .args(["config", "--bool", "--get", "syncode.asAgent"]) + .output() + .ok() + .filter(|output| output.status.success()) + .is_some_and(|output| output.stdout == b"true\n") +} + +async fn read_input() -> Result> { + let mut input = BTreeMap::new(); + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + while let Some(line) = lines.next_line().await? { + if line.is_empty() { + break; + } + let (key, value) = line + .split_once('=') + .ok_or_else(|| Error::Configuration("git credential input is malformed".to_owned()))?; + input.insert(key.to_owned(), value.to_owned()); + } + Ok(input) +} diff --git a/src/login.rs b/src/login.rs --- a/src/login.rs +++ b/src/login.rs @@ -1,222 +1,225 @@ -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()); - let status = std::process::Command::new("git") - .args([ - "config", - "--global", - &format!("credential.https://{host}.helper"), - &helper, - ]) - .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()?; + 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()); + } +} diff --git a/src/repository.rs b/src/repository.rs --- a/src/repository.rs +++ b/src/repository.rs @@ -1,66 +1,79 @@ -use std::path::Path; - -use tokio::process::Command; - -use crate::api::Repository; -use crate::error::{Error, Result}; -use crate::ipc::{self, Request}; - -const CREDENTIAL_HELPER: &str = - "!f() { printf 'username=syn\\npassword=%s\\n\\n' \"$SYNCODE_GIT_TOKEN\"; }; f"; - -pub async fn clone( - host: &str, - repository: &str, - directory: Option<&Path>, - as_agent: bool, -) -> Result<()> { - let repository = Repository::parse(repository)?; - let (host_key, _) = crate::config::normalize_host(host)?; - let config = crate::config::load()?; - let configured = config - .hosts - .get(&host_key) - .ok_or_else(|| Error::Configuration(format!("not logged in to {host_key}")))?; - let response = ipc::send(&Request::RepositoryCredential { - host: host_key, - owner: repository.owner.clone(), - repository: repository.name.clone(), - as_agent, - }) - .await?; - let token = response - .token - .ok_or_else(|| Error::Daemon("daemon returned no repository token".to_owned()))?; - let mut remote = url::Url::parse(&configured.forge_url)?; - let mut segments = remote.path_segments_mut().map_err(|()| { - Error::Configuration("forge URL cannot contain repository paths".to_owned()) - })?; - segments.clear(); - segments.push(&repository.owner); - segments.push(&format!("{}.git", repository.name)); - drop(segments); - let mut command = Command::new("git"); - command - .env("SYNCODE_GIT_TOKEN", token) - .env("GIT_TERMINAL_PROMPT", "0") - .args([ - "-c", - "credential.helper=", - "-c", - &format!("credential.helper={CREDENTIAL_HELPER}"), - "-c", - "credential.useHttpPath=true", - "clone", - remote.as_str(), - ]); - if let Some(directory) = directory { - command.arg(directory); - } - let status = command.status().await?; - if status.success() { - Ok(()) - } else { - Err(Error::Command(format!("git clone exited with {status}"))) - } -} +use std::path::{Path, PathBuf}; + +use tokio::process::Command; + +use crate::api::Repository; +use crate::error::{Error, Result}; +use crate::ipc::{self, Request}; + +const CREDENTIAL_HELPER: &str = + "!f() { printf 'username=syn\\npassword=%s\\n\\n' \"$SYNCODE_GIT_TOKEN\"; }; f"; + +pub async fn clone( + host: &str, + repository: &str, + directory: Option<&Path>, + as_agent: bool, +) -> Result<()> { + let repository = Repository::parse(repository)?; + let (host_key, _) = crate::config::normalize_host(host)?; + let config = crate::config::load()?; + let configured = config + .hosts + .get(&host_key) + .ok_or_else(|| Error::Configuration(format!("not logged in to {host_key}")))?; + let response = ipc::send(&Request::RepositoryCredential { + host: host_key, + owner: repository.owner.clone(), + repository: repository.name.clone(), + as_agent, + }) + .await?; + let token = response + .token + .ok_or_else(|| Error::Daemon("daemon returned no repository token".to_owned()))?; + let target = directory + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(&repository.name)); + let mut remote = url::Url::parse(&configured.forge_url)?; + let mut segments = remote.path_segments_mut().map_err(|()| { + Error::Configuration("forge URL cannot contain repository paths".to_owned()) + })?; + segments.clear(); + segments.push(&repository.owner); + segments.push(&format!("{}.git", repository.name)); + drop(segments); + let mut command = Command::new("git"); + command + .env("SYNCODE_GIT_TOKEN", token) + .env("GIT_TERMINAL_PROMPT", "0") + .args([ + "-c", + "credential.helper=", + "-c", + &format!("credential.helper={CREDENTIAL_HELPER}"), + "-c", + "credential.useHttpPath=true", + "clone", + remote.as_str(), + ]); + if let Some(directory) = directory { + command.arg(directory); + } + let status = command.status().await?; + if !status.success() { + return Err(Error::Command(format!("git clone exited with {status}"))); + } + if as_agent { + let status = Command::new("git") + .arg("-C") + .arg(target) + .args(["config", "syncode.asAgent", "true"]) + .status() + .await?; + if !status.success() { + return Err(Error::Command(format!("git config exited with {status}"))); + } + } + Ok(()) +} -- SynCode