diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 182b5dd..2cd82ff 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -76,6 +76,22 @@ pub enum AuthCommand { Logout, /// Show who is currently signed in. Status, + /// Let git use your sigit.si sign-in for clones and fetches, by adding a + /// credential helper to your global git config. + SetupGit, + /// Git's credential helper protocol. Git runs this; you don't need to. + #[command(hide = true)] + GitCredential { + #[arg(value_enum)] + operation: GitCredentialOperation, + }, +} + +#[derive(Clone, Copy, clap::ValueEnum)] +pub enum GitCredentialOperation { + Get, + Store, + Erase, } #[derive(clap::Subcommand)] @@ -107,6 +123,17 @@ pub enum RepoCommand { }, /// Open a repository in your browser. Browse { repo: Option }, + /// Clone a repository with git, signing in through `si` so the token + /// never lands in the URL or on disk. + Clone { + /// OWNER/NAME. + repo: String, + /// Directory to clone into. + dir: Option, + /// Extra arguments for `git clone`, after `--`. + #[arg(last = true)] + git_args: Vec, + }, } #[derive(clap::Subcommand)] diff --git a/crates/cli/src/commands/auth.rs b/crates/cli/src/commands/auth.rs index d019d88..161f6c4 100644 --- a/crates/cli/src/commands/auth.rs +++ b/crates/cli/src/commands/auth.rs @@ -1,7 +1,13 @@ use { - crate::{cli::AuthCommand, config, context::Ctx, ui}, - anyhow::{anyhow, Result}, + crate::{ + cli::{AuthCommand, GitCredentialOperation}, + config, + context::Ctx, + credential, ui, + }, + anyhow::{anyhow, Context, Result}, sigit_si_api::{account::incomplete_reason, models::AccountStatus}, + std::io::{self, Write}, }; pub async fn run(ctx: &Ctx, command: AuthCommand) -> Result<()> { @@ -9,9 +15,42 @@ pub async fn run(ctx: &Ctx, command: AuthCommand) -> Result<()> { AuthCommand::Login { email } => login(ctx, email).await, AuthCommand::Logout => logout(ctx).await, AuthCommand::Status => status(ctx).await, + AuthCommand::SetupGit => setup_git(ctx), + AuthCommand::GitCredential { operation } => git_credential(ctx, operation).await, } } +fn setup_git(ctx: &Ctx) -> Result<()> { + let exe = std::env::current_exe().context("Could not locate the `si` executable.")?; + let base_url = ctx.client.base_url(); + credential::setup_global_helper(base_url, &exe)?; + ui::success(format!( + "Git will now sign in to {} through `si`.", + ui::highlight(base_url) + )); + Ok(()) +} + +/// Answer git's credential protocol. `store` and `erase` do nothing, since the +/// password is minted on demand and never kept. Anything this helper can't +/// answer for stays silent so git moves on to the next helper. +async fn git_credential(ctx: &Ctx, operation: GitCredentialOperation) -> Result<()> { + if !matches!(operation, GitCredentialOperation::Get) { + return Ok(()); + } + + let request = credential::parse_request(io::stdin().lock()); + if ctx.client.token().is_none() || !credential::is_for_host(&request, ctx.client.base_url()) { + return Ok(()); + } + + let minted = ctx.client.git_credential().await?; + let mut stdout = io::stdout().lock(); + stdout.write_all(credential::response(&minted.username, &minted.git_token).as_bytes())?; + stdout.flush()?; + Ok(()) +} + async fn login(ctx: &Ctx, email: Option) -> Result<()> { let email = match email { Some(email) => email, diff --git a/crates/cli/src/commands/repo.rs b/crates/cli/src/commands/repo.rs index 78746f0..03522a0 100644 --- a/crates/cli/src/commands/repo.rs +++ b/crates/cli/src/commands/repo.rs @@ -2,13 +2,25 @@ use { crate::{ cli::RepoCommand, context::Ctx, + credential, + git::RepoSlug, ui::{self, prompt}, }, - anyhow::Result, + anyhow::{Context, Result}, sigit_si_api::NewRepository, }; pub async fn run(ctx: &Ctx, command: RepoCommand) -> Result<()> { + // A public repository clones without signing in. + if let RepoCommand::Clone { + repo, + dir, + git_args, + } = command + { + return clone(ctx, &repo, dir.as_deref(), &git_args); + } + ctx.require_token()?; match command { @@ -22,9 +34,17 @@ pub async fn run(ctx: &Ctx, command: RepoCommand) -> Result<()> { yes, } => create(ctx, name, private, description, yes).await, RepoCommand::Browse { repo } => browse(ctx, repo).await, + RepoCommand::Clone { .. } => unreachable!("handled before the token check"), } } +fn clone(ctx: &Ctx, repo: &str, dir: Option<&str>, git_args: &[String]) -> Result<()> { + let slug = RepoSlug::parse(repo)?; + let exe = std::env::current_exe().context("Could not locate the `si` executable.")?; + let args = credential::clone_args(ctx.client.base_url(), &exe, &slug, dir, git_args); + credential::clone(&args) +} + async fn list(ctx: &Ctx) -> Result<()> { let repos = ctx.client.repos().await?; ui::emit(ctx.json, &repos, |repos| ui::render::repos(repos)) diff --git a/crates/cli/src/credential.rs b/crates/cli/src/credential.rs new file mode 100644 index 0000000..23c1a4a --- /dev/null +++ b/crates/cli/src/credential.rs @@ -0,0 +1,241 @@ +//! Git's credential helper protocol, and the `git` command lines built around +//! it. +//! +//! Git asks a helper for a password by writing `key=value` lines to its stdin, +//! ended by a blank line, and reads `key=value` lines back. `si` answers for +//! the sigit.si host only, with a short-lived token minted on demand, so no +//! credential is ever stored. + +use { + crate::git::{hosts_match, RepoSlug}, + anyhow::{anyhow, Context, Result}, + std::{collections::HashMap, io::BufRead, path::Path, process::Command}, +}; + +/// The attributes of one credential request, e.g. `protocol`, `host`, `path`. +pub type Request = HashMap; + +/// Read a request up to the first blank line, or the end of input. +pub fn parse_request(input: impl BufRead) -> Request { + let mut request = Request::new(); + for line in input.lines().map_while(|line| line.ok()) { + let line = line.trim_end_matches('\r'); + if line.is_empty() { + break; + } + if let Some((key, value)) = line.split_once('=') { + request.insert(key.to_string(), value.to_string()); + } + } + request +} + +/// Whether the request is for `base_url`, such as `https://sigit.si`. A +/// request for another host is none of our business, and a request over a +/// different scheme must not get a token that was meant for the secure one. +pub fn is_for_host(request: &Request, base_url: &str) -> bool { + let Some(host) = request.get("host") else { + return false; + }; + if !hosts_match(host, base_url) { + return false; + } + match (request.get("protocol"), base_url.split_once("://")) { + (Some(protocol), Some((scheme, _))) => protocol.eq_ignore_ascii_case(scheme), + _ => true, + } +} + +/// The reply git expects: two attributes and a blank line. +pub fn response(username: &str, password: &str) -> String { + format!("username={username}\npassword={password}\n\n") +} + +/// `credential..helper`, the config key that scopes a helper to one +/// host. +pub fn helper_key(base_url: &str) -> String { + format!("credential.{base_url}.helper") +} + +/// The helper command git runs, as a shell snippet: git appends the operation. +pub fn helper_command(exe: &Path) -> String { + let exe = exe.to_string_lossy().replace('\'', r"'\''"); + format!("!'{exe}' auth git-credential") +} + +/// The arguments for `git clone`. The leading empty `helper` entry clears any +/// helper inherited from the user's config, so the one that follows is the only +/// one asked. +pub fn clone_args( + base_url: &str, + exe: &Path, + slug: &RepoSlug, + dir: Option<&str>, + extra: &[String], +) -> Vec { + let key = helper_key(base_url); + let mut args = vec![ + "-c".to_string(), + format!("{key}="), + "-c".to_string(), + format!("{key}={}", helper_command(exe)), + "clone".to_string(), + ]; + args.extend(extra.iter().cloned()); + args.push(format!("{base_url}/{}/{}.git", slug.owner, slug.name)); + args.extend(dir.map(str::to_string)); + args +} + +fn git() -> Command { + Command::new("git") +} + +fn not_found(error: std::io::Error) -> anyhow::Error { + if error.kind() == std::io::ErrorKind::NotFound { + anyhow!("`git` was not found on your PATH. Install git and try again.") + } else { + anyhow!("Could not run git: {error}") + } +} + +/// Run `git clone` with the helper in place. +pub fn clone(args: &[String]) -> Result<()> { + let status = git().args(args).status().map_err(not_found)?; + if status.success() { + Ok(()) + } else { + Err(anyhow!("git clone failed ({status}).")) + } +} + +/// Register the helper for `base_url` in the global git config, replacing any +/// earlier `si` entry. The empty entry first keeps a system-wide helper (such +/// as the macOS keychain) from answering for this host as well. +pub fn setup_global_helper(base_url: &str, exe: &Path) -> Result<()> { + let key = helper_key(base_url); + + // Clear what we wrote before; exit code 5 only means there was none. + git() + .args(["config", "--global", "--unset-all", &key]) + .status() + .map_err(not_found)?; + + for value in [String::new(), helper_command(exe)] { + let status = git() + .args(["config", "--global", "--add", &key, &value]) + .status() + .map_err(not_found) + .context("Could not write the global git config.")?; + if !status.success() { + return Err(anyhow!("`git config --global` failed ({status}).")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(input: &str) -> Request { + parse_request(input.as_bytes()) + } + + #[test] + fn a_request_is_read_up_to_the_blank_line() { + let request = request("protocol=https\nhost=sigit.si\n\nignored=yes\n"); + assert_eq!(request["host"], "sigit.si"); + assert_eq!(request["protocol"], "https"); + assert!(!request.contains_key("ignored")); + } + + #[test] + fn a_request_may_end_without_a_blank_line() { + assert_eq!(request("host=sigit.si")["host"], "sigit.si"); + } + + #[test] + fn values_may_contain_equals_signs() { + assert_eq!(request("path=a=b\n")["path"], "a=b"); + } + + #[test] + fn the_configured_host_matches() { + let request = request("protocol=https\nhost=sigit.si\n\n"); + assert!(is_for_host(&request, "https://sigit.si")); + } + + #[test] + fn other_hosts_are_ignored() { + let request = request("protocol=https\nhost=github.com\n\n"); + assert!(!is_for_host(&request, "https://sigit.si")); + } + + #[test] + fn a_request_without_a_host_is_ignored() { + assert!(!is_for_host( + &request("protocol=https\n\n"), + "https://sigit.si" + )); + } + + #[test] + fn a_plain_http_request_does_not_get_a_token_for_an_https_host() { + let request = request("protocol=http\nhost=sigit.si\n\n"); + assert!(!is_for_host(&request, "https://sigit.si")); + } + + #[test] + fn a_dev_host_matches_with_its_port() { + let request = request("protocol=http\nhost=localhost:3000\n\n"); + assert!(is_for_host(&request, "http://localhost:3000")); + } + + #[test] + fn the_response_ends_with_a_blank_line() { + assert_eq!( + response("x-access-token", "abc"), + "username=x-access-token\npassword=abc\n\n" + ); + } + + #[test] + fn the_helper_command_quotes_the_executable() { + let command = helper_command(Path::new("/opt/it's here/si")); + assert_eq!(command, r"!'/opt/it'\''s here/si' auth git-credential"); + } + + #[test] + fn clone_resets_the_helper_before_setting_ours() { + let slug = RepoSlug::parse("seto/demo").unwrap(); + let args = clone_args( + "https://sigit.si", + Path::new("/bin/si"), + &slug, + Some("out"), + &["--depth".to_string(), "1".to_string()], + ); + assert_eq!( + args, + [ + "-c", + "credential.https://sigit.si.helper=", + "-c", + "credential.https://sigit.si.helper=!'/bin/si' auth git-credential", + "clone", + "--depth", + "1", + "https://sigit.si/seto/demo.git", + "out", + ] + ); + } + + #[test] + fn clone_without_a_directory_leaves_the_choice_to_git() { + let slug = RepoSlug::parse("seto/demo").unwrap(); + let args = clone_args("https://sigit.si", Path::new("/bin/si"), &slug, None, &[]); + assert_eq!(args.last().unwrap(), "https://sigit.si/seto/demo.git"); + } +} diff --git a/crates/cli/src/git.rs b/crates/cli/src/git.rs index 7b364c4..75fa892 100644 --- a/crates/cli/src/git.rs +++ b/crates/cli/src/git.rs @@ -133,7 +133,7 @@ fn split_host_and_path(url: &str) -> Option<(String, String)> { /// Compare hosts ignoring the port and a leading `www.`, so a dev remote on /// `localhost:3000` matches a `localhost` base URL and vice versa. -fn hosts_match(a: &str, b: &str) -> bool { +pub fn hosts_match(a: &str, b: &str) -> bool { fn normalize(host: &str) -> String { let host = host.trim().to_ascii_lowercase(); let host = host diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 3b311e5..45dd6d7 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod commands; mod config; mod context; +mod credential; mod git; mod mcp; mod ui;