--- /dev/null
+//! 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 <pubkey>
+//! command="/home/git/git-shell-multiplex sarah",restrict <pubkey>
+//! ```
+//!
+//! Accepts paths of the form `<user>/<repo>.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 `<GIT_DIR>/<user>/<repo>`. 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<P>(path: P) -> std::io::Result<Lines<BufReader<File>>>
+where
+ P: AsRef<Path>,
+{
+ 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 `<user>/<repo>.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 <username>");
+ 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,
+ },
+ }
+}