From 7e167e76107ba60d8ee6ef7feb2214e08765f744 Mon Sep 17 00:00:00 2001 From: Joshua Simmons Date: Fri, 30 May 2025 14:31:13 +0200 Subject: [PATCH 1/1] Initial Commit! --- .gitignore | 1 + Cargo.lock | 16 +++++ Cargo.toml | 7 ++ src/main.rs | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 222 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1ea0c30 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "git-shell-multiplex" +version = "0.1.0" +dependencies = [ + "shlex", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3704d5d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "git-shell-multiplex" +version = "0.1.0" +edition = "2024" + +[dependencies] +shlex = "1.3.0" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..db2d9cb --- /dev/null +++ b/src/main.rs @@ -0,0 +1,198 @@ +//! git-shell-multiplex +//! +//! An implementation of `git-shell` which allows multiple users to access git +//! repositories through a single linux user account. It works by using the +//! `command` parameter of `authorized_keys` entries to associate a username +//! with a public key, then correlating that with the permissions of the git +//! repository. +//! +//! There's no support for an interactive shell, and only the following git +//! commands are available. +//! * `git-receive-pack` +//! * `git-upload-pack` +//! * `git-upload-archive` +//! +//! Control Files +//! === +//! +//! `PUBLIC_ACCESS_FILE` +//! --- +//! `git-shell-multiplex` looks for the file `PUBLIC_ACCESS_FILE` (default: +//! `git-daemon-export-ok`) within the git repository, if found all users will be +//! granted read-access to the repository. +//! +//! `CONTRIBUTORS_FILE` +//! --- +//! `git-shell-multiplex` looks for the file `PUBLIC_ACCESS_FILE` (default: +//! `git-shell-multiplex-contributors`) within the git repository. The file is +//! expected to contain a newline-separated list of additional contributor +//! usernames. These users will be granted write access to the repository, in +//! addition to the repository owner. +//! +//! Example `authorized_keys` file +//! === +//! +//! ```text +//! command="/home/git/git-shell-multiplex josh",restrict +//! command="/home/git/git-shell-multiplex sarah",restrict +//! ``` +//! +//! Accepts paths of the form `/.git` only. + +use std::fs::File; +use std::io::{BufRead as _, BufReader, Lines}; +use std::path::{Path, PathBuf}; + +enum Error { + Usage, + NotAShell { username: String }, + NoPermission, +} + +/// Repositories should be found at `//`. Note the lack of `.git`. +const GIT_DIR: &str = "/var/git/"; + +/// If a file with this name is present in a git repository, read-access will be +/// granted to anybody with ssh access. +const PUBLIC_ACCESS_FILE: &str = "git-daemon-export-ok"; + +/// This file, if it exists, should contain a newline-separated list of additional +/// contributor usernames. These users will be granted write access to the +/// repository. +/// +/// If the file does not exist, only the owner of the repository will be able to +/// write to the repository. +const CONTRIBUTORS_FILE: &str = "git-shell-multiplex-contributors"; + +fn read_lines

(path: P) -> std::io::Result>> +where + P: AsRef, +{ + let file = File::open(path)?; + Ok(BufReader::new(file).lines()) +} + +fn git_shell() -> Result<(), Error> { + let mut args = std::env::args(); + + // First arg is the process name. + let _ = args.next().ok_or(Error::Usage)?; + let username = args.next().ok_or(Error::Usage)?; + + // Usage error if we pass extra arguments. + if args.next().is_some() { + return Err(Error::Usage); + } + + // If sshd hasn't set the original command, some goober is trying to use the + // git account as a shell. + let Some(ssh_original_command) = std::env::var_os("SSH_ORIGINAL_COMMAND") else { + return Err(Error::NotAShell { username }); + }; + + // If the incoming command isn't valid unicode. + let ssh_original_command = ssh_original_command.to_str().ok_or(Error::NoPermission)?; + + // The `SSH_ORIGINAL_COMMAND` is escaped, so we need to undo that. + let command; + let path; + { + let mut words = shlex::Shlex::new(ssh_original_command); + command = words.next().ok_or(Error::NoPermission)?; + path = words.next().ok_or(Error::NoPermission)?; + if words.next().is_some() { + return Err(Error::NoPermission); + } + }; + + // We want to support both `git-blah-blah` and `git blah-blah`. + let command = if let Some(rest) = command.strip_prefix("git-") { + rest + } else if let Some(rest) = command.strip_prefix("git ") { + rest + } else { + return Err(Error::NoPermission); + }; + + // We exclusively support paths that look like `/.git`. + let (repo_owner, repo_name) = path.split_once('/').ok_or(Error::NoPermission)?; + let repo_name = repo_name.strip_suffix(".git").ok_or(Error::NoPermission)?; + + // Hopefully this is enough... + const UNSAFE_CHARS: &[char] = &['\\', '.', '/', ':', '?']; + if repo_owner.contains(UNSAFE_CHARS) || repo_name.contains(UNSAFE_CHARS) { + return Err(Error::NoPermission); + } + + // On the filesystem we don't have a `.git` suffix for repositories. + let repo_path = PathBuf::from(format!("{GIT_DIR}/{repo_owner}/{repo_name}")); + + // Is this actually a git repo? + let has_head_file = matches!(std::fs::exists(repo_path.join("HEAD")), Ok(true)); + if !has_head_file { + return Err(Error::NoPermission); + } + + // If the repo contains a file named `git-daemon-export-ok` we allow public access. + let is_repo_public = matches!( + std::fs::exists(repo_path.join(PUBLIC_ACCESS_FILE)), + Ok(true) + ); + + let is_user_a_contributor = + if let Ok(contributors) = read_lines(repo_path.join(CONTRIBUTORS_FILE)) { + contributors + .map_while(Result::ok) + .any(|contributor_username| contributor_username == username) + } else { + false + }; + + // Check the permissions... + let is_user_the_repo_owner = repo_owner == username; + let write_access = is_user_the_repo_owner || is_user_a_contributor; + let read_access = is_user_the_repo_owner || is_user_a_contributor || is_repo_public; + + let has_permission = match command { + "receive-pack" => write_access, + "upload-pack" | "upload-archive" => read_access, + + // Spooky unknown commands ~~~ + _ => false, + }; + + if !has_permission { + return Err(Error::NoPermission); + } + + // Finally we can actually run the git command. + let program = format!("git-{command}"); + let mut child = std::process::Command::new(program) + .arg(repo_path) + .spawn() + .map_err(|_| Error::NoPermission)?; + let exit_status = child.wait().map_err(|_| Error::NoPermission)?; + + if exit_status.success() { + Ok(()) + } else { + Err(Error::NoPermission) + } +} + +pub fn main() -> std::process::ExitCode { + match git_shell() { + Ok(_) => std::process::ExitCode::SUCCESS, + Err(error) => match error { + Error::Usage => { + eprintln!("usage: `git-shell-multiplex "); + std::process::ExitCode::FAILURE + } + Error::NotAShell { username } => { + eprintln!("Hello {username}! This isn't the shell you're looking for."); + std::process::ExitCode::SUCCESS + } + Error::NoPermission => std::process::ExitCode::FAILURE, + }, + } +} -- 2.49.0