Merge pull request #2418 from gitbutlerapp/add-fetch-method

initial setup for new fork/exec authentication system
This commit is contained in:
Qix 2024-01-24 03:19:21 +01:00 committed by GitHub
commit 1c043b3196
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 291 additions and 100 deletions

20
Cargo.lock generated
View File

@ -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",

View File

@ -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"] }

View File

@ -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()
}

View File

@ -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):");
}

View File

@ -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);
}

View File

@ -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::<Vec<_>>();
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);
}
}
}

View File

@ -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<BTreeMap<String, String>>,
) -> 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<BTreeMap<String, String>>,
) -> 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
}
}

View File

@ -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<BTreeMap<String, String>>,
) -> 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<String, String>,
}
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<K: AsRef<str>, V: AsRef<str>>(self, key: K, value: V) -> TokioExecutorEnv;
/// Creates a new [`TokioExecutorEnv`] with the given additional environment variables.
fn with_envs<K: AsRef<str>, V: AsRef<str>, I: IntoIterator<Item = (K, V)>>(
self,
envs: I,
) -> TokioExecutorEnv;
}
impl WithEnv for TokioExecutor {
fn with_env<K: AsRef<str>, V: AsRef<str>>(self, key: K, value: V) -> TokioExecutorEnv {
TokioExecutorEnv {
env: [(key.as_ref().into(), value.as_ref().into())]
.iter()
.cloned()
.collect(),
}
}
fn with_envs<K: AsRef<str>, V: AsRef<str>, I: IntoIterator<Item = (K, V)>>(
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<K: AsRef<str>, V: AsRef<str>>(mut self, key: K, value: V) -> TokioExecutorEnv {
self.env.insert(key.as_ref().into(), value.as_ref().into());
self
}
fn with_envs<K: AsRef<str>, V: AsRef<str>, I: IntoIterator<Item = (K, V)>>(
mut self,
envs: I,
) -> TokioExecutorEnv {
self.env.extend(
envs.into_iter()
.map(|(k, v)| (k.as_ref().into(), v.as_ref().into())),
);
self
}
}

View File

@ -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<E: core::error::Error + core::fmt::Debug + Send + Sync + 'static>
"git command exited with non-zero exit code {0}: {1:?}\n\nSTDOUT:\n{2}\n\nSTDERR:\n{3}"
)]
Failed(usize, Vec<String>, 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<E: GitExecutor> Repository<E> {
/// Note that this **does not** check if the repository exists,
/// but assumes it does.
#[inline]
pub fn open_unchecked<P: AsRef<Path>>(exec: E, path: P) -> Self {
pub fn open_unchecked<P: AsRef<str>>(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<P: AsRef<Path>>(exec: E, path: P) -> Result<Self, Error<E::Error>> {
let path = path.as_ref().to_str().unwrap().to_string();
pub async fn open_or_init<P: AsRef<str>>(exec: E, path: P) -> Result<Self, Error<E::Error>> {
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<E: GitExecutor> Repository<E> {
))
}
}
async fn execute_with_auth_harness(
&self,
args: &[&str],
envs: Option<BTreeMap<String, String>>,
) -> Result<(usize, String, String), Error<E::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::<String>();
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<E: GitExecutor + 'static> crate::Repository for Repository<E> {
@ -81,7 +143,8 @@ impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
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<E: GitExecutor + 'static> crate::Repository for Repository<E> {
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(())

View File

@ -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)]

View File

@ -5,3 +5,7 @@ pub use alloc::{
vec,
vec::Vec,
};
#[cfg(feature = "std")]
#[allow(unused_imports)]
pub use std::collections::BTreeMap;

View File

@ -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})")]

View File

@ -0,0 +1,9 @@
pub enum Authorization {
Basic {
pub username: String,
pub password: String,
},
PublicKey {
pub path: PathBuf
}
}