AkurAI Build
Menu

Public repository / 8d41f1e10aceed387d425b43038f2070e399a349

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());
    }
}