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