mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-11-24 05:29:51 +03:00
add initial complete implementation of fork/exec auth harness
This commit is contained in:
parent
79629e9ca6
commit
5f157b26ec
45
Cargo.lock
generated
45
Cargo.lock
generated
@ -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"
|
||||
|
@ -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"]}
|
||||
|
@ -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)]
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user