shackle-shell 0.4.1

A shell for restricting access on a version control server
Documentation
use anyhow::Result;
use assert_cmd::{assert::Assert, cargo::cargo_bin, Command};
use get_port::{tcp::TcpPort, Ops, Range};
use once_cell::sync::Lazy;
use rexpect::session::{spawn_command, PtySession};
use std::{fs, io, path, sync::Mutex};
use tempfile::TempDir;
use thiserror::Error;

const GIT_SSH_COMMAND: &str = "ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no";

struct TestContext {
    workdir: TempDir,
    ssh_port: u16,
    docker_process: PtySession,
}

impl Drop for TestContext {
    fn drop(&mut self) {
        self.docker_process.send_line("exit").unwrap();
    }
}

#[derive(Error, Debug, Clone)]
pub enum DockerBuildError {
    #[error(transparent)]
    StripPrefixError(#[from] path::StripPrefixError),
    #[error("IO Error: `{0}`")]
    IoError(String),
    #[error("Failed to build dockerfile")]
    CliError,
}

impl From<io::Error> for DockerBuildError {
    fn from(e: io::Error) -> Self {
        DockerBuildError::IoError(e.to_string())
    }
}

static BUILD_DOCKER_RESULT: Lazy<Result<(), DockerBuildError>> = Lazy::new(|| {
    let mut command = std::process::Command::new("docker");

    let absolute_shell_path = cargo_bin(env!("CARGO_PKG_NAME"));
    let relative_shell_path = absolute_shell_path.strip_prefix(std::fs::canonicalize(".")?)?;
    command.args([
        "build",
        "-t",
        "shackle-server",
        "--build-arg",
        &format!("SHELL={}", relative_shell_path.display()),
        "./",
    ]);

    let status = command.status()?;
    if status.success() {
        Ok(())
    } else {
        Err(DockerBuildError::CliError)
    }
});

fn build_docker_image() -> Result<(), DockerBuildError> {
    BUILD_DOCKER_RESULT.clone()
}

#[derive(Error, Debug, Clone)]
pub enum PortAssignmentError {
    #[error("Mutex Error: `{0}`")]
    MutexError(String),
    #[error("Couldn't find an available port")]
    NoMorePorts,
}

static LAST_USED_PORT: Lazy<Mutex<u16>> = Lazy::new(|| Mutex::new(2022));
fn find_unused_port() -> Result<u16, PortAssignmentError> {
    let mut last_used = LAST_USED_PORT
        .lock()
        .map_err(|e| PortAssignmentError::MutexError(e.to_string()))?;

    let port = TcpPort::in_range(
        "127.0.0.1",
        Range {
            min: *last_used + 1,
            max: 3022,
        },
    )
    .ok_or(PortAssignmentError::NoMorePorts)?;

    *last_used = port;

    Ok(port)
}

fn spawn_ssh_server() -> Result<TestContext> {
    build_docker_image()?;

    let workdir = tempfile::tempdir()?;
    let ssh_port = find_unused_port()?;

    let mut command = std::process::Command::new("docker");
    command.args([
        "run",
        "-it",
        "-p",
        &format!("{}:22", ssh_port),
        "shackle-server",
    ]);
    command.current_dir(&workdir);
    let mut docker_process = spawn_command(command, Some(3000))?;
    docker_process.exp_string("Ready")?;
    Ok(TestContext {
        workdir,
        ssh_port,
        docker_process,
    })
}

fn connect_to_ssh_server_interactively(c: &TestContext) -> Result<PtySession> {
    let mut command = std::process::Command::new("ssh");
    command.args([
        "-p",
        &c.ssh_port.to_string(),
        "shukkie@localhost",
        "-o",
        "UserKnownHostsFile=/dev/null",
        "-o",
        "StrictHostKeyChecking=false",
    ]);
    command.current_dir(&c.workdir);
    let mut p = spawn_command(command, Some(3000))?;
    expect_prompt(&mut p)?;
    Ok(p)
}

fn expect_prompt(p: &mut PtySession) -> Result<()> {
    p.exp_string("> ")?;
    Ok(())
}

fn make_new_repo(c: &TestContext, repo_name: &str) -> Result<()> {
    let mut p = connect_to_ssh_server_interactively(&c)?;
    p.send_line(&format!("init {}", repo_name))?;
    p.exp_string(&format!(
        "Successfully created \"git/shukkie/{}.git\"",
        repo_name
    ))?;
    expect_prompt(&mut p)?;
    p.send_line("exit")?;
    p.exp_eof()?;
    Ok(())
}

fn make_new_shared_repo(c: &TestContext, group: &str, repo_name: &str) -> Result<()> {
    let mut p = connect_to_ssh_server_interactively(&c)?;
    p.send_line(&format!("init --group {} {}", group, repo_name))?;
    p.exp_string(&format!(
        "Successfully created \"git/{}/{}.git\"",
        group, repo_name
    ))?;
    expect_prompt(&mut p)?;
    p.send_line("exit")?;
    p.exp_eof()?;
    Ok(())
}

#[test]
#[cfg_attr(not(feature = "docker_tests"), ignore)]
fn shows_a_prompt() -> Result<()> {
    let c = spawn_ssh_server()?;
    connect_to_ssh_server_interactively(&c)?;
    Ok(())
}

fn clone_git_repo(c: &TestContext, path: &str) -> Assert {
    Command::new("git")
        .args([
            "clone",
            &format!("ssh://shukkie@localhost:{}{}", c.ssh_port, path),
        ])
        .env("GIT_SSH_COMMAND", GIT_SSH_COMMAND)
        .current_dir(&c.workdir)
        .timeout(std::time::Duration::from_secs(3))
        .assert()
}

fn clone_git_repo_relative_personal_path(c: &TestContext, repo_name: &str) -> Assert {
    clone_git_repo(c, &format!("/~/git/shukkie/{}.git", repo_name))
}

fn clone_git_repo_relative_shared_path(c: &TestContext, group: &str, repo_name: &str) -> Assert {
    clone_git_repo(c, &format!("/~/git/{}/{}.git", group, repo_name))
}

fn push_git_repo(c: &TestContext, repo_name: &str) -> Assert {
    let repo_dir = c.workdir.as_ref().join(repo_name);
    Command::new("git")
        .args(["push", "origin", "main"])
        .current_dir(&repo_dir)
        .env("GIT_SSH_COMMAND", GIT_SSH_COMMAND)
        .timeout(std::time::Duration::from_secs(3))
        .assert()
}

fn commit_dummy_content(c: &TestContext, repo_name: &str) -> Result<()> {
    let repo_dir = c.workdir.as_ref().join(repo_name);

    Command::new("git")
        .args(["config", "user.email", "shukkie@example.com"])
        .current_dir(&repo_dir)
        .assert()
        .success();
    Command::new("git")
        .args(["config", "user.name", "Shukkie"])
        .current_dir(&repo_dir)
        .assert()
        .success();
    Command::new("git")
        .args(["checkout", "-b", "main"])
        .current_dir(&repo_dir)
        .assert()
        .success();

    let file_name = "yay-a-file";
    let file_path = repo_dir.join(file_name);
    fs::write(&file_path, "doesn't matter what this is")?;

    Command::new("git")
        .args(["add", "-A"])
        .current_dir(&repo_dir)
        .assert()
        .success();
    Command::new("git")
        .args(["commit", "-m", "commitment"])
        .current_dir(&repo_dir)
        .assert()
        .success();
    Ok(())
}

#[test]
#[cfg_attr(not(feature = "docker_tests"), ignore)]
fn git_clone_works_with_an_empty_repo() -> Result<()> {
    let c = spawn_ssh_server()?;
    let repo_name = "my-new-clonable-repo";
    make_new_repo(&c, repo_name)?;
    clone_git_repo_relative_personal_path(&c, repo_name).success();

    Ok(())
}

#[test]
#[cfg_attr(not(feature = "docker_tests"), ignore)]
fn git_push_works() -> Result<()> {
    let c = spawn_ssh_server()?;
    let repo_name = "my-new-pushable-repo";
    make_new_repo(&c, repo_name)?;
    clone_git_repo_relative_personal_path(&c, repo_name).success();
    commit_dummy_content(&c, repo_name)?;
    push_git_repo(&c, repo_name).success();

    Ok(())
}

#[test]
#[cfg_attr(not(feature = "docker_tests"), ignore)]
fn git_clone_works_with_an_empty_shared_repo() -> Result<()> {
    let c = spawn_ssh_server()?;
    let repo_name = "my-new-clonable-repo";
    let group = "shukkies-company";
    make_new_shared_repo(&c, group, repo_name)?;
    clone_git_repo_relative_shared_path(&c, group, repo_name).success();

    Ok(())
}

#[test]
#[cfg_attr(not(feature = "docker_tests"), ignore)]
fn git_push_works_with_shared_repo() -> Result<()> {
    let c = spawn_ssh_server()?;
    let repo_name = "my-new-pushable-repo";
    let group = "shukkies-company";
    make_new_shared_repo(&c, group, repo_name)?;
    clone_git_repo_relative_shared_path(&c, group, repo_name).success();
    commit_dummy_content(&c, repo_name)?;
    push_git_repo(&c, repo_name).success();

    Ok(())
}

#[test]
#[cfg_attr(not(feature = "docker_tests"), ignore)]
fn git_clone_can_not_target_repo_outside_allowed_paths() -> Result<()> {
    fn test_git_clone_unallowed_path(repo_name: &str) -> Result<()> {
        let c = spawn_ssh_server()?;
        clone_git_repo(&c, &format!("/~/{}.git", repo_name))
            .failure()
            .stderr(predicates::str::contains("Path is not accessible"));
        Ok(())
    }
    test_git_clone_unallowed_path("disallowed")?;
    test_git_clone_unallowed_path("disallowed-doesnt-exist")?;
    Ok(())
}

fn init_local_git_dir(c: &TestContext, repo_name: &str) {
    Command::new("git")
        .args(["init", repo_name])
        .current_dir(&c.workdir)
        .timeout(std::time::Duration::from_secs(3))
        .assert()
        .success();

    let repo_dir = c.workdir.as_ref().join(repo_name);
    Command::new("git")
        .args([
            "remote",
            "add",
            "origin",
            &format!("ssh://shukkie@localhost:{}/~/{}.git", c.ssh_port, repo_name),
        ])
        .current_dir(&repo_dir)
        .timeout(std::time::Duration::from_secs(3))
        .assert()
        .success();
}

#[test]
#[cfg_attr(not(feature = "docker_tests"), ignore)]
fn git_push_can_not_target_repo_outside_allowed_paths() -> Result<()> {
    fn test_push_to_unallowed_path(repo_name: &str) -> Result<()> {
        let c = spawn_ssh_server()?;
        init_local_git_dir(&c, &repo_name);
        commit_dummy_content(&c, repo_name)?;

        push_git_repo(&c, repo_name)
            .failure()
            .stderr(predicates::str::contains("Path is not accessible"));

        Ok(())
    }

    test_push_to_unallowed_path("disallowed")?;
    test_push_to_unallowed_path("disallowed-doesnt-exist")?;
    Ok(())
}