Menu
Public repository / main
AkurAI-Build / src/git_shell.rs
Latest change e8aa34a9be50d7d7ea72f66991ff82dfca7d23e1 - OLI-276: Git over SSH, forks and mirrors, code search through akurai-index, repository insights by Ólafur Búi Ólafsson
//! The forced command behind every SSH key (OLI-276): `akurai git-shell --key-id N`.
//! It reads `SSH_ORIGINAL_COMMAND`, allows only `git-upload-pack` and
//! `git-receive-pack` on a hosted repository, applies the same roles as Smart
//! HTTP (read to fetch, write to push, public repositories readable by any
//! key holder) and runs git with the hook environment so branch protection and
//! webhooks work exactly as over HTTP.
use std::{
path::Path,
process::{Command, ExitStatus},
};
use anyhow::{Context, Result, bail, ensure};
use crate::{
access::{self, Role},
config,
db::Database,
hosted_git, ssh_keys,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Service {
UploadPack,
ReceivePack,
}
impl Service {
fn git_subcommand(&self) -> &'static str {
match self {
Service::UploadPack => "upload-pack",
Service::ReceivePack => "receive-pack",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitRequest {
pub service: Service,
pub repository: String,
}
/// Parse what the git client asked ssh to run: `git-upload-pack '/Name.git'`
/// (also `git upload-pack`, no quotes, no leading slash).
pub fn parse_command(original: &str) -> Result<GitRequest> {
let original = original.trim();
let (service, rest) = [
"git-upload-pack",
"git upload-pack",
"git-receive-pack",
"git receive-pack",
]
.into_iter()
.find_map(|word| {
original.strip_prefix(word).map(|rest| {
let service = if word.contains("upload") {
Service::UploadPack
} else {
Service::ReceivePack
};
(service, rest)
})
})
.with_context(|| {
format!("only git-upload-pack and git-receive-pack are allowed over SSH, not `{original}`")
})?;
let argument = rest.trim();
ensure!(
!argument.is_empty() && !argument.contains(char::is_whitespace),
"expected exactly one repository argument"
);
let unquoted = argument
.strip_prefix('\'')
.and_then(|a| a.strip_suffix('\''))
.or_else(|| argument.strip_prefix('"').and_then(|a| a.strip_suffix('"')))
.unwrap_or(argument);
let name = unquoted
.trim_start_matches('/')
.strip_suffix(".git")
.unwrap_or(unquoted.trim_start_matches('/'));
config::validate_repo_name(name).context("invalid repository in the SSH command")?;
Ok(GitRequest {
service,
repository: name.to_owned(),
})
}
/// The role a key holder has: forge admins hold `admin` everywhere, everyone
/// else their collaborator role; a public repository grants `read` to any key.
pub fn role_for(
database: &Database,
subject: &str,
admins: &[String],
repository: &str,
public: bool,
) -> Result<Option<Role>> {
if admins
.iter()
.any(|admin| admin.eq_ignore_ascii_case(subject))
{
return Ok(Some(Role::Admin));
}
let granted = access::collaborator_role(database, repository, subject)?;
Ok(match (granted, public) {
(Some(role), _) => Some(role),
(None, true) => Some(Role::Read),
(None, false) => None,
})
}
pub fn authorize(role: Option<Role>, service: &Service, repository: &str) -> Result<()> {
let needed = match service {
Service::UploadPack => Role::Read,
Service::ReceivePack => Role::Write,
};
match role {
Some(held) if held.allows(needed) => Ok(()),
Some(_) => bail!("{} access to {repository} is required", needed.as_str()),
None => bail!("no access to {repository}"),
}
}
/// Everything `run` needs besides the database.
pub struct Shell<'a> {
pub root: &'a Path,
pub key_id: i64,
pub original_command: &'a str,
pub forge_url: &'a str,
pub forge_token: &'a str,
pub admins: &'a [String],
pub now: i64,
}
/// Serve one SSH git request on the inherited stdin/stdout. Returns git's exit
/// status; refusals are errors (printed by the caller to stderr, which ssh
/// forwards to the client).
pub fn run(database: &Database, shell: &Shell<'_>) -> Result<ExitStatus> {
let request = parse_command(shell.original_command)?;
let key = ssh_keys::key(database, shell.key_id)?.context("unknown SSH key; it was removed")?;
let repository = database
.repository(&request.repository)
.map_err(|_| anyhow::anyhow!("unknown repository {}", request.repository))?;
let role = role_for(
database,
&key.subject,
shell.admins,
&repository.name,
repository.visibility == "public",
)?;
authorize(role, &request.service, &repository.name)?;
let path = hosted_git::repository_path(shell.root, &repository.name)?;
ensure!(
path.is_dir(),
"{} is not hosted on this forge",
repository.name
);
ssh_keys::touch(database, key.id, shell.now)?;
let status = Command::new("git")
.arg(request.service.git_subcommand())
.arg(&path)
.env("REMOTE_USER", &key.subject)
.env("AKURAI_FORGE_URL", shell.forge_url)
.env("AKURAI_FORGE_TOKEN", shell.forge_token)
.env("AKURAI_FORGE_REPO", &repository.name)
.env("GIT_TERMINAL_PROMPT", "0")
.status()
.context("failed to start git")?;
Ok(status)
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn parses_the_forms_git_clients_send() {
for (command, service, name) in [
("git-upload-pack '/Demo.git'", Service::UploadPack, "Demo"),
("git-upload-pack 'Demo.git'", Service::UploadPack, "Demo"),
(
"git-receive-pack \"/Demo.git\"",
Service::ReceivePack,
"Demo",
),
("git receive-pack Demo", Service::ReceivePack, "Demo"),
(
" git-upload-pack '/my-repo.git' ",
Service::UploadPack,
"my-repo",
),
] {
let request = parse_command(command).expect(command);
assert_eq!(request.service, service, "{command}");
assert_eq!(request.repository, name, "{command}");
}
}
#[test]
fn refuses_anything_but_pack_services_on_one_valid_repository() {
for command in [
"",
"bash",
"git-upload-archive 'Demo.git'",
"git-upload-pack",
"git-upload-pack 'a.git' 'b.git'",
"git-upload-pack '../x.git'",
"git-upload-pack '/hosted/x.git'",
"scp -f /etc/passwd",
] {
assert!(parse_command(command).is_err(), "{command:?}");
}
}
#[test]
fn roles_follow_collaborators_admins_and_public_repositories() {
let db = Database::memory(KEY).expect("db");
db.add_repository("demo", "https://example.com/demo.git", "main")
.expect("repo");
db.add_repository("open", "https://example.com/open.git", "main")
.expect("repo");
access::set_collaborator(&db, "demo", "ada@example", Role::Write).expect("collab");
let admins = vec!["ROOT@example".to_string()];
assert_eq!(
role_for(&db, "ada@example", &admins, "demo", false).expect("r"),
Some(Role::Write)
);
assert_eq!(
role_for(&db, "bob@example", &admins, "demo", false).expect("r"),
None
);
assert_eq!(
role_for(&db, "bob@example", &admins, "open", true).expect("r"),
Some(Role::Read)
);
assert_eq!(
role_for(&db, "root@example", &admins, "demo", false).expect("r"),
Some(Role::Admin)
);
assert!(authorize(Some(Role::Read), &Service::UploadPack, "demo").is_ok());
assert!(authorize(Some(Role::Read), &Service::ReceivePack, "demo").is_err());
assert!(authorize(Some(Role::Write), &Service::ReceivePack, "demo").is_ok());
assert!(authorize(None, &Service::UploadPack, "demo").is_err());
}
}