fix: Reset inherited Git credential helpers #7
+53
-16
@@ -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()],
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user