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 for DockerBuildError { fn from(e: io::Error) -> Self { DockerBuildError::IoError(e.to_string()) } } static BUILD_DOCKER_RESULT: Lazy> = 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> = Lazy::new(|| Mutex::new(2022)); fn find_unused_port() -> Result { 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 { 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 { 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(()) }