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")
}