add initial complete implementation of fork/exec auth harness

This commit is contained in:
Josh Junon 2024-01-25 22:09:40 +01:00 committed by GitButler
parent 79629e9ca6
commit 5f157b26ec
5 changed files with 228 additions and 32 deletions

45
Cargo.lock generated
View File

@ -1875,10 +1875,12 @@ dependencies = [
name = "gitbutler-git"
version = "0.0.0"
dependencies = [
"futures",
"git2",
"nix 0.27.1",
"rand 0.8.5",
"serde",
"sysinfo",
"thiserror",
"tokio",
]
@ -3028,6 +3030,15 @@ dependencies = [
"walkdir",
]
[[package]]
name = "ntapi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@ -4988,6 +4999,21 @@ dependencies = [
"walkdir",
]
[[package]]
name = "sysinfo"
version = "0.30.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fb4f3438c8f6389c864e61221cbc97e9bca98b4daf39a5beb7bea660f528bb2"
dependencies = [
"cfg-if",
"core-foundation-sys",
"libc",
"ntapi",
"once_cell",
"rayon",
"windows 0.52.0",
]
[[package]]
name = "system-configuration"
version = "0.5.1"
@ -6176,6 +6202,16 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core",
"windows-targets 0.52.0",
]
[[package]]
name = "windows-bindgen"
version = "0.39.0"
@ -6186,6 +6222,15 @@ dependencies = [
"windows-tokens",
]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets 0.52.0",
]
[[package]]
name = "windows-implement"
version = "0.39.0"

View File

@ -18,7 +18,7 @@ required-features = ["cli"]
[features]
default = ["git2", "cli", "serde", "tokio"]
cli = ["std", "dep:nix", "dep:rand"]
cli = ["std", "dep:nix", "dep:rand", "dep:futures", "dep:sysinfo"]
git2 = ["dep:git2", "std"]
serde = ["dep:serde"]
std = ["dep:thiserror"]
@ -30,6 +30,8 @@ thiserror = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
tokio = { workspace = true, optional = true, features = ["process", "rt", "process", "time", "io-util", "net", "fs"]}
rand = { version = "0.8.5", optional = true }
futures = { version = "0.3.30", optional = true }
sysinfo = { version = "0.30.5", optional = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread"]}

View File

@ -52,6 +52,9 @@ pub unsafe trait GitExecutor {
///
/// Returns a tuple of `(exit_code, stdout, stderr)`.
///
/// To the best of their abilities, child processes should
/// be killed if the future is dropped.
///
/// `Err` is returned if the command could not be executed,
/// **not** if the command returned a non-zero exit code.
async fn execute_raw(
@ -130,7 +133,7 @@ pub unsafe trait GitExecutor {
///
/// If for some reason these invariants are not possible to uphold,
/// please open an issue on the repository to discuss this issue.
async unsafe fn create_askpass_server<F>(&self) -> Result<Self::ServerHandle, Self::Error>;
async unsafe fn create_askpass_server(&self) -> Result<Self::ServerHandle, Self::Error>;
/// Gets some basic information about a file on the filesystem.
///
@ -170,14 +173,14 @@ pub struct FileStat {
///
/// Upon dropping the handle, the server should be closed.
pub trait AskpassServer: core::fmt::Display {
/// The type of error that is returned by [`AskpassServer::next`].
/// The type of error that is returned by [`AskpassServer::accept`].
type Error: core::error::Error + core::fmt::Debug + Send + Sync + 'static;
/// The type of the socket yielded by the incoming iterator.
type SocketHandle: Socket + Send + Sync + 'static;
/// Waits for a connection to the server to be established.
async fn next(&self, timeout: Option<Duration>) -> Result<Self::SocketHandle, Self::Error>;
async fn accept(&self, timeout: Option<Duration>) -> Result<Self::SocketHandle, Self::Error>;
}
#[cfg(unix)]

View File

@ -22,7 +22,10 @@ unsafe impl super::GitExecutor for TokioExecutor {
envs: Option<BTreeMap<String, String>>,
) -> Result<(usize, String, String), Self::Error> {
let mut cmd = Command::new("git");
cmd.kill_on_drop(true);
cmd.args(args);
if let Some(envs) = envs {
cmd.envs(envs);
}
@ -37,7 +40,7 @@ unsafe impl super::GitExecutor for TokioExecutor {
}
#[cfg(unix)]
async unsafe fn create_askpass_server<F>(&self) -> Result<Self::ServerHandle, Self::Error> {
async unsafe fn create_askpass_server(&self) -> Result<Self::ServerHandle, Self::Error> {
let connection_string =
std::env::temp_dir().join(format!("gitbutler-askpass-{}", rand::random::<u64>()));
@ -109,7 +112,7 @@ impl super::AskpassServer for TokioAskpassServer {
#[cfg(unix)]
type SocketHandle = tokio::io::BufStream<tokio::net::UnixStream>;
async fn next(&self, timeout: Option<Duration>) -> Result<Self::SocketHandle, Self::Error> {
async fn accept(&self, timeout: Option<Duration>) -> Result<Self::SocketHandle, Self::Error> {
let res = if let Some(timeout) = timeout {
tokio::time::timeout(timeout, self.server.as_ref().unwrap().accept()).await?
} else {

View File

@ -1,5 +1,7 @@
use super::executor::GitExecutor;
use crate::{prelude::*, ConfigScope};
use super::executor::{AskpassServer, GitExecutor, Pid, Socket};
use crate::{prelude::*, Authorization, ConfigScope};
use core::time::Duration;
use futures::{select, FutureExt};
use rand::Rng;
/// The number of characters in the secret used for checking
@ -7,18 +9,50 @@ use rand::Rng;
const ASKPASS_SECRET_LENGTH: usize = 24;
/// Higher level errors that can occur when interacting with the CLI.
///
/// You probably don't want to use this type. Use [`Error`] instead.
#[derive(Debug, thiserror::Error)]
pub enum Error<E: core::error::Error + core::fmt::Debug + Send + Sync + 'static> {
pub enum RepositoryError<
Eexec: core::error::Error + core::fmt::Debug + Send + Sync + 'static,
Easkpass: core::error::Error + core::fmt::Debug + Send + Sync + 'static,
Esocket: core::error::Error + core::fmt::Debug + Send + Sync + 'static,
> {
#[error("failed to execute git command: {0}")]
Exec(E),
Exec(Eexec),
#[error("failed to create askpass server: {0}")]
AskpassServer(Easkpass),
#[error("i/o error communicating with askpass utility: {0}")]
AskpassIo(Esocket),
#[error(
"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),
#[error("askpass secret mismatch")]
AskpassSecretMismatch,
#[error("git requires authorization credentials but none were provided: prompt was {0:?}")]
NeedsAuthorization(String),
#[error("unable to determine PID of askpass peer: {0}")]
NoPid(Esocket),
#[cfg(unix)]
#[error("unable to determine UID of askpass peer: {0}")]
NoUid(Esocket),
#[error("failed to perform askpass security check; no such PID: {0}")]
NoSuchPid(Pid),
#[error("failed to perform askpass security check; device mismatch")]
AskpassDeviceMismatch,
#[error("failed to perform askpass security check; executable mismatch")]
AskpassExecutableMismatch,
}
/// Higher level errors that can occur when interacting with the CLI.
pub type Error<E> = RepositoryError<
<E as GitExecutor>::Error,
<<E as GitExecutor>::ServerHandle as AskpassServer>::Error,
<<<E as GitExecutor>::ServerHandle as AskpassServer>::SocketHandle as Socket>::Error,
>;
/// A [`crate::Repository`] implementation using the `git` CLI
/// and the given [`GitExecutor`] implementation.
pub struct Repository<E: GitExecutor> {
@ -41,16 +75,17 @@ impl<E: GitExecutor> Repository<E> {
/// (Re-)initializes a repository at the given path
/// using the given [`GitExecutor`].
pub async fn open_or_init<P: AsRef<str>>(exec: E, path: P) -> Result<Self, Error<E::Error>> {
pub async fn open_or_init<P: AsRef<str>>(exec: E, path: P) -> Result<Self, Error<E>> {
let path = path.as_ref().to_owned();
let args = vec!["init", "--quiet", &path];
let (exit_code, stdout, stderr) = exec.execute(&args, None).await.map_err(Error::Exec)?;
let (exit_code, stdout, stderr) =
exec.execute(&args, None).await.map_err(Error::<E>::Exec)?;
if exit_code == 0 {
Ok(Self { exec, path })
} else {
Err(Error::Failed(
Err(Error::<E>::Failed(
exit_code,
args.into_iter().map(Into::into).collect(),
stdout,
@ -63,13 +98,32 @@ impl<E: GitExecutor> Repository<E> {
&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))?;
authorization: &Authorization,
) -> Result<(usize, String, String), Error<E>> {
let path = std::env::current_exe().map_err(|e| Error::<E>::NoSelfExe(e))?;
let our_pid = std::process::id();
let askpath_path = path.with_file_name("gitbutler-git-askpass");
let askpath_path = path
.with_file_name("gitbutler-git-askpass")
.to_string_lossy()
.into_owned();
#[cfg(not(target_os = "windows"))]
let setsid_path = path.with_file_name("gitbutler-git-setsid");
let setsid_path = path
.with_file_name("gitbutler-git-setsid")
.to_string_lossy()
.into_owned();
let askpath_stat = self
.exec
.stat(&askpath_path)
.await
.map_err(Error::<E>::Exec)?;
#[cfg(not(target_os = "windows"))]
let setsid_stat = self
.exec
.stat(&setsid_path)
.await
.map_err(Error::<E>::Exec)?;
let sock_path = std::env::temp_dir().join(format!("gitbutler-git-{our_pid}.sock"));
@ -89,10 +143,7 @@ impl<E: GitExecutor> Repository<E> {
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(),
);
envs.insert("SSH_ASKPASS".into(), askpath_path);
// DISPLAY is required by SSH to check SSH_ASKPASS.
// Please don't ask us why, it's unclear.
@ -105,22 +156,108 @@ impl<E: GitExecutor> Repository<E> {
"GIT_SSH_COMMAND".into(),
format!(
"{} {}",
setsid_path.to_string_lossy(),
setsid_path,
envs.get("GIT_SSH_COMMAND").unwrap_or(&"ssh".into())
),
);
// TODO(qix-): implement the actual socket server code (right now this won't work)
if let Authorization::Ssh { private_key, .. } = authorization {
if let Some(private_key) = private_key {
envs.insert("GIT_SSH_VARIANT".into(), "ssh".into());
envs.insert("GIT_SSH_KEY".into(), private_key.clone());
}
}
self.exec
#[allow(unsafe_code)]
let sock_server = unsafe { self.exec.create_askpass_server() }
.await
.map_err(Error::<E>::Exec)?;
let mut child_process = core::pin::pin! {async {
self.exec
.execute(args, Some(envs))
.await
.map_err(Error::Exec)
.map_err(Error::<E>::Exec)
}.fuse()};
loop {
select! {
res = sock_server.accept(Some(Duration::from_secs(60))).fuse() => {
let mut sock = res.map_err(Error::<E>::AskpassServer)?;
// get the PID of the peer
let peer_pid = sock.pid().map_err(Error::<E>::NoPid)?;
// get the full image path of the peer id; this is pretty expensive at the moment.
// TODO(qix-): see if dropping sysinfo for a more bespoke implementation is worth it.
let mut system = sysinfo::System::new();
system.refresh_processes();
let peer_path = system
.process(sysinfo::Pid::from_u32(peer_pid.try_into().map_err(|_| Error::<E>::NoSuchPid(peer_pid))?))
.and_then(|p| p.exe().map(|exe| exe.to_string_lossy().into_owned()))
.ok_or(Error::<E>::NoSuchPid(peer_pid))?;
// stat the askpass executable that is being invoked
let peer_stat = self.exec.stat(&peer_path).await.map_err(Error::<E>::Exec)?;
if peer_stat.ino == askpath_stat.ino {
if peer_stat.dev != askpath_stat.dev {
return Err(Error::<E>::AskpassDeviceMismatch);
}
} else if peer_stat.ino == setsid_stat.ino {
if peer_stat.dev != setsid_stat.dev {
return Err(Error::<E>::AskpassDeviceMismatch);
}
} else {
return Err(Error::<E>::AskpassExecutableMismatch);
}
// await for peer to send secret
let peer_secret = sock.read_line().await.map_err(Error::<E>::AskpassIo)?;
// check the secret
if peer_secret.trim() != secret {
return Err(Error::<E>::AskpassSecretMismatch);
}
// get the prompt
let prompt = sock.read_line().await.map_err(Error::<E>::AskpassIo)?;
match authorization {
Authorization::Auto => {
return Err(Error::<E>::NeedsAuthorization(prompt));
}
Authorization::Basic{username, password} => {
if prompt.contains("Username for") {
sock.write_line(username).await.map_err(Error::<E>::AskpassIo)?;
} else if prompt.contains("Password for") {
sock.write_line(password).await.map_err(Error::<E>::AskpassIo)?;
} else {
return Err(Error::<E>::NeedsAuthorization(prompt));
}
},
Authorization::Ssh { passphrase, .. } => {
if let Some(passphrase) = passphrase {
if prompt.contains("passphrase for key") {
sock.write_line(passphrase).await.map_err(Error::<E>::AskpassIo)?;
continue;
}
}
return Err(Error::<E>::NeedsAuthorization(prompt));
}
}
},
res = child_process => {
return res;
}
}
}
}
}
impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
type Error = Error<E::Error>;
type Error = Error<E>;
async fn config_get(
&self,
@ -143,15 +280,18 @@ impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
args.push(key);
let (exit_code, stdout, stderr) =
self.exec.execute(&args, None).await.map_err(Error::Exec)?;
let (exit_code, stdout, stderr) = self
.exec
.execute(&args, None)
.await
.map_err(Error::<E>::Exec)?;
if exit_code == 0 {
Ok(Some(stdout))
} else if exit_code == 1 && stderr.is_empty() {
Ok(None)
} else {
Err(Error::Failed(
Err(Error::<E>::Failed(
exit_code,
args.into_iter().map(Into::into).collect(),
stdout,
@ -183,13 +323,16 @@ impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
args.push(key);
args.push(value);
let (exit_code, stdout, stderr) =
self.exec.execute(&args, None).await.map_err(Error::Exec)?;
let (exit_code, stdout, stderr) = self
.exec
.execute(&args, None)
.await
.map_err(Error::<E>::Exec)?;
if exit_code == 0 {
Ok(())
} else {
Err(Error::Failed(
Err(Error::<E>::Failed(
exit_code,
args.into_iter().map(Into::into).collect(),
stdout,