//! End-to-end test: real `ssh` and `git` clients against the forge-ssh server. //! Skips with a message when `ssh`, `ssh-keygen` or `git` is not on PATH. use std::collections::HashMap; use std::net::TcpListener as StdListener; use std::path::{Path, PathBuf}; use std::process::Output; use std::time::Duration; use forge_git::GitService; use forge_ssh::{GitBackend, LfsOperation, SshConfig}; use russh::keys::PublicKey; use tokio::process::Command; /// Accepts one registered key and maps `test/repo` to a bare repository on disk. struct TestBackend { algorithm: String, blob: Vec, user_id: String, repos: HashMap, } impl GitBackend for TestBackend { async fn authenticate_key(&self, algorithm: &str, key_blob: &[u8]) -> Option { (algorithm == self.algorithm && key_blob == self.blob.as_slice()) .then(|| self.user_id.clone()) } async fn prepare( &self, user_id: &str, service: GitService, repo_path: &str, _git_protocol: Option<&str>, ) -> Result { if user_id != self.user_id { return Err("forbidden".to_owned()); } let repo = self .repos .get(repo_path) .ok_or_else(|| "repository not found".to_owned())?; let mut cmd = Command::new("git"); cmd.arg(service.name().trim_start_matches("git-")).arg(repo); Ok(cmd) } async fn lfs_authenticate( &self, user_id: &str, repo_path: &str, operation: LfsOperation, ) -> Result { if user_id != self.user_id || !self.repos.contains_key(repo_path) { return Err("repository not found".to_owned()); } let op = match operation { LfsOperation::Download => "download", LfsOperation::Upload => "upload", }; Ok(format!( r#"{{"href":"http://lfs.test/{repo_path}.git/info/lfs","header":{{"Authorization":"RemoteAuth {op}"}},"expires_in":3600}}"# )) } } fn on_path(tool: &str) -> bool { std::env::var_os("PATH") .map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(tool).is_file())) .unwrap_or(false) } fn free_port() -> u16 { let listener = StdListener::bind("127.0.0.1:0").expect("bind free port"); listener.local_addr().expect("local addr").port() } fn ssh_command(key: &Path, port: u16) -> String { format!( "ssh -i {} -p {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes", key.display(), port ) } async fn check(label: &str, cmd: &mut Command) -> Output { let out = cmd .stdin(std::process::Stdio::null()) .output() .await .expect("spawn"); assert!( out.status.success(), "{label} failed: {}\n{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ); out } /// Runs an `ssh` exec with the given remote command and returns the output, expecting failure. async fn ssh_exec(key: &Path, port: u16, remote_cmd: &str) -> Output { Command::new("ssh") .args(["-i"]) .arg(key) .args(["-p", &port.to_string()]) .args([ "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "IdentitiesOnly=yes", ]) .arg("git@127.0.0.1") .arg(remote_cmd) .stdin(std::process::Stdio::null()) .output() .await .expect("spawn ssh") } async fn ssh_exec_fails(key: &Path, port: u16, remote_cmd: &str) -> Output { let out = ssh_exec(key, port, remote_cmd).await; assert!(!out.status.success(), "expected {remote_cmd:?} to fail"); out } async fn wait_for_port(port: u16) { for _ in 0..100 { if tokio::net::TcpStream::connect(("127.0.0.1", port)) .await .is_ok() { return; } tokio::time::sleep(Duration::from_millis(50)).await; } panic!("ssh server did not start listening on port {port}"); } #[tokio::test(flavor = "multi_thread")] async fn clone_push_clone_over_ssh() { for tool in ["ssh", "ssh-keygen", "git"] { if !on_path(tool) { eprintln!("SKIP ssh_e2e: `{tool}` not found on PATH"); return; } } let tmp = tempfile::tempdir().expect("tempdir"); let root = tmp.path(); // Client keys: one registered, one unknown. let key = root.join("id"); let other = root.join("other"); for path in [&key, &other] { check( "ssh-keygen", Command::new("ssh-keygen") .args(["-t", "ed25519", "-N", "", "-q", "-f"]) .arg(path), ) .await; } let pub_line = std::fs::read_to_string(root.join("id.pub")).expect("read pub key"); let public = PublicKey::from_openssh(pub_line.trim()).expect("parse pub key"); let algorithm = public.algorithm().as_str().to_owned(); let blob = public.to_bytes().expect("key blob"); // Bare repository served as test/repo. let bare = root.join("remote").join("test").join("repo.git"); std::fs::create_dir_all(bare.parent().expect("parent")).expect("mkdir"); check( "git init --bare", Command::new("git") .args(["init", "--bare", "-q", "--initial-branch=main"]) .arg(&bare), ) .await; let backend = TestBackend { algorithm, blob, user_id: "user-1".to_owned(), repos: HashMap::from([("test/repo".to_owned(), bare.clone())]), }; let port = free_port(); let host_key = root.join("state").join("ssh_host_ed25519_key"); let config = SshConfig { listen: ([127, 0, 0, 1], port).into(), host_key_path: host_key.clone(), auth_timeout: Duration::from_secs(10), inactivity_timeout: Duration::from_secs(60), }; tokio::spawn(forge_ssh::run(config, std::sync::Arc::new(backend))); wait_for_port(port).await; // The host key is generated with mode 0600. use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(&host_key) .expect("host key exists") .permissions() .mode(); assert_eq!(mode & 0o777, 0o600, "host key must be 0600"); let ssh_env = ssh_command(&key, port); let url = format!("ssh://git@127.0.0.1:{port}/test/repo.git"); // 0. git-lfs-authenticate replies with JSON for a known repo and is refused otherwise. let lfs = ssh_exec(&key, port, "git-lfs-authenticate 'test/repo.git' upload").await; assert!(lfs.status.success(), "lfs authenticate failed: {lfs:?}"); let reply = String::from_utf8_lossy(&lfs.stdout); assert!( reply.contains("http://lfs.test/test/repo.git/info/lfs") && reply.contains("RemoteAuth upload"), "unexpected reply: {reply}" ); ssh_exec_fails(&key, port, "git-lfs-authenticate 'test/missing' download").await; ssh_exec_fails(&key, port, "git-lfs-authenticate '../test/repo' download").await; ssh_exec_fails(&other, port, "git-lfs-authenticate 'test/repo' download").await; // 1. Clone the empty repository with the registered key. let clone1 = root.join("clone1"); check( "clone", Command::new("git") .env("GIT_SSH_COMMAND", &ssh_env) .arg("clone") .arg(&url) .arg(&clone1), ) .await; // 2. Commit and push. An empty clone may start on master, so point HEAD at main. check( "symbolic-ref", Command::new("git") .current_dir(&clone1) .args(["symbolic-ref", "HEAD", "refs/heads/main"]), ) .await; std::fs::write(clone1.join("hello.txt"), "hello from forge\n").expect("write file"); let git_in = |args: &[&str]| { let mut c = Command::new("git"); c.current_dir(&clone1) .env("GIT_SSH_COMMAND", &ssh_env) .env("GIT_AUTHOR_NAME", "test") .env("GIT_AUTHOR_EMAIL", "test@example.invalid") .env("GIT_COMMITTER_NAME", "test") .env("GIT_COMMITTER_EMAIL", "test@example.invalid") .args(args); c }; check("add", &mut git_in(&["add", "hello.txt"])).await; check("commit", &mut git_in(&["commit", "-q", "-m", "add hello"])).await; check("push", &mut git_in(&["push", "-q", "origin", "main"])).await; // 3. Clone again and verify the pushed file. let clone2 = root.join("clone2"); check( "second clone", Command::new("git") .env("GIT_SSH_COMMAND", &ssh_env) .arg("clone") .arg(&url) .arg(&clone2), ) .await; let content = std::fs::read_to_string(clone2.join("hello.txt")).expect("pushed file exists"); assert_eq!(content, "hello from forge\n"); // 4. An unregistered key is rejected. let bad_env = ssh_command(&other, port); let rejected = Command::new("git") .env("GIT_SSH_COMMAND", &bad_env) .arg("clone") .arg(&url) .arg(root.join("clone3")) .stdin(std::process::Stdio::null()) .output() .await .expect("spawn git"); assert!(!rejected.status.success(), "unknown key must not clone"); // 5. Injected or malformed exec requests are rejected by the parser. let injections = [ "git-upload-pack '--help'", "git-upload-pack '/test/repo'; id", "git-upload-pack '../x/y'", "git-upload-pack 'test/repo/extra'", "git-upload-archive '/test/repo'", ]; for cmd in injections { let out = ssh_exec_fails(&key, port, cmd).await; let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("malformed repository path") || stderr.contains("unsupported command"), "unexpected rejection for {cmd:?}: {stderr}" ); assert!(out.stdout.is_empty(), "no stdout for rejected {cmd:?}"); } // 6. A well-formed but unknown repository is refused by the backend. let out = ssh_exec_fails(&key, port, "git-upload-pack '/test/missing.git'").await; assert!(String::from_utf8_lossy(&out.stderr).contains("repository not found")); }