fix: Issue user Git credentials #6
+10
-1
@@ -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<String, String>) -> 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<BTreeMap<String, String>> {
|
||||
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<String, String>) -> 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<BTreeMap<String, String>> {
|
||||
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)
|
||||
}
|
||||
+15
-12
@@ -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::<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());
|
||||
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::<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());
|
||||
}
|
||||
}
|
||||
+18
-5
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user