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/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 +- 5 files changed, 4 insertions(+), 724 deletions(-) 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/src') 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