From ffcb7e870cbf1ed3d18aa319475c672d57140a56 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi Date: Wed, 28 Oct 2026 10:11:26 +0100 Subject: [PATCH] Add si repo clone and a git credential helper si repo clone OWNER/NAME runs git clone with a credential helper that points back at si, so the token is never put in the URL or written to disk. Extra arguments after -- go to git. si auth git-credential implements git's helper protocol. On get, for the configured host only, it mints a short-lived git password and prints it. store and erase do nothing, and it prints nothing when nobody is signed in so git moves on to the next helper. si auth setup-git writes the helper into the global git config, like gh auth setup-git. Co-Authored-By: Claude Opus 5.5 --- crates/cli/src/cli.rs | 27 ++++ crates/cli/src/commands/auth.rs | 43 +++++- crates/cli/src/commands/repo.rs | 22 ++- crates/cli/src/credential.rs | 241 ++++++++++++++++++++++++++++++++ crates/cli/src/git.rs | 2 +- crates/cli/src/main.rs | 1 + 6 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 crates/cli/src/credential.rs 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;