From d780391408b9e6d443e5e4f907748cae484b79fb Mon Sep 17 00:00:00 2001 From: Joel Klinghed Date: Sat, 1 Feb 2025 22:42:11 +0100 Subject: Use workspace instead of features Having to include --feature=build-server in basically all commands that wasn't building eyeballs-githook got tiring quickly. Instead, use workspaces, with a separate project for building the githook. It means I also had to add a library common with code shared by both githook and server. --- server/Cargo.lock | 22 +- server/Cargo.toml | 46 ++-- server/README | 7 +- server/common/Cargo.toml | 10 + server/common/src/fs_utils.rs | 54 ++++ server/common/src/git.rs | 539 ++++++++++++++++++++++++++++++++++++++++ server/common/src/git_socket.rs | 24 ++ server/common/src/lib.rs | 3 + server/hook/Cargo.toml | 14 ++ server/hook/src/githook.rs | 105 ++++++++ server/src/fs_utils.rs | 54 ---- server/src/git.rs | 537 --------------------------------------- server/src/git_socket.rs | 24 -- server/src/githook.rs | 106 -------- server/src/main.rs | 7 +- 15 files changed, 798 insertions(+), 754 deletions(-) create mode 100644 server/common/Cargo.toml create mode 100644 server/common/src/fs_utils.rs create mode 100644 server/common/src/git.rs create mode 100644 server/common/src/git_socket.rs create mode 100644 server/common/src/lib.rs create mode 100644 server/hook/Cargo.toml create mode 100644 server/hook/src/githook.rs delete mode 100644 server/src/fs_utils.rs delete mode 100644 server/src/git.rs delete mode 100644 server/src/git_socket.rs delete mode 100644 server/src/githook.rs (limited to 'server') diff --git a/server/Cargo.lock b/server/Cargo.lock index 8fd8e92..7dfa00a 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -551,9 +551,9 @@ name = "eyeballs" version = "0.1.0" dependencies = [ "anyhow", + "eyeballs-common", "futures", "ldap3", - "pathdiff", "rmp-serde", "rocket", "rocket_db_pools", @@ -567,6 +567,26 @@ dependencies = [ "utoipa-swagger-ui", ] +[[package]] +name = "eyeballs-common" +version = "0.1.0" +dependencies = [ + "futures", + "pathdiff", + "serde", + "tokio", +] + +[[package]] +name = "eyeballs-githook" +version = "0.1.0" +dependencies = [ + "eyeballs-common", + "rmp-serde", + "serde", + "tokio", +] + [[package]] name = "fastrand" version = "2.3.0" diff --git a/server/Cargo.toml b/server/Cargo.toml index 64d6796..9288bc2 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -2,38 +2,32 @@ name = "eyeballs" version = "0.1.0" edition = "2021" -default-run = "eyeballs" -[dependencies] -# Used by all binaries, either because its needed or because the library is small enough I don't care -anyhow = "1.0" +[workspace] +members = ["hook"] +resolver = "2" + +[workspace.dependencies] futures = "0.3.31" -pathdiff = "0.2.3" rmp-serde = "1.3" serde = { version = "1.0", features = ["derive"] } -time = "0.3.34" -tokio = { version = "1", features = ["full"] } +tokio = { version = "1" } -# Optional dependencies, listed in features -ldap3 = { version = "0.11.5", default-features = false, features = [ "native-tls", "tls", "tls-native", "tokio-native-tls" ], optional = true } -rocket = { version = "0.5.1", features = ["json", "secrets"], optional = true } -rocket_db_pools = { version = "0.2.0", features = ["sqlx_mysql"], optional = true } -sqlx = { version = "0.7.0", default-features = false, features = ["macros", "migrate"], optional = true } -utoipa = { version = "5", features = ["rocket_extras"], optional = true } -utoipa-swagger-ui = { version = "9", features = ["rocket", "vendored"], default-features = false, optional = true } +[dependencies] +anyhow = "1.0" +eyeballs-common = { path = "common" } +futures.workspace = true +ldap3 = { version = "0.11.5", default-features = false, features = [ "native-tls", "tls", "tls-native", "tokio-native-tls" ] } +rmp-serde.workspace = true +rocket = { version = "0.5.1", features = ["json", "secrets"] } +rocket_db_pools = { version = "0.2.0", features = ["sqlx_mysql"] } +serde.workspace = true +sqlx = { version = "0.7.0", default-features = false, features = ["macros", "migrate"] } +time = "0.3.34" +tokio = { workspace = true, features = ["process"] } +utoipa = { version = "5", features = ["rocket_extras"] } +utoipa-swagger-ui = { version = "9", features = ["rocket", "vendored"], default-features = false } [dev-dependencies] stdext = "0.3.3" testdir = "0.9.3" - -[features] -build-server = ["ldap3", "rocket", "rocket_db_pools", "sqlx", "utoipa", "utoipa-swagger-ui"] - -[[bin]] -name = "eyeballs" -path = "src/main.rs" -required-features = ["build-server"] - -[[bin]] -name = "eyeballs-githook" -path = "src/githook.rs" diff --git a/server/README b/server/README index e0ecaf9..4e97d2e 100644 --- a/server/README +++ b/server/README @@ -4,7 +4,8 @@ Development setup Start git, ldap and mariadb in docker/dev using docker compose up or simular. You might have to create docker/git/authorized_keys to be able to mount it. -Then compile, and because rust hasn't figured out how to do dependencies per -artifact, you have to do this: -cargo build --target=x86_64-unknown-linux-musl --bin eyeballs-githook && cargo build --features="build-server" && cargo run --features="build-server" +Then compile, using this to get musl based binaries for the githook: +cargo --target=x86_64-unknown-linux-musl --package eyeballs-githook build +and then: +cargo build && cargo run diff --git a/server/common/Cargo.toml b/server/common/Cargo.toml new file mode 100644 index 0000000..a17fb95 --- /dev/null +++ b/server/common/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "eyeballs-common" +version = "0.1.0" +edition = "2021" + +[dependencies] +futures.workspace = true +pathdiff = "0.2.3" +serde.workspace = true +tokio = { workspace = true, features = ["fs", "process", "sync"] } diff --git a/server/common/src/fs_utils.rs b/server/common/src/fs_utils.rs new file mode 100644 index 0000000..7905d01 --- /dev/null +++ b/server/common/src/fs_utils.rs @@ -0,0 +1,54 @@ +#![allow(dead_code)] + +use std::io; +use std::path::Path; +use tokio::fs; + +pub async fn create_dir_allow_existing(path: impl AsRef) -> io::Result<()> { + match fs::create_dir(path).await { + Ok(_) => Ok(()), + Err(e) => { + if e.kind() == io::ErrorKind::AlreadyExists { + Ok(()) + } else { + Err(e) + } + } + } +} + +pub async fn remove_file_allow_not_found(path: impl AsRef) -> io::Result<()> { + match fs::remove_file(path).await { + Ok(_) => Ok(()), + Err(e) => { + if e.kind() == io::ErrorKind::NotFound { + Ok(()) + } else { + Err(e) + } + } + } +} + +pub async fn symlink_update_existing( + src: impl AsRef, + dst: impl AsRef, +) -> io::Result<()> { + let src = src.as_ref(); + let dst = dst.as_ref(); + match fs::symlink(&src, &dst).await { + Ok(_) => Ok(()), + Err(e) => { + if e.kind() == io::ErrorKind::AlreadyExists { + let path = fs::read_link(&dst).await?; + if path == src { + return Ok(()); + } + fs::remove_file(&dst).await?; + fs::symlink(&src, &dst).await + } else { + Err(e) + } + } + } +} diff --git a/server/common/src/git.rs b/server/common/src/git.rs new file mode 100644 index 0000000..e2966e0 --- /dev/null +++ b/server/common/src/git.rs @@ -0,0 +1,539 @@ +#![allow(dead_code)] + +use futures::future::TryFutureExt; +use pathdiff::diff_paths; +use std::collections::HashMap; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use tokio::fs; +use tokio::process::Command; +use tokio::sync::{RwLock, Semaphore}; + +use crate::fs_utils; + +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl Error { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +pub const EMPTY: &str = "0000000000000000000000000000000000000000"; + +struct RepoData { + // Only one fetch at a time, and they should be in queue + fetch_semaphore: Semaphore, + config_cache: HashMap, +} + +pub struct Repository { + path: PathBuf, + bare: bool, + + remote: Option, + project_id: Option, + socket: Option, + githook: Option, + + // Lock for any repo task, 90% of all tasks are readers but there are some writers + // where nothing else may be done. + lock: RwLock, +} + +#[allow(dead_code)] +pub struct User { + pub name: String, + pub email: String, + // Part before '@' in email + pub username: String, +} + +fn io_err(action: &str, e: std::io::Error) -> Error { + Error::new(format!("{action}: {e}")) +} + +fn parse_user(output: String) -> User { + let mut lines = output.lines(); + let name = lines.next().unwrap_or("").to_string(); + let username = lines.next().unwrap_or("").to_string(); + let email = lines.next().unwrap_or("").to_string(); + User { + name, + email, + username, + } +} + +impl RepoData { + fn new() -> Self { + Self { + fetch_semaphore: Semaphore::new(1), + config_cache: HashMap::new(), + } + } + + async fn fetch(&self, repo: &Repository, branch: String) -> Result<(), Error> { + if repo.remote.is_none() { + return Err(Error::new("No remote set")); + } + + let _permit = self.fetch_semaphore.acquire().await; + + let mut cmd = self.git_cmd(repo); + cmd.arg("fetch"); + // Use an atomic transaction to update local refs. + cmd.arg("--atomic"); + // Print the output to standard output in an easy-to-parse format for scripts. + cmd.arg("--porcelain"); + // This option disables this automatic tag following. + cmd.arg("--no-tags"); + cmd.arg("origin"); + // <+ force update>: + cmd.arg(format!("+{branch}:{branch}")); + + self.output(&mut cmd).await?; + + Ok(()) + } + + async fn init(&mut self, repo: &Repository) -> Result<(), Error> { + fs_utils::create_dir_allow_existing(repo.path()) + .map_err(|e| Error::new(format!("{e}"))) + .await?; + + let mut cmd = self.git_cmd(repo); + cmd.arg("init"); + if repo.is_bare() { + cmd.arg("--bare"); + } + + self.run(&mut cmd).await?; + + Ok(()) + } + + async fn sync_config(&mut self, repo: &Repository) -> Result<(), Error> { + self.config_fill_cache(repo).await?; + + if let Some(remote) = repo.remote() { + self.config_set(repo, "remote.origin.url", remote).await?; + } + if let Some(socket) = repo.socket() { + let relative = diff_paths(socket, repo.path()).unwrap(); + self.config_set(repo, "eyeballs.socket", relative.to_str().unwrap()) + .await?; + } + + // Handled by pre-receive hook, allow fast forwards for reviews that expect it. + self.config_set(repo, "receive.denyNonFastForwards", "false") + .await?; + // Handled by pre-receive hook, allow deletes for non-review branches + self.config_set(repo, "receive.denyDeletes", "false") + .await?; + + Ok(()) + } + + async fn sync_hooks(&mut self, repo: &Repository) -> Result<(), Error> { + let hook = match repo.githook() { + Some(path) => PathBuf::from(path), + None => { + let server_exe = + std::env::current_exe().map_err(|e| io_err("unable to get current exe", e))?; + server_exe.parent().unwrap().join("eyeballs-githook") + } + }; + + let hooks = if repo.is_bare() { + repo.path().join("hooks") + } else { + repo.path().join(".git/hooks") + }; + + fs_utils::create_dir_allow_existing(&hooks) + .map_err(|e| io_err("unable to create hooks", e)) + .await?; + + let pre_receive = hooks.join("pre-receive"); + let update = hooks.join("update"); + let post_receive = hooks.join("post-receive"); + + fs_utils::remove_file_allow_not_found(update) + .map_err(|e| io_err("unable to remove update hook", e)) + .await?; + + // Must be hard links, symbolic links doesn't allow the hook + // the lookup how it's called using std::env::current_exe(). + fs_utils::remove_file_allow_not_found(&pre_receive) + .map_err(|e| io_err("unable to remove pre-receive hook", e)) + .await?; + fs::hard_link(hook.as_path(), pre_receive) + .map_err(|e| io_err("unable to link pre-receive hook", e)) + .await?; + fs_utils::remove_file_allow_not_found(&post_receive) + .map_err(|e| io_err("unable to remove post-receive hook", e)) + .await?; + fs::hard_link(hook.as_path(), post_receive) + .map_err(|e| io_err("unable to link post-receive hook", e)) + .await + } + + async fn config_get(&self, repo: &Repository, name: &str) -> Result { + if let Some(value) = self.config_cache.get(name) { + return Ok(value.clone()); + } + + // Note, want to keep this method non-mutable so we can't update the cache here, should be + // edge case to end up here anyway. + + let mut cmd = self.git_cmd(repo); + cmd.arg("config") + .arg("get") + // End value with the null character and use newline as delimiter between key and value + .arg("--null") + .arg("--default=") + .arg(name); + let data = self.output(&mut cmd).await?; + match data.as_str().split_once('\0') { + Some((value, _)) => Ok(value.to_string()), + None => Err(Error::new("Invalid output from git config get")), + } + } + + async fn config_fill_cache(&mut self, repo: &Repository) -> Result<(), Error> { + self.config_cache.clear(); + + let mut cmd = self.git_cmd(repo); + cmd.arg("config") + .arg("list") + // read only from the repository .git/config, + .arg("--local") + // End value with the null character and use newline as delimiter between key and value + .arg("--null"); + let data = self.output(&mut cmd).await?; + for key_value in data.split_terminator('\0') { + match key_value.split_once('\n') { + Some((key, value)) => self.config_cache.insert(key.to_string(), value.to_string()), + None => return Err(Error::new("Invalid output from git config list")), + }; + } + Ok(()) + } + + async fn config_set( + &mut self, + repo: &Repository, + name: &str, + value: &str, + ) -> Result<(), Error> { + if let Some(cached_value) = self.config_cache.get(name) { + if cached_value == value { + return Ok(()); + } + } + + let mut cmd = self.git_cmd(repo); + cmd.arg("config").arg("set").arg(name).arg(value); + self.run(&mut cmd).await?; + + self.config_cache + .insert(name.to_string(), value.to_string()); + + Ok(()) + } + + async fn is_ancestor( + &self, + repo: &Repository, + ancestor: &str, + commit: &str, + ) -> Result { + let mut cmd = self.git_cmd(repo); + cmd.arg("merge-base") + .arg("--is-ancestor") + .arg(ancestor) + .arg(commit); + self.check(&mut cmd).await + } + + async fn is_equal_content( + &self, + repo: &Repository, + commit1: &str, + commit2: &str, + ) -> Result { + let mut cmd = self.git_cmd(repo); + cmd.arg("diff") + .arg("--quiet") + .arg("--no-renames") + .arg(commit1) + .arg(commit2); + self.check(&mut cmd).await + } + + async fn get_author(&self, repo: &Repository, commit: &str) -> Result { + self.get_log_format(repo, commit, "%an%n%al%n%ae") + .map_ok(parse_user) + .await + } + + async fn get_commiter(&self, repo: &Repository, commit: &str) -> Result { + self.get_log_format(repo, commit, "%cn%n%cl%n%ce") + .map_ok(parse_user) + .await + } + + async fn get_log_format( + &self, + repo: &Repository, + commit: &str, + format: &str, + ) -> Result { + let mut cmd = self.git_cmd(repo); + cmd.arg("log") + .arg("-1") + .arg("--no-decorate") + .arg("--no-mailmap") + .arg(format!("--pretty=format:{format}")) + .arg(commit); + self.output(&mut cmd).await + } + + fn git_cmd(&self, repo: &Repository) -> Command { + let mut cmd = Command::new("git"); + // Run as if git was started in instead of the current working directory. + cmd.arg("-C").arg(repo.path().to_str().unwrap()); + // Disable all advice hints from being printed. + cmd.arg("--no-advice"); + // Do not pipe Git output into a pager. + cmd.arg("--no-pager"); + // Do not perform optional operations that require locks. + cmd.arg("--no-optional-locks"); + + cmd + } + + async fn run(&self, cmd: &mut Command) -> Result<(), Error> { + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + let child = cmd + .spawn() + .map_err(|e| Error::new(format!("git command failed to start: {e}")))?; + + let output = child + .wait_with_output() + .map_err(|e| Error::new(format!("git command failed to execute: {e}"))) + .await?; + + if output.status.success() { + Ok(()) + } else { + Err(Error::new(format!( + "git command failed with exitcode: {}\n{:?}\n{}", + output.status, + cmd.as_std().get_args(), + std::str::from_utf8(output.stderr.as_slice()).unwrap_or(""), + ))) + } + } + + async fn check(&self, cmd: &mut Command) -> Result { + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + let child = cmd + .spawn() + .map_err(|e| Error::new(format!("git command failed to start: {e}")))?; + + let output = child + .wait_with_output() + .map_err(|e| Error::new(format!("git command failed to execute: {e}"))) + .await?; + + if output.status.success() { + Ok(true) + } else { + match output.status.code() { + Some(1) => Ok(false), + _ => Err(Error::new(format!( + "git command failed with exitcode: {}\n{:?}\n{}", + output.status, + cmd.as_std().get_args(), + std::str::from_utf8(output.stderr.as_slice()).unwrap_or(""), + ))), + } + } + } + + async fn output(&self, cmd: &mut Command) -> Result { + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let child = cmd + .spawn() + .map_err(|e| Error::new(format!("git command failed to start: {e}")))?; + + let output = child + .wait_with_output() + .map_err(|e| Error::new(format!("git command failed to execute: {e}"))) + .await?; + + if output.status.success() { + let output_utf8 = String::from_utf8(output.stdout) + .map_err(|e| Error::new(format!("git command had invalid output: {e}")))?; + Ok(output_utf8) + } else { + Err(Error::new(format!( + "git command failed with exitcode: {}\n{:?}\n{}", + output.status, + cmd.as_std().get_args(), + std::str::from_utf8(output.stderr.as_slice()).unwrap_or(""), + ))) + } + } +} + +#[allow(dead_code)] +impl Repository { + pub fn new( + path: impl Into, + bare: bool, + remote: Option>, + project_id: Option>, + githook: Option>, + ) -> Self { + let path = path.into(); + let project_id = project_id.map(|x| x.into()); + let githook = githook.map(|x| x.into()); + let socket: Option; + if let Some(project_id) = &project_id { + socket = Some( + path.parent() + .unwrap() + .join(format!("{}.socket", project_id)), + ); + } else { + socket = None; + } + + Self { + remote: remote.map(|x| x.into()), + project_id, + path, + socket, + githook, + bare, + lock: RwLock::new(RepoData::new()), + } + } + + pub fn remote(&self) -> Option<&str> { + self.remote.as_deref() + } + + pub fn project_id(&self) -> Option<&str> { + self.project_id.as_deref() + } + + pub fn path(&self) -> &Path { + self.path.as_path() + } + + pub fn socket(&self) -> Option<&Path> { + self.socket.as_deref() + } + + fn githook(&self) -> Option<&Path> { + self.githook.as_deref() + } + + pub fn is_bare(&self) -> bool { + self.bare + } + + pub async fn setup(&self) -> Result<(), Error> { + let mut data = self.lock.write().await; + + data.init(self).await?; + data.sync_config(self).await?; + if self.socket.is_some() { + data.sync_hooks(self).await?; + } + + Ok(()) + } + + pub async fn fetch(&self, branch: impl Into) -> Result<(), Error> { + let branch = branch.into(); + let data = self.lock.read().await; + + data.fetch(self, branch).await + } + + pub async fn config_get(&self, name: impl Into) -> Result { + let name = name.into(); + let data = self.lock.read().await; + + data.config_get(self, name.as_str()).await + } + + pub async fn is_ancestor( + &self, + ancestor: impl Into, + commit: impl Into, + ) -> Result { + let ancestor = ancestor.into(); + let commit = commit.into(); + + let data = self.lock.read().await; + + data.is_ancestor(self, ancestor.as_str(), commit.as_str()) + .await + } + + pub async fn is_equal_content( + &self, + commit1: impl Into, + commit2: impl Into, + ) -> Result { + let commit1 = commit1.into(); + let commit2 = commit2.into(); + let data = self.lock.read().await; + + data.is_equal_content(self, commit1.as_str(), commit2.as_str()) + .await + } + + pub async fn get_author(&self, commit: impl Into) -> Result { + let commit = commit.into(); + let data = self.lock.read().await; + + data.get_author(self, commit.as_str()).await + } + + pub async fn get_commiter(&self, commit: impl Into) -> Result { + let commit = commit.into(); + let data = self.lock.read().await; + + data.get_commiter(self, commit.as_str()).await + } +} diff --git a/server/common/src/git_socket.rs b/server/common/src/git_socket.rs new file mode 100644 index 0000000..a4805be --- /dev/null +++ b/server/common/src/git_socket.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +pub struct GitReceive { + pub old_value: String, + pub new_value: String, + pub reference: String, + // Only set for pre hooks, because server can't read the objects the pre-hook has not yet + // accepted, so to be able to validate the commiter, send them. Also only set if new_value + // is not empty. + pub commiter: Option, +} + +#[derive(Deserialize, Serialize)] +pub struct GitHookRequest { + pub pre: bool, + pub receive: Vec, +} + +#[derive(Deserialize, Serialize)] +pub struct GitHookResponse { + pub ok: bool, + pub message: String, +} diff --git a/server/common/src/lib.rs b/server/common/src/lib.rs new file mode 100644 index 0000000..a63e05b --- /dev/null +++ b/server/common/src/lib.rs @@ -0,0 +1,3 @@ +pub mod fs_utils; +pub mod git; +pub mod git_socket; diff --git a/server/hook/Cargo.toml b/server/hook/Cargo.toml new file mode 100644 index 0000000..2a298b7 --- /dev/null +++ b/server/hook/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "eyeballs-githook" +version = "0.1.0" +edition = "2021" + +[dependencies] +eyeballs-common = { path = "../common" } +rmp-serde.workspace = true +serde.workspace = true +tokio = { workspace = true, features = ["full"] } + +[[bin]] +name = "eyeballs-githook" +path = "src/githook.rs" diff --git a/server/hook/src/githook.rs b/server/hook/src/githook.rs new file mode 100644 index 0000000..a9cb898 --- /dev/null +++ b/server/hook/src/githook.rs @@ -0,0 +1,105 @@ +use rmp_serde::{decode, Serializer}; +use serde::ser::Serialize; +use std::error::Error; +use std::fmt; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::task; + +use eyeballs_common::git; +use eyeballs_common::git_socket; + +#[derive(Debug)] +struct IoError { + message: String, +} + +impl IoError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for IoError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl Error for IoError {} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let pre = match std::env::current_exe()? + .file_name() + .and_then(|x| x.to_str()) + { + Some("pre-receive") => true, + Some("post-receive") => false, + _ => return Err(Box::::from("Invalid hook executable name")), + }; + + let input = io::stdin(); + let reader = BufReader::new(input); + let mut lines = reader.lines(); + + let mut request = git_socket::GitHookRequest { + pre, + receive: Vec::new(), + }; + + let repo = git::Repository::new( + PathBuf::from("."), + true, + None::, + None::, + None::, + ); + + while let Some(line) = lines.next_line().await? { + let data: Vec<&str> = line.split(' ').collect(); + + if data.len() == 3 { + let mut commiter: Option = None; + if pre && data[1] != git::EMPTY { + if let Ok(user) = repo.get_commiter(data[1]).await { + commiter = Some(user.username); + } + } + + request.receive.push(git_socket::GitReceive { + old_value: data[0].to_string(), + new_value: data[1].to_string(), + reference: data[2].to_string(), + commiter, + }) + } + } + + let socket = PathBuf::from(repo.config_get("eyeballs.socket").await?); + + let response = task::spawn_blocking(move || { + let stream = UnixStream::connect(socket).map_err(|e| IoError::new(e.to_string()))?; + let mut serializer = Serializer::new(&stream); + request + .serialize(&mut serializer) + .map_err(|e| IoError::new(e.to_string()))?; + let result: Result = + decode::from_read(stream).map_err(|e| IoError::new(e.to_string())); + result + }) + .await? + .map_err(Box::::from)?; + + let mut output = io::stdout(); + output.write_all(response.message.as_bytes()).await?; + + if response.ok { + Ok(()) + } else { + Err(Box::::from("Hook failed")) + } +} diff --git a/server/src/fs_utils.rs b/server/src/fs_utils.rs deleted file mode 100644 index 7905d01..0000000 --- a/server/src/fs_utils.rs +++ /dev/null @@ -1,54 +0,0 @@ -#![allow(dead_code)] - -use std::io; -use std::path::Path; -use tokio::fs; - -pub async fn create_dir_allow_existing(path: impl AsRef) -> io::Result<()> { - match fs::create_dir(path).await { - Ok(_) => Ok(()), - Err(e) => { - if e.kind() == io::ErrorKind::AlreadyExists { - Ok(()) - } else { - Err(e) - } - } - } -} - -pub async fn remove_file_allow_not_found(path: impl AsRef) -> io::Result<()> { - match fs::remove_file(path).await { - Ok(_) => Ok(()), - Err(e) => { - if e.kind() == io::ErrorKind::NotFound { - Ok(()) - } else { - Err(e) - } - } - } -} - -pub async fn symlink_update_existing( - src: impl AsRef, - dst: impl AsRef, -) -> io::Result<()> { - let src = src.as_ref(); - let dst = dst.as_ref(); - match fs::symlink(&src, &dst).await { - Ok(_) => Ok(()), - Err(e) => { - if e.kind() == io::ErrorKind::AlreadyExists { - let path = fs::read_link(&dst).await?; - if path == src { - return Ok(()); - } - fs::remove_file(&dst).await?; - fs::symlink(&src, &dst).await - } else { - Err(e) - } - } - } -} diff --git a/server/src/git.rs b/server/src/git.rs deleted file mode 100644 index a05c670..0000000 --- a/server/src/git.rs +++ /dev/null @@ -1,537 +0,0 @@ -use futures::future::TryFutureExt; -use pathdiff::diff_paths; -use std::collections::HashMap; -use std::fmt; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use tokio::fs; -use tokio::process::Command; -use tokio::sync::{RwLock, Semaphore}; - -use crate::fs_utils; - -#[derive(Debug)] -pub struct Error { - pub message: String, -} - -impl Error { - fn new(message: impl Into) -> Self { - Self { - message: message.into(), - } - } -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for Error {} - -pub const EMPTY: &str = "0000000000000000000000000000000000000000"; - -struct RepoData { - // Only one fetch at a time, and they should be in queue - fetch_semaphore: Semaphore, - config_cache: HashMap, -} - -pub struct Repository { - path: PathBuf, - bare: bool, - - remote: Option, - project_id: Option, - socket: Option, - githook: Option, - - // Lock for any repo task, 90% of all tasks are readers but there are some writers - // where nothing else may be done. - lock: RwLock, -} - -#[allow(dead_code)] -pub struct User { - pub name: String, - pub email: String, - // Part before '@' in email - pub username: String, -} - -fn io_err(action: &str, e: std::io::Error) -> Error { - Error::new(format!("{action}: {e}")) -} - -fn parse_user(output: String) -> User { - let mut lines = output.lines(); - let name = lines.next().unwrap_or("").to_string(); - let username = lines.next().unwrap_or("").to_string(); - let email = lines.next().unwrap_or("").to_string(); - User { - name, - email, - username, - } -} - -impl RepoData { - fn new() -> Self { - Self { - fetch_semaphore: Semaphore::new(1), - config_cache: HashMap::new(), - } - } - - async fn fetch(&self, repo: &Repository, branch: String) -> Result<(), Error> { - if repo.remote.is_none() { - return Err(Error::new("No remote set")); - } - - let _permit = self.fetch_semaphore.acquire().await; - - let mut cmd = self.git_cmd(repo); - cmd.arg("fetch"); - // Use an atomic transaction to update local refs. - cmd.arg("--atomic"); - // Print the output to standard output in an easy-to-parse format for scripts. - cmd.arg("--porcelain"); - // This option disables this automatic tag following. - cmd.arg("--no-tags"); - cmd.arg("origin"); - // <+ force update>: - cmd.arg(format!("+{branch}:{branch}")); - - self.output(&mut cmd).await?; - - Ok(()) - } - - async fn init(&mut self, repo: &Repository) -> Result<(), Error> { - fs_utils::create_dir_allow_existing(repo.path()) - .map_err(|e| Error::new(format!("{e}"))) - .await?; - - let mut cmd = self.git_cmd(repo); - cmd.arg("init"); - if repo.is_bare() { - cmd.arg("--bare"); - } - - self.run(&mut cmd).await?; - - Ok(()) - } - - async fn sync_config(&mut self, repo: &Repository) -> Result<(), Error> { - self.config_fill_cache(repo).await?; - - if let Some(remote) = repo.remote() { - self.config_set(repo, "remote.origin.url", remote).await?; - } - if let Some(socket) = repo.socket() { - let relative = diff_paths(socket, repo.path()).unwrap(); - self.config_set(repo, "eyeballs.socket", relative.to_str().unwrap()) - .await?; - } - - // Handled by pre-receive hook, allow fast forwards for reviews that expect it. - self.config_set(repo, "receive.denyNonFastForwards", "false") - .await?; - // Handled by pre-receive hook, allow deletes for non-review branches - self.config_set(repo, "receive.denyDeletes", "false") - .await?; - - Ok(()) - } - - async fn sync_hooks(&mut self, repo: &Repository) -> Result<(), Error> { - let hook = match repo.githook() { - Some(path) => PathBuf::from(path), - None => { - let server_exe = - std::env::current_exe().map_err(|e| io_err("unable to get current exe", e))?; - server_exe.parent().unwrap().join("eyeballs-githook") - } - }; - - let hooks = if repo.is_bare() { - repo.path().join("hooks") - } else { - repo.path().join(".git/hooks") - }; - - fs_utils::create_dir_allow_existing(&hooks) - .map_err(|e| io_err("unable to create hooks", e)) - .await?; - - let pre_receive = hooks.join("pre-receive"); - let update = hooks.join("update"); - let post_receive = hooks.join("post-receive"); - - fs_utils::remove_file_allow_not_found(update) - .map_err(|e| io_err("unable to remove update hook", e)) - .await?; - - // Must be hard links, symbolic links doesn't allow the hook - // the lookup how it's called using std::env::current_exe(). - fs_utils::remove_file_allow_not_found(&pre_receive) - .map_err(|e| io_err("unable to remove pre-receive hook", e)) - .await?; - fs::hard_link(hook.as_path(), pre_receive) - .map_err(|e| io_err("unable to link pre-receive hook", e)) - .await?; - fs_utils::remove_file_allow_not_found(&post_receive) - .map_err(|e| io_err("unable to remove post-receive hook", e)) - .await?; - fs::hard_link(hook.as_path(), post_receive) - .map_err(|e| io_err("unable to link post-receive hook", e)) - .await - } - - async fn config_get(&self, repo: &Repository, name: &str) -> Result { - if let Some(value) = self.config_cache.get(name) { - return Ok(value.clone()); - } - - // Note, want to keep this method non-mutable so we can't update the cache here, should be - // edge case to end up here anyway. - - let mut cmd = self.git_cmd(repo); - cmd.arg("config") - .arg("get") - // End value with the null character and use newline as delimiter between key and value - .arg("--null") - .arg("--default=") - .arg(name); - let data = self.output(&mut cmd).await?; - match data.as_str().split_once('\0') { - Some((value, _)) => Ok(value.to_string()), - None => Err(Error::new("Invalid output from git config get")), - } - } - - async fn config_fill_cache(&mut self, repo: &Repository) -> Result<(), Error> { - self.config_cache.clear(); - - let mut cmd = self.git_cmd(repo); - cmd.arg("config") - .arg("list") - // read only from the repository .git/config, - .arg("--local") - // End value with the null character and use newline as delimiter between key and value - .arg("--null"); - let data = self.output(&mut cmd).await?; - for key_value in data.split_terminator('\0') { - match key_value.split_once('\n') { - Some((key, value)) => self.config_cache.insert(key.to_string(), value.to_string()), - None => return Err(Error::new("Invalid output from git config list")), - }; - } - Ok(()) - } - - async fn config_set( - &mut self, - repo: &Repository, - name: &str, - value: &str, - ) -> Result<(), Error> { - if let Some(cached_value) = self.config_cache.get(name) { - if cached_value == value { - return Ok(()); - } - } - - let mut cmd = self.git_cmd(repo); - cmd.arg("config").arg("set").arg(name).arg(value); - self.run(&mut cmd).await?; - - self.config_cache - .insert(name.to_string(), value.to_string()); - - Ok(()) - } - - async fn is_ancestor( - &self, - repo: &Repository, - ancestor: &str, - commit: &str, - ) -> Result { - let mut cmd = self.git_cmd(repo); - cmd.arg("merge-base") - .arg("--is-ancestor") - .arg(ancestor) - .arg(commit); - self.check(&mut cmd).await - } - - async fn is_equal_content( - &self, - repo: &Repository, - commit1: &str, - commit2: &str, - ) -> Result { - let mut cmd = self.git_cmd(repo); - cmd.arg("diff") - .arg("--quiet") - .arg("--no-renames") - .arg(commit1) - .arg(commit2); - self.check(&mut cmd).await - } - - async fn get_author(&self, repo: &Repository, commit: &str) -> Result { - self.get_log_format(repo, commit, "%an%n%al%n%ae") - .map_ok(parse_user) - .await - } - - async fn get_commiter(&self, repo: &Repository, commit: &str) -> Result { - self.get_log_format(repo, commit, "%cn%n%cl%n%ce") - .map_ok(parse_user) - .await - } - - async fn get_log_format( - &self, - repo: &Repository, - commit: &str, - format: &str, - ) -> Result { - let mut cmd = self.git_cmd(repo); - cmd.arg("log") - .arg("-1") - .arg("--no-decorate") - .arg("--no-mailmap") - .arg(format!("--pretty=format:{format}")) - .arg(commit); - self.output(&mut cmd).await - } - - fn git_cmd(&self, repo: &Repository) -> Command { - let mut cmd = Command::new("git"); - // Run as if git was started in instead of the current working directory. - cmd.arg("-C").arg(repo.path().to_str().unwrap()); - // Disable all advice hints from being printed. - cmd.arg("--no-advice"); - // Do not pipe Git output into a pager. - cmd.arg("--no-pager"); - // Do not perform optional operations that require locks. - cmd.arg("--no-optional-locks"); - - cmd - } - - async fn run(&self, cmd: &mut Command) -> Result<(), Error> { - cmd.stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()); - - let child = cmd - .spawn() - .map_err(|e| Error::new(format!("git command failed to start: {e}")))?; - - let output = child - .wait_with_output() - .map_err(|e| Error::new(format!("git command failed to execute: {e}"))) - .await?; - - if output.status.success() { - Ok(()) - } else { - Err(Error::new(format!( - "git command failed with exitcode: {}\n{:?}\n{}", - output.status, - cmd.as_std().get_args(), - std::str::from_utf8(output.stderr.as_slice()).unwrap_or(""), - ))) - } - } - - async fn check(&self, cmd: &mut Command) -> Result { - cmd.stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()); - - let child = cmd - .spawn() - .map_err(|e| Error::new(format!("git command failed to start: {e}")))?; - - let output = child - .wait_with_output() - .map_err(|e| Error::new(format!("git command failed to execute: {e}"))) - .await?; - - if output.status.success() { - Ok(true) - } else { - match output.status.code() { - Some(1) => Ok(false), - _ => Err(Error::new(format!( - "git command failed with exitcode: {}\n{:?}\n{}", - output.status, - cmd.as_std().get_args(), - std::str::from_utf8(output.stderr.as_slice()).unwrap_or(""), - ))), - } - } - } - - async fn output(&self, cmd: &mut Command) -> Result { - cmd.stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let child = cmd - .spawn() - .map_err(|e| Error::new(format!("git command failed to start: {e}")))?; - - let output = child - .wait_with_output() - .map_err(|e| Error::new(format!("git command failed to execute: {e}"))) - .await?; - - if output.status.success() { - let output_utf8 = String::from_utf8(output.stdout) - .map_err(|e| Error::new(format!("git command had invalid output: {e}")))?; - Ok(output_utf8) - } else { - Err(Error::new(format!( - "git command failed with exitcode: {}\n{:?}\n{}", - output.status, - cmd.as_std().get_args(), - std::str::from_utf8(output.stderr.as_slice()).unwrap_or(""), - ))) - } - } -} - -#[allow(dead_code)] -impl Repository { - pub fn new( - path: impl Into, - bare: bool, - remote: Option>, - project_id: Option>, - githook: Option>, - ) -> Self { - let path = path.into(); - let project_id = project_id.map(|x| x.into()); - let githook = githook.map(|x| x.into()); - let socket: Option; - if let Some(project_id) = &project_id { - socket = Some( - path.parent() - .unwrap() - .join(format!("{}.socket", project_id)), - ); - } else { - socket = None; - } - - Self { - remote: remote.map(|x| x.into()), - project_id, - path, - socket, - githook, - bare, - lock: RwLock::new(RepoData::new()), - } - } - - pub fn remote(&self) -> Option<&str> { - self.remote.as_deref() - } - - pub fn project_id(&self) -> Option<&str> { - self.project_id.as_deref() - } - - pub fn path(&self) -> &Path { - self.path.as_path() - } - - pub fn socket(&self) -> Option<&Path> { - self.socket.as_deref() - } - - fn githook(&self) -> Option<&Path> { - self.githook.as_deref() - } - - pub fn is_bare(&self) -> bool { - self.bare - } - - pub async fn setup(&self) -> Result<(), Error> { - let mut data = self.lock.write().await; - - data.init(self).await?; - data.sync_config(self).await?; - if self.socket.is_some() { - data.sync_hooks(self).await?; - } - - Ok(()) - } - - pub async fn fetch(&self, branch: impl Into) -> Result<(), Error> { - let branch = branch.into(); - let data = self.lock.read().await; - - data.fetch(self, branch).await - } - - pub async fn config_get(&self, name: impl Into) -> Result { - let name = name.into(); - let data = self.lock.read().await; - - data.config_get(self, name.as_str()).await - } - - pub async fn is_ancestor( - &self, - ancestor: impl Into, - commit: impl Into, - ) -> Result { - let ancestor = ancestor.into(); - let commit = commit.into(); - - let data = self.lock.read().await; - - data.is_ancestor(self, ancestor.as_str(), commit.as_str()) - .await - } - - pub async fn is_equal_content( - &self, - commit1: impl Into, - commit2: impl Into, - ) -> Result { - let commit1 = commit1.into(); - let commit2 = commit2.into(); - let data = self.lock.read().await; - - data.is_equal_content(self, commit1.as_str(), commit2.as_str()) - .await - } - - pub async fn get_author(&self, commit: impl Into) -> Result { - let commit = commit.into(); - let data = self.lock.read().await; - - data.get_author(self, commit.as_str()).await - } - - pub async fn get_commiter(&self, commit: impl Into) -> Result { - let commit = commit.into(); - let data = self.lock.read().await; - - data.get_commiter(self, commit.as_str()).await - } -} diff --git a/server/src/git_socket.rs b/server/src/git_socket.rs deleted file mode 100644 index a4805be..0000000 --- a/server/src/git_socket.rs +++ /dev/null @@ -1,24 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Deserialize, Serialize)] -pub struct GitReceive { - pub old_value: String, - pub new_value: String, - pub reference: String, - // Only set for pre hooks, because server can't read the objects the pre-hook has not yet - // accepted, so to be able to validate the commiter, send them. Also only set if new_value - // is not empty. - pub commiter: Option, -} - -#[derive(Deserialize, Serialize)] -pub struct GitHookRequest { - pub pre: bool, - pub receive: Vec, -} - -#[derive(Deserialize, Serialize)] -pub struct GitHookResponse { - pub ok: bool, - pub message: String, -} diff --git a/server/src/githook.rs b/server/src/githook.rs deleted file mode 100644 index 2e1de13..0000000 --- a/server/src/githook.rs +++ /dev/null @@ -1,106 +0,0 @@ -use rmp_serde::{decode, Serializer}; -use serde::ser::Serialize; -use std::error::Error; -use std::fmt; -use std::os::unix::net::UnixStream; -use std::path::PathBuf; -use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::task; - -mod fs_utils; -mod git; -mod git_socket; - -#[derive(Debug)] -struct IoError { - message: String, -} - -impl IoError { - fn new(message: impl Into) -> Self { - Self { - message: message.into(), - } - } -} - -impl fmt::Display for IoError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.message) - } -} - -impl Error for IoError {} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let pre = match std::env::current_exe()? - .file_name() - .and_then(|x| x.to_str()) - { - Some("pre-receive") => true, - Some("post-receive") => false, - _ => return Err(Box::::from("Invalid hook executable name")), - }; - - let input = io::stdin(); - let reader = BufReader::new(input); - let mut lines = reader.lines(); - - let mut request = git_socket::GitHookRequest { - pre, - receive: Vec::new(), - }; - - let repo = git::Repository::new( - PathBuf::from("."), - true, - None::, - None::, - None::, - ); - - while let Some(line) = lines.next_line().await? { - let data: Vec<&str> = line.split(' ').collect(); - - if data.len() == 3 { - let mut commiter: Option = None; - if pre && data[1] != git::EMPTY { - if let Ok(user) = repo.get_commiter(data[1]).await { - commiter = Some(user.username); - } - } - - request.receive.push(git_socket::GitReceive { - old_value: data[0].to_string(), - new_value: data[1].to_string(), - reference: data[2].to_string(), - commiter, - }) - } - } - - let socket = PathBuf::from(repo.config_get("eyeballs.socket").await?); - - let response = task::spawn_blocking(move || { - let stream = UnixStream::connect(socket).map_err(|e| IoError::new(e.to_string()))?; - let mut serializer = Serializer::new(&stream); - request - .serialize(&mut serializer) - .map_err(|e| IoError::new(e.to_string()))?; - let result: Result = - decode::from_read(stream).map_err(|e| IoError::new(e.to_string())); - result - }) - .await? - .map_err(Box::::from)?; - - let mut output = io::stdout(); - output.write_all(response.message.as_bytes()).await?; - - if response.ok { - Ok(()) - } else { - Err(Box::::from("Hook failed")) - } -} diff --git a/server/src/main.rs b/server/src/main.rs index 3d6d0e6..b2974ac 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -14,6 +14,10 @@ use std::path::PathBuf; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; +use eyeballs_common::fs_utils; +use eyeballs_common::git; +use eyeballs_common::git_socket; + #[cfg(test)] mod tests; @@ -21,10 +25,7 @@ mod api_model; mod auth; mod authorized_keys; mod db_utils; -mod fs_utils; -mod git; mod git_root; -mod git_socket; use auth::AuthApiAddon; -- cgit v1.2.3-70-g09d2