fix: Issue user Git credentials #6

Manually merged
day01 merged 1 commits from fix/0.6-user-git-credentials into develop 2026-08-30 17:13:44 +00:00
3 changed files with 43 additions and 18 deletions
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}