diff --git a/Cargo.lock b/Cargo.lock index b8cc34e17..4ad32fc3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1876,6 +1876,8 @@ name = "gitbutler-git" version = "0.0.0" dependencies = [ "git2", + "nix 0.27.1", + "rand 0.8.5", "serde", "thiserror", "tokio", @@ -2881,7 +2883,7 @@ dependencies = [ "combine", "libc", "mach2", - "nix", + "nix 0.26.4", "sysctl", "thiserror", "widestring", @@ -2953,6 +2955,18 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.4.0", + "cfg-if", + "libc", + "memoffset 0.9.0", +] + [[package]] name = "no-std-compat" version = "0.4.1" @@ -6531,7 +6545,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2769203cd13a0c6015d515be729c526d041e9cf2c0cc478d57faee85f40c6dcd" dependencies = [ - "nix", + "nix 0.26.4", "winapi", ] @@ -6565,7 +6579,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.26.4", "once_cell", "ordered-stream", "rand 0.8.5", diff --git a/gitbutler-git/Cargo.toml b/gitbutler-git/Cargo.toml index a97add921..6a2963e7e 100644 --- a/gitbutler-git/Cargo.toml +++ b/gitbutler-git/Cargo.toml @@ -3,9 +3,22 @@ name = "gitbutler-git" version = "0.0.0" edition = "2021" +[lib] +path = "src/lib.rs" + +[[bin]] +name = "gitbutler-git-askpass" +path = "src/backend/cli/bin/askpass.rs" +required-features = ["cli"] + +[[bin]] +name = "gitbutler-git-setsid" +path = "src/backend/cli/bin/setsid.rs" +required-features = ["cli"] + [features] default = ["git2", "cli", "serde", "tokio"] -cli = ["std"] +cli = ["std", "dep:nix", "dep:rand"] git2 = ["dep:git2", "std"] serde = ["dep:serde"] std = ["dep:thiserror"] @@ -16,6 +29,10 @@ git2 = { workspace = true, optional = true } thiserror = { workspace = true, optional = true } serde = { workspace = true, optional = true } tokio = { workspace = true, optional = true, features = ["process"]} +rand = { version = "0.8.5", optional = true } [dev-dependencies] tokio = { workspace = true, features = ["rt", "rt-multi-thread", "process"]} + +[target."cfg(unix)".dependencies] +nix = { version = "0.27.1", optional = true, features = ["process", "socket"] } diff --git a/gitbutler-git/src/backend/cli.rs b/gitbutler-git/src/backend/cli.rs index b2215ceb6..f4b26a9c0 100644 --- a/gitbutler-git/src/backend/cli.rs +++ b/gitbutler-git/src/backend/cli.rs @@ -22,7 +22,7 @@ mod tests { .join(test_name); let _ = std::fs::remove_dir_all(&repo_path); std::fs::create_dir_all(&repo_path).unwrap(); - Repository::open_or_init(executor::tokio::TokioExecutor, repo_path) + Repository::open_or_init(executor::tokio::TokioExecutor, repo_path.to_str().unwrap()) .await .unwrap() } diff --git a/gitbutler-git/src/backend/cli/bin/askpass-unix.rs b/gitbutler-git/src/backend/cli/bin/askpass-unix.rs new file mode 100644 index 000000000..a08e27275 --- /dev/null +++ b/gitbutler-git/src/backend/cli/bin/askpass-unix.rs @@ -0,0 +1,36 @@ +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; + +pub fn main(sock_path: &str, secret: &str, prompt: &str) { + let mut stream = UnixStream::connect(sock_path).expect("connect():"); + + // Set a timer for 10s. + stream + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .expect("set_read_timeout():"); + + // Write the secret. + stream + .write_all(secret.as_bytes()) + .expect("write_all(secret):"); + + // Write the prompt that Git gave us. + stream + .write_all(prompt.as_bytes()) + .expect("write_all(prompt):"); + + // Wait for the response. + let mut buf = [0; 2048]; + let n = stream.read(&mut buf).expect("read():"); + + // TODO(qix-): Figure out a way to do a single timeout + // TODO(qix-): but allow any response size. + if n == buf.len() { + panic!("response too long"); + } + + // Write the response back to Git. + std::io::stdout() + .write_all(&buf[..n]) + .expect("write_all(stdout):"); +} diff --git a/gitbutler-git/src/backend/cli/bin/askpass.rs b/gitbutler-git/src/backend/cli/bin/askpass.rs new file mode 100644 index 000000000..86027d0cf --- /dev/null +++ b/gitbutler-git/src/backend/cli/bin/askpass.rs @@ -0,0 +1,15 @@ +#[cfg(not(target_os = "windows"))] +#[path = "askpass-unix.rs"] +mod unix; + +#[cfg(target_os = "windows")] +compile_error!("Windows support is not yet implemented."); + +pub fn main() { + let pipe_name = std::env::var("GITBUTLER_ASKPASS_PIPE").expect("do not run this binary yourself; it's only meant to be run by GitButler (missing GITBUTLER_ASKPASS_PIPE env var)"); + let pipe_secret = std::env::var("GITBUTLER_ASKPASS_SECRET").expect("do not run this binary yourself; it's only meant to be run by GitButler (missing GITBUTLER_ASKPASS_SECRET env var)"); + let prompt = std::env::args().nth(1).expect("do not run this binary yourself; it's only meant to be run by GitButler (missing prompt arg)"); + + #[cfg(not(target_os = "windows"))] + unix::main(&pipe_name, &pipe_secret, &prompt); +} diff --git a/gitbutler-git/src/backend/cli/bin/setsid.rs b/gitbutler-git/src/backend/cli/bin/setsid.rs new file mode 100644 index 000000000..31c923f76 --- /dev/null +++ b/gitbutler-git/src/backend/cli/bin/setsid.rs @@ -0,0 +1,54 @@ +#[cfg(not(target_os = "windows"))] +use nix::{ + libc::{c_int, wait, EXIT_FAILURE, WEXITSTATUS, WIFEXITED, WIFSIGNALED, WTERMSIG}, + unistd::{fork, setsid, ForkResult}, +}; +use std::{os::unix::process::CommandExt, process}; + +#[cfg(target_os = "windows")] +pub fn main() { + panic!("This binary is only meant to be run on Unix-like systems. It exists on Windows only because Cargo cannot switch off bins based on target platform."); +} + +#[cfg(not(target_os = "windows"))] +pub fn main() { + let has_pipe_var = std::env::var("GITBUTLER_ASKPASS_PIPE") + .map(|v| v != "") + .unwrap_or(false); + if !has_pipe_var { + panic!("This binary is only meant to be run by GitButler; please do not use it yourself as it's entirely unstable."); + } + + let args = std::env::args().skip(1).collect::>(); + + match unsafe { fork() }.unwrap() { + ForkResult::Parent { child, .. } => { + let mut status: c_int = 0; + + let waited_pid = unsafe { wait(&mut status as *mut _) }; + if waited_pid != child.as_raw() { + panic!( + "wait(): unexpected child process; got {}, expected {}", + waited_pid, child + ); + } + + if WIFEXITED(status) { + let exit_status = WEXITSTATUS(status); + process::exit(exit_status); + } else if WIFSIGNALED(status) { + let signal = WTERMSIG(status); + process::exit(128 + signal); + } else { + process::exit(EXIT_FAILURE); + } + } + ForkResult::Child => { + setsid().expect("setsid():"); + + let err = process::Command::new(&args[0]).args(&args[1..]).exec(); + + panic!("exec(): {}", err); + } + } +} diff --git a/gitbutler-git/src/backend/cli/executor.rs b/gitbutler-git/src/backend/cli/executor.rs index 47266367f..d7a2c66b6 100644 --- a/gitbutler-git/src/backend/cli/executor.rs +++ b/gitbutler-git/src/backend/cli/executor.rs @@ -1,3 +1,5 @@ +use crate::prelude::*; + #[cfg(any(test, feature = "tokio"))] pub mod tokio; @@ -21,5 +23,32 @@ pub trait GitExecutor { /// /// `Err` is returned if the command could not be executed, /// **not** if the command returned a non-zero exit code. - async fn execute(&self, args: &[&str]) -> Result<(usize, String, String), Self::Error>; + async fn execute_raw( + &self, + args: &[&str], + envs: Option>, + ) -> Result<(usize, String, String), Self::Error>; + + /// Executes the given Git command with sane defaults. + /// `git` is never passed as the first argument (arg 0). + /// + /// Implementers should use this method over [`Self::execute_raw`] + /// when possible. + async fn execute( + &self, + args: &[&str], + envs: Option>, + ) -> Result<(usize, String, String), Self::Error> { + let mut args = args.as_ref().to_vec(); + + args.insert(0, "--no-pager"); + // TODO(qix-): Test the performance impact of this. + args.insert(0, "--no-optional-locks"); + + let mut envs = envs.unwrap_or_default(); + envs.insert("GIT_TERMINAL_PROMPT".into(), "0".into()); + envs.insert("LC_ALL".into(), "C".into()); // Force English. We need this for parsing output. + + self.execute_raw(&args, Some(envs)).await + } } diff --git a/gitbutler-git/src/backend/cli/executor/tokio.rs b/gitbutler-git/src/backend/cli/executor/tokio.rs index f6638a909..78034f0da 100644 --- a/gitbutler-git/src/backend/cli/executor/tokio.rs +++ b/gitbutler-git/src/backend/cli/executor/tokio.rs @@ -1,6 +1,6 @@ //! A [Tokio](https://tokio.rs)-based [`GitExecutor`] implementation. -use std::collections::HashMap; +use crate::prelude::*; use tokio::process::Command; /// A [`GitExecutor`] implementation using the `git` command-line tool @@ -10,9 +10,16 @@ pub struct TokioExecutor; impl super::GitExecutor for TokioExecutor { type Error = std::io::Error; - async fn execute(&self, args: &[&str]) -> Result<(usize, String, String), Self::Error> { + async fn execute_raw( + &self, + args: &[&str], + envs: Option>, + ) -> Result<(usize, String, String), Self::Error> { let mut cmd = Command::new("git"); cmd.args(args); + if let Some(envs) = envs { + cmd.envs(envs); + } let output = cmd.output().await?; @@ -23,82 +30,3 @@ impl super::GitExecutor for TokioExecutor { )) } } - -/// A [`GitExecutor`] implementation using the `git` command-line tool -/// via [`tokio::process::Command`], with the given environment variables. -pub struct TokioExecutorEnv { - env: HashMap, -} - -impl super::GitExecutor for TokioExecutorEnv { - type Error = std::io::Error; - - async fn execute(&self, args: &[&str]) -> Result<(usize, String, String), Self::Error> { - let mut cmd = Command::new("git"); - cmd.args(args); - cmd.envs(&self.env); - - let output = cmd.output().await?; - - Ok(( - output.status.code().unwrap_or(127) as usize, - String::from_utf8_lossy(&output.stdout).trim().into(), - String::from_utf8_lossy(&output.stderr).trim().into(), - )) - } -} - -/// Allows executors to create (or modify) a [`TokioExecutorEnv`], -/// with added/modified environment variables, set for each execution -/// of `git`. -pub trait WithEnv: Sized { - /// Sets the given environment variable. - fn with_env, V: AsRef>(self, key: K, value: V) -> TokioExecutorEnv; - - /// Creates a new [`TokioExecutorEnv`] with the given additional environment variables. - fn with_envs, V: AsRef, I: IntoIterator>( - self, - envs: I, - ) -> TokioExecutorEnv; -} - -impl WithEnv for TokioExecutor { - fn with_env, V: AsRef>(self, key: K, value: V) -> TokioExecutorEnv { - TokioExecutorEnv { - env: [(key.as_ref().into(), value.as_ref().into())] - .iter() - .cloned() - .collect(), - } - } - - fn with_envs, V: AsRef, I: IntoIterator>( - self, - envs: I, - ) -> TokioExecutorEnv { - TokioExecutorEnv { - env: envs - .into_iter() - .map(|(k, v)| (k.as_ref().into(), v.as_ref().into())) - .collect(), - } - } -} - -impl WithEnv for TokioExecutorEnv { - fn with_env, V: AsRef>(mut self, key: K, value: V) -> TokioExecutorEnv { - self.env.insert(key.as_ref().into(), value.as_ref().into()); - self - } - - fn with_envs, V: AsRef, I: IntoIterator>( - mut self, - envs: I, - ) -> TokioExecutorEnv { - self.env.extend( - envs.into_iter() - .map(|(k, v)| (k.as_ref().into(), v.as_ref().into())), - ); - self - } -} diff --git a/gitbutler-git/src/backend/cli/repository.rs b/gitbutler-git/src/backend/cli/repository.rs index 90ee0242a..7b4724e7f 100644 --- a/gitbutler-git/src/backend/cli/repository.rs +++ b/gitbutler-git/src/backend/cli/repository.rs @@ -1,9 +1,10 @@ -//! NOTE: Doesn't support `no_std` yet. - -use std::path::Path; - use super::executor::GitExecutor; -use crate::ConfigScope; +use crate::{prelude::*, ConfigScope}; +use rand::Rng; + +/// The number of characters in the secret used for checking +/// askpass invocations by ssh/git when connecting to our process. +const ASKPASS_SECRET_LENGTH: usize = 24; /// Higher level errors that can occur when interacting with the CLI. #[derive(Debug, thiserror::Error)] @@ -14,6 +15,8 @@ pub enum Error "git command exited with non-zero exit code {0}: {1:?}\n\nSTDOUT:\n{2}\n\nSTDERR:\n{3}" )] Failed(usize, Vec, String, String), + #[error("failed to determine path to this executable: {0}")] + NoSelfExe(std::io::Error), } /// A [`crate::Repository`] implementation using the `git` CLI @@ -29,20 +32,20 @@ impl Repository { /// Note that this **does not** check if the repository exists, /// but assumes it does. #[inline] - pub fn open_unchecked>(exec: E, path: P) -> Self { + pub fn open_unchecked>(exec: E, path: P) -> Self { Self { exec, - path: path.as_ref().to_str().unwrap().to_string(), + path: path.as_ref().to_owned(), } } /// (Re-)initializes a repository at the given path /// using the given [`GitExecutor`]. - pub async fn open_or_init>(exec: E, path: P) -> Result> { - let path = path.as_ref().to_str().unwrap().to_string(); + pub async fn open_or_init>(exec: E, path: P) -> Result> { + let path = path.as_ref().to_owned(); let args = vec!["init", "--quiet", &path]; - let (exit_code, stdout, stderr) = exec.execute(&args).await.map_err(Error::Exec)?; + let (exit_code, stdout, stderr) = exec.execute(&args, None).await.map_err(Error::Exec)?; if exit_code == 0 { Ok(Self { exec, path }) @@ -55,6 +58,65 @@ impl Repository { )) } } + + async fn execute_with_auth_harness( + &self, + args: &[&str], + envs: Option>, + ) -> Result<(usize, String, String), Error> { + let path = std::env::current_exe().map_err(|e| Error::NoSelfExe(e))?; + let our_pid = std::process::id(); + + let askpath_path = path.with_file_name("gitbutler-git-askpass"); + #[cfg(not(target_os = "windows"))] + let setsid_path = path.with_file_name("gitbutler-git-setsid"); + + let sock_path = std::env::temp_dir().join(format!("gitbutler-git-{our_pid}.sock")); + + // FIXME(qix-): This is probably not cryptographically secure, did this in a bit + // FIXME(qix-): of a hurry. We should probably use a proper CSPRNG here, but this + // FIXME(qix-): is probably fine for now (as this security mechanism is probably + // FIXME(qix-): overkill to begin with). + let secret = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(ASKPASS_SECRET_LENGTH) + .map(char::from) + .collect::(); + + let mut envs = envs.unwrap_or_default(); + envs.insert( + "GITBUTLER_ASKPASS_PIPE".into(), + sock_path.to_string_lossy().into_owned(), + ); + envs.insert("GITBUTLER_ASKPASS_SECRET".into(), secret.clone()); + envs.insert( + "SSH_ASKPASS".into(), + askpath_path.to_string_lossy().into_owned(), + ); + + // DISPLAY is required by SSH to check SSH_ASKPASS. + // Please don't ask us why, it's unclear. + if !std::env::var("DISPLAY").map(|v| v != "").unwrap_or(false) { + envs.insert("DISPLAY".into(), ":".into()); + } + + #[cfg(not(target_os = "windows"))] + envs.insert( + "GIT_SSH_COMMAND".into(), + format!( + "{} {}", + setsid_path.to_string_lossy(), + envs.get("GIT_SSH_COMMAND").unwrap_or(&"ssh".into()) + ), + ); + + // TODO(qix-): implement the actual socket server code (right now this won't work) + + self.exec + .execute(args, Some(envs)) + .await + .map_err(Error::Exec) + } } impl crate::Repository for Repository { @@ -81,7 +143,8 @@ impl crate::Repository for Repository { args.push(key); - let (exit_code, stdout, stderr) = self.exec.execute(&args).await.map_err(Error::Exec)?; + let (exit_code, stdout, stderr) = + self.exec.execute(&args, None).await.map_err(Error::Exec)?; if exit_code == 0 { Ok(Some(stdout)) @@ -120,7 +183,8 @@ impl crate::Repository for Repository { args.push(key); args.push(value); - let (exit_code, stdout, stderr) = self.exec.execute(&args).await.map_err(Error::Exec)?; + let (exit_code, stdout, stderr) = + self.exec.execute(&args, None).await.map_err(Error::Exec)?; if exit_code == 0 { Ok(()) diff --git a/gitbutler-git/src/lib.rs b/gitbutler-git/src/lib.rs index dd7190613..fed8f439e 100644 --- a/gitbutler-git/src/lib.rs +++ b/gitbutler-git/src/lib.rs @@ -3,6 +3,26 @@ //! This library houses a number of Git implementations, //! over which we abstract a common interface and provide //! higher-level operations that are implementation-agnostic. +//! +//! # Libgit2 Support +//! This library supports libgit2 via the `git2` feature. +//! Not much in the way of assumptions are made about the environment; +//! it's a fairly clean and safe Git backend. +//! +//! # Fork/Exec (CLI) Support +//! This library supports the Git CLI via the `cli` feature. +//! Note that this is a fairly experimental implementation that +//! uses some (ideally portable) hacks for authentication, +//! including a custom executable (or two, in the case of +//! *nix systems) for handling automatic authentication +//! via the API. +//! +//! This means those executables must be situated next to +//! the executable that is running them (as sibling files), +//! for security purposes. They may not be symlinked. +//! +//! This hampers certain use cases, such as implementing +//! [`cli::GitExecutor`] for e.g. remote connections. #![cfg_attr(not(feature = "std"), no_std)] // must be first #![feature(error_in_core)] diff --git a/gitbutler-git/src/prelude.rs b/gitbutler-git/src/prelude.rs index 5720756d3..d8988a708 100644 --- a/gitbutler-git/src/prelude.rs +++ b/gitbutler-git/src/prelude.rs @@ -5,3 +5,7 @@ pub use alloc::{ vec, vec::Vec, }; + +#[cfg(feature = "std")] +#[allow(unused_imports)] +pub use std::collections::BTreeMap; diff --git a/gitbutler-git/src/refspec.rs b/gitbutler-git/src/refspec.rs index f2f764c73..b87490aef 100644 --- a/gitbutler-git/src/refspec.rs +++ b/gitbutler-git/src/refspec.rs @@ -1,7 +1,8 @@ use core::fmt; /// An error that can occur while parsing a refspec from a string. -#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)] +#[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "std", derive(thiserror::Error))] pub enum Error { /// Encountered an unexpected character when parsing a [`RefSpec`] from a string. #[error("unexpected character {0:?} (offset {1})")] diff --git a/gitbutler-git/src/remote.rs b/gitbutler-git/src/remote.rs new file mode 100644 index 000000000..84d45e7f2 --- /dev/null +++ b/gitbutler-git/src/remote.rs @@ -0,0 +1,9 @@ +pub enum Authorization { + Basic { + pub username: String, + pub password: String, + }, + PublicKey { + pub path: PathBuf + } +}