AkurAI Build
Menu

Public repository / e8aa34a9be50d7d7ea72f66991ff82dfca7d23e1

AkurAI-Build / tests/ssh_git.rs

Latest change e8aa34a9be50d7d7ea72f66991ff82dfca7d23e1 - OLI-276: Git over SSH, forks and mirrors, code search through akurai-index, repository insights by Ólafur Búi Ólafsson

//! Git over SSH end to end (OLI-276): a key holder clones and pushes through
//! `akurai git-shell` (stood in for sshd by a GIT_SSH_COMMAND wrapper) while the
//! forge is up, so the pre-receive hook really asks it about branch protection.

use std::{fs, path::Path, process::Command};

use akurai_build::{
    access::{self, Role},
    db::Database,
    hosted_git, ssh_keys,
};
use anyhow::{Context, Result, ensure};

mod support;
use support::ProcessServer;

const TOKEN: &str = "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff";
const KEY: &str =
    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGtBqWmJ1v+1AbrlAt4w7EqzPgM4tMUgwFvYZdbQ6N2G test@example";

#[test]
fn key_holders_clone_and_push_over_ssh_with_the_same_roles_as_https() -> Result<()> {
    let work = tempfile::tempdir()?;
    let data = work.path().join("data");
    let key_file = work.path().join("key.bin");
    let token_file = work.path().join("token.txt");
    let webhook_file = work.path().join("webhook.txt");
    fs::create_dir_all(&data)?;
    run_cli(&data, &key_file, &["keygen", "--out", path(&key_file)?])?;
    run_cli(&data, &key_file, &["migrate"])?;
    write_secret(&token_file, TOKEN)?;
    write_secret(
        &webhook_file,
        "aaaabbbbccccddddeeeeffff00001111aaaabbbbccccddddeeeeffff00001111",
    )?;

    // A hosted repository and two key holders: Ada may write, Bob has no role.
    let source = work.path().join("source");
    fs::create_dir(&source)?;
    git(&source, &["init", "-q", "-b", "main"])?;
    git(&source, &["config", "user.name", "AkurAI Test"])?;
    git(&source, &["config", "user.email", "test@example.invalid"])?;
    fs::write(source.join("README.md"), "# demo\n")?;
    git(&source, &["add", "."])?;
    git(&source, &["commit", "-q", "-m", "first"])?;
    let hosted = hosted_git::host(&data, "demo", &source)?;
    let key = fs::read_to_string(&key_file)?;
    let database = Database::open(&data.join("akurai.db"), key.trim(), false)?;
    database.add_repository("demo", path(&hosted)?, "main")?;
    access::set_collaborator(&database, "demo", "ada@example", Role::Write)?;
    let ada = ssh_keys::add_key(&database, "ada@example", "laptop", KEY)?;
    let bob_key = KEY.replacen("IGtBqWmJ1v", "IGtBqWmJ1w", 1); // a different key body
    let bob = ssh_keys::add_key(&database, "bob@example", "laptop", &bob_key)?;
    drop(database);

    let mut command = Command::new(env!("CARGO_BIN_EXE_akurai"));
    command.args([
        "--data",
        path(&data)?,
        "--key-file",
        path(&key_file)?,
        "serve",
        "--address",
        "127.0.0.1:0",
        "--token-file",
        path(&token_file)?,
        "--webhook-secret-file",
        path(&webhook_file)?,
        "--public-origin",
        "http://localhost",
        "--worker-count",
        "1",
    ]);
    for variable in [
        "AKURAI_DATA",
        "AKURAI_KEY_FILE",
        "AKURAI_ADDRESS",
        "AKURAI_TOKEN_FILE",
        "AKURAI_WEBHOOK_SECRET_FILE",
        "AKURAI_PUBLIC_ORIGIN",
        "AKURAI_ALLOW_NATIVE",
        "AKURAI_WORKERS",
        "AKURAI_BUILD_AUTHORIZED_KEYS",
        "AKURAI_BUILD_INDEX_URL",
    ] {
        command.env_remove(variable);
    }
    let server = ProcessServer::start_dynamic(command);
    let forge_url = format!("http://{}", server.address());

    // What sshd would run for each key: the forced command with SSH_ORIGINAL_COMMAND set.
    let wrapper_for = |key_id: i64| -> Result<std::path::PathBuf> {
        let wrapper = work.path().join(format!("ssh-{key_id}.sh"));
        fs::write(
            &wrapper,
            format!(
                "#!/bin/sh\nshift\nexport SSH_ORIGINAL_COMMAND=\"$*\"\nexec {exe} --data {data} --key-file {key} git-shell --key-id {key_id} --token-file {token} --forge-url {forge}\n",
                exe = env!("CARGO_BIN_EXE_akurai"),
                data = path(&data)?,
                key = path(&key_file)?,
                token = path(&token_file)?,
                forge = forge_url,
            ),
        )?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o755))?;
        }
        Ok(wrapper)
    };
    let ada_ssh = wrapper_for(ada.id)?;
    let bob_ssh = wrapper_for(bob.id)?;

    // Bob: no role on a private repository -> refused with a reason.
    let refused = Command::new("git")
        .current_dir(work.path())
        .env("GIT_SSH_COMMAND", path(&bob_ssh)?)
        .args(["clone", "-q", "forge:demo.git", "bob-clone"])
        .output()?;
    ensure!(
        !refused.status.success(),
        "bob must not clone a private repository"
    );
    let stderr = String::from_utf8_lossy(&refused.stderr);
    ensure!(
        stderr.contains("no access to demo"),
        "reason reaches the client: {stderr}"
    );

    // Ada clones, pushes a commit; the pre-receive hook asks the live forge and allows it.
    let clone = work.path().join("ada-clone");
    let cloned = Command::new("git")
        .current_dir(work.path())
        .env("GIT_SSH_COMMAND", path(&ada_ssh)?)
        .args(["clone", "-q", "forge:/demo.git", "ada-clone"])
        .output()?;
    ensure!(
        cloned.status.success(),
        "clone: {}",
        String::from_utf8_lossy(&cloned.stderr)
    );
    ensure!(clone.join("README.md").exists());
    git(&clone, &["config", "user.name", "Ada"])?;
    git(&clone, &["config", "user.email", "ada@example"])?;
    fs::write(clone.join("feature.txt"), "over ssh\n")?;
    git(&clone, &["add", "."])?;
    git(&clone, &["commit", "-q", "-m", "pushed over ssh"])?;
    let pushed = Command::new("git")
        .current_dir(&clone)
        .env("GIT_SSH_COMMAND", path(&ada_ssh)?)
        .args(["push", "-q", "origin", "main"])
        .output()?;
    ensure!(
        pushed.status.success(),
        "push: {}",
        String::from_utf8_lossy(&pushed.stderr)
    );
    let head = String::from_utf8(
        Command::new("git")
            .arg("-C")
            .arg(&hosted)
            .args(["rev-parse", "main"])
            .output()?
            .stdout,
    )?;
    let local = String::from_utf8(
        Command::new("git")
            .arg("-C")
            .arg(&clone)
            .args(["rev-parse", "main"])
            .output()?
            .stdout,
    )?;
    ensure!(head == local, "the hosted repository has the pushed commit");

    // Branch protection still applies over SSH: the default rule forbids force-pushes.
    git(&clone, &["reset", "-q", "--hard", "HEAD~1"])?;
    let forced = Command::new("git")
        .current_dir(&clone)
        .env("GIT_SSH_COMMAND", path(&ada_ssh)?)
        .args(["push", "-q", "--force", "origin", "main"])
        .output()?;
    ensure!(
        !forced.status.success(),
        "force-push must be rejected by the pre-receive hook"
    );
    let stderr = String::from_utf8_lossy(&forced.stderr);
    ensure!(stderr.contains("rejected"), "{stderr}");

    // Only pack services run: anything else is refused before git starts.
    let shell = Command::new(env!("CARGO_BIN_EXE_akurai"))
        .args([
            "--data",
            path(&data)?,
            "--key-file",
            path(&key_file)?,
            "git-shell",
            "--key-id",
            &ada.id.to_string(),
            "--token-file",
            path(&token_file)?,
        ])
        .env("SSH_ORIGINAL_COMMAND", "bash -i")
        .output()?;
    ensure!(!shell.status.success());
    ensure!(
        String::from_utf8_lossy(&shell.stderr)
            .contains("only git-upload-pack and git-receive-pack")
    );

    // The key was used: last_used_at is set.
    let database = Database::open(&data.join("akurai.db"), key.trim(), false)?;
    let used = ssh_keys::key(&database, ada.id)?.context("ada's key")?;
    ensure!(used.last_used_at.is_some(), "last_used_at recorded");
    Ok(())
}

fn run_cli(data: &Path, key_file: &Path, arguments: &[&str]) -> Result<()> {
    let output = Command::new(env!("CARGO_BIN_EXE_akurai"))
        .args(["--data", path(data)?, "--key-file", path(key_file)?])
        .args(arguments)
        .env_remove("AKURAI_DATA")
        .env_remove("AKURAI_KEY_FILE")
        .output()?;
    ensure!(
        output.status.success(),
        "akurai {arguments:?}: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    Ok(())
}

fn write_secret(path: &Path, value: &str) -> Result<()> {
    fs::write(path, value)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
    }
    Ok(())
}

fn git(directory: &Path, arguments: &[&str]) -> Result<()> {
    let output = Command::new("git")
        .current_dir(directory)
        .args(arguments)
        .output()?;
    ensure!(
        output.status.success(),
        "git {arguments:?}: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    Ok(())
}

fn path(path: &Path) -> Result<&str> {
    path.to_str().context("path is not UTF-8")
}