Tahinli/forge
public0 watching
★ 0
| 1 | //! End-to-end test: real `ssh` and `git` clients against the forge-ssh server. |
| 2 | //! Skips with a message when `ssh`, `ssh-keygen` or `git` is not on PATH. |
| 3 | |
| 4 | use std::collections::HashMap; |
| 5 | use std::net::TcpListener as StdListener; |
| 6 | use std::path::{Path, PathBuf}; |
| 7 | use std::process::Output; |
| 8 | use std::time::Duration; |
| 9 | |
| 10 | use forge_git::GitService; |
| 11 | use forge_ssh::{GitBackend, LfsOperation, SshConfig}; |
| 12 | use russh::keys::PublicKey; |
| 13 | use tokio::process::Command; |
| 14 | |
| 15 | /// Accepts one registered key and maps `test/repo` to a bare repository on disk. |
| 16 | struct TestBackend { |
| 17 | algorithm: String, |
| 18 | blob: Vec<u8>, |
| 19 | user_id: String, |
| 20 | repos: HashMap<String, PathBuf>, |
| 21 | } |
| 22 | |
| 23 | impl GitBackend for TestBackend { |
| 24 | async fn authenticate_key(&self, algorithm: &str, key_blob: &[u8]) -> Option<String> { |
| 25 | (algorithm == self.algorithm && key_blob == self.blob.as_slice()) |
| 26 | .then(|| self.user_id.clone()) |
| 27 | } |
| 28 | |
| 29 | async fn prepare( |
| 30 | &self, |
| 31 | user_id: &str, |
| 32 | service: GitService, |
| 33 | repo_path: &str, |
| 34 | _git_protocol: Option<&str>, |
| 35 | ) -> Result<Command, String> { |
| 36 | if user_id != self.user_id { |
| 37 | return Err("forbidden".to_owned()); |
| 38 | } |
| 39 | let repo = self |
| 40 | .repos |
| 41 | .get(repo_path) |
| 42 | .ok_or_else(|| "repository not found".to_owned())?; |
| 43 | let mut cmd = Command::new("git"); |
| 44 | cmd.arg(service.name().trim_start_matches("git-")).arg(repo); |
| 45 | Ok(cmd) |
| 46 | } |
| 47 | |
| 48 | async fn lfs_authenticate( |
| 49 | &self, |
| 50 | user_id: &str, |
| 51 | repo_path: &str, |
| 52 | operation: LfsOperation, |
| 53 | ) -> Result<String, String> { |
| 54 | if user_id != self.user_id || !self.repos.contains_key(repo_path) { |
| 55 | return Err("repository not found".to_owned()); |
| 56 | } |
| 57 | let op = match operation { |
| 58 | LfsOperation::Download => "download", |
| 59 | LfsOperation::Upload => "upload", |
| 60 | }; |
| 61 | Ok(format!( |
| 62 | r#"{{"href":"http://lfs.test/{repo_path}.git/info/lfs","header":{{"Authorization":"RemoteAuth {op}"}},"expires_in":3600}}"# |
| 63 | )) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | fn on_path(tool: &str) -> bool { |
| 68 | std::env::var_os("PATH") |
| 69 | .map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(tool).is_file())) |
| 70 | .unwrap_or(false) |
| 71 | } |
| 72 | |
| 73 | fn free_port() -> u16 { |
| 74 | let listener = StdListener::bind("127.0.0.1:0").expect("bind free port"); |
| 75 | listener.local_addr().expect("local addr").port() |
| 76 | } |
| 77 | |
| 78 | fn ssh_command(key: &Path, port: u16) -> String { |
| 79 | format!( |
| 80 | "ssh -i {} -p {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes", |
| 81 | key.display(), |
| 82 | port |
| 83 | ) |
| 84 | } |
| 85 | |
| 86 | async fn check(label: &str, cmd: &mut Command) -> Output { |
| 87 | let out = cmd |
| 88 | .stdin(std::process::Stdio::null()) |
| 89 | .output() |
| 90 | .await |
| 91 | .expect("spawn"); |
| 92 | assert!( |
| 93 | out.status.success(), |
| 94 | "{label} failed: {}\n{}", |
| 95 | String::from_utf8_lossy(&out.stdout), |
| 96 | String::from_utf8_lossy(&out.stderr) |
| 97 | ); |
| 98 | out |
| 99 | } |
| 100 | |
| 101 | /// Runs an `ssh` exec with the given remote command and returns the output, expecting failure. |
| 102 | async fn ssh_exec(key: &Path, port: u16, remote_cmd: &str) -> Output { |
| 103 | Command::new("ssh") |
| 104 | .args(["-i"]) |
| 105 | .arg(key) |
| 106 | .args(["-p", &port.to_string()]) |
| 107 | .args([ |
| 108 | "-o", |
| 109 | "StrictHostKeyChecking=no", |
| 110 | "-o", |
| 111 | "UserKnownHostsFile=/dev/null", |
| 112 | "-o", |
| 113 | "IdentitiesOnly=yes", |
| 114 | ]) |
| 115 | .arg("git@127.0.0.1") |
| 116 | .arg(remote_cmd) |
| 117 | .stdin(std::process::Stdio::null()) |
| 118 | .output() |
| 119 | .await |
| 120 | .expect("spawn ssh") |
| 121 | } |
| 122 | |
| 123 | async fn ssh_exec_fails(key: &Path, port: u16, remote_cmd: &str) -> Output { |
| 124 | let out = ssh_exec(key, port, remote_cmd).await; |
| 125 | assert!(!out.status.success(), "expected {remote_cmd:?} to fail"); |
| 126 | out |
| 127 | } |
| 128 | |
| 129 | async fn wait_for_port(port: u16) { |
| 130 | for _ in 0..100 { |
| 131 | if tokio::net::TcpStream::connect(("127.0.0.1", port)) |
| 132 | .await |
| 133 | .is_ok() |
| 134 | { |
| 135 | return; |
| 136 | } |
| 137 | tokio::time::sleep(Duration::from_millis(50)).await; |
| 138 | } |
| 139 | panic!("ssh server did not start listening on port {port}"); |
| 140 | } |
| 141 | |
| 142 | #[tokio::test(flavor = "multi_thread")] |
| 143 | async fn clone_push_clone_over_ssh() { |
| 144 | for tool in ["ssh", "ssh-keygen", "git"] { |
| 145 | if !on_path(tool) { |
| 146 | eprintln!("SKIP ssh_e2e: `{tool}` not found on PATH"); |
| 147 | return; |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 152 | let root = tmp.path(); |
| 153 | |
| 154 | // Client keys: one registered, one unknown. |
| 155 | let key = root.join("id"); |
| 156 | let other = root.join("other"); |
| 157 | for path in [&key, &other] { |
| 158 | check( |
| 159 | "ssh-keygen", |
| 160 | Command::new("ssh-keygen") |
| 161 | .args(["-t", "ed25519", "-N", "", "-q", "-f"]) |
| 162 | .arg(path), |
| 163 | ) |
| 164 | .await; |
| 165 | } |
| 166 | let pub_line = std::fs::read_to_string(root.join("id.pub")).expect("read pub key"); |
| 167 | let public = PublicKey::from_openssh(pub_line.trim()).expect("parse pub key"); |
| 168 | let algorithm = public.algorithm().as_str().to_owned(); |
| 169 | let blob = public.to_bytes().expect("key blob"); |
| 170 | |
| 171 | // Bare repository served as test/repo. |
| 172 | let bare = root.join("remote").join("test").join("repo.git"); |
| 173 | std::fs::create_dir_all(bare.parent().expect("parent")).expect("mkdir"); |
| 174 | check( |
| 175 | "git init --bare", |
| 176 | Command::new("git") |
| 177 | .args(["init", "--bare", "-q", "--initial-branch=main"]) |
| 178 | .arg(&bare), |
| 179 | ) |
| 180 | .await; |
| 181 | |
| 182 | let backend = TestBackend { |
| 183 | algorithm, |
| 184 | blob, |
| 185 | user_id: "user-1".to_owned(), |
| 186 | repos: HashMap::from([("test/repo".to_owned(), bare.clone())]), |
| 187 | }; |
| 188 | |
| 189 | let port = free_port(); |
| 190 | let host_key = root.join("state").join("ssh_host_ed25519_key"); |
| 191 | let config = SshConfig { |
| 192 | listen: ([127, 0, 0, 1], port).into(), |
| 193 | host_key_path: host_key.clone(), |
| 194 | auth_timeout: Duration::from_secs(10), |
| 195 | inactivity_timeout: Duration::from_secs(60), |
| 196 | }; |
| 197 | tokio::spawn(forge_ssh::run(config, std::sync::Arc::new(backend))); |
| 198 | wait_for_port(port).await; |
| 199 | |
| 200 | // The host key is generated with mode 0600. |
| 201 | use std::os::unix::fs::PermissionsExt; |
| 202 | let mode = std::fs::metadata(&host_key) |
| 203 | .expect("host key exists") |
| 204 | .permissions() |
| 205 | .mode(); |
| 206 | assert_eq!(mode & 0o777, 0o600, "host key must be 0600"); |
| 207 | |
| 208 | let ssh_env = ssh_command(&key, port); |
| 209 | let url = format!("ssh://git@127.0.0.1:{port}/test/repo.git"); |
| 210 | |
| 211 | // 0. git-lfs-authenticate replies with JSON for a known repo and is refused otherwise. |
| 212 | let lfs = ssh_exec(&key, port, "git-lfs-authenticate 'test/repo.git' upload").await; |
| 213 | assert!(lfs.status.success(), "lfs authenticate failed: {lfs:?}"); |
| 214 | let reply = String::from_utf8_lossy(&lfs.stdout); |
| 215 | assert!( |
| 216 | reply.contains("http://lfs.test/test/repo.git/info/lfs") |
| 217 | && reply.contains("RemoteAuth upload"), |
| 218 | "unexpected reply: {reply}" |
| 219 | ); |
| 220 | ssh_exec_fails(&key, port, "git-lfs-authenticate 'test/missing' download").await; |
| 221 | ssh_exec_fails(&key, port, "git-lfs-authenticate '../test/repo' download").await; |
| 222 | ssh_exec_fails(&other, port, "git-lfs-authenticate 'test/repo' download").await; |
| 223 | |
| 224 | // 1. Clone the empty repository with the registered key. |
| 225 | let clone1 = root.join("clone1"); |
| 226 | check( |
| 227 | "clone", |
| 228 | Command::new("git") |
| 229 | .env("GIT_SSH_COMMAND", &ssh_env) |
| 230 | .arg("clone") |
| 231 | .arg(&url) |
| 232 | .arg(&clone1), |
| 233 | ) |
| 234 | .await; |
| 235 | |
| 236 | // 2. Commit and push. An empty clone may start on master, so point HEAD at main. |
| 237 | check( |
| 238 | "symbolic-ref", |
| 239 | Command::new("git") |
| 240 | .current_dir(&clone1) |
| 241 | .args(["symbolic-ref", "HEAD", "refs/heads/main"]), |
| 242 | ) |
| 243 | .await; |
| 244 | std::fs::write(clone1.join("hello.txt"), "hello from forge\n").expect("write file"); |
| 245 | let git_in = |args: &[&str]| { |
| 246 | let mut c = Command::new("git"); |
| 247 | c.current_dir(&clone1) |
| 248 | .env("GIT_SSH_COMMAND", &ssh_env) |
| 249 | .env("GIT_AUTHOR_NAME", "test") |
| 250 | .env("GIT_AUTHOR_EMAIL", "test@example.invalid") |
| 251 | .env("GIT_COMMITTER_NAME", "test") |
| 252 | .env("GIT_COMMITTER_EMAIL", "test@example.invalid") |
| 253 | .args(args); |
| 254 | c |
| 255 | }; |
| 256 | check("add", &mut git_in(&["add", "hello.txt"])).await; |
| 257 | check("commit", &mut git_in(&["commit", "-q", "-m", "add hello"])).await; |
| 258 | check("push", &mut git_in(&["push", "-q", "origin", "main"])).await; |
| 259 | |
| 260 | // 3. Clone again and verify the pushed file. |
| 261 | let clone2 = root.join("clone2"); |
| 262 | check( |
| 263 | "second clone", |
| 264 | Command::new("git") |
| 265 | .env("GIT_SSH_COMMAND", &ssh_env) |
| 266 | .arg("clone") |
| 267 | .arg(&url) |
| 268 | .arg(&clone2), |
| 269 | ) |
| 270 | .await; |
| 271 | let content = std::fs::read_to_string(clone2.join("hello.txt")).expect("pushed file exists"); |
| 272 | assert_eq!(content, "hello from forge\n"); |
| 273 | |
| 274 | // 4. An unregistered key is rejected. |
| 275 | let bad_env = ssh_command(&other, port); |
| 276 | let rejected = Command::new("git") |
| 277 | .env("GIT_SSH_COMMAND", &bad_env) |
| 278 | .arg("clone") |
| 279 | .arg(&url) |
| 280 | .arg(root.join("clone3")) |
| 281 | .stdin(std::process::Stdio::null()) |
| 282 | .output() |
| 283 | .await |
| 284 | .expect("spawn git"); |
| 285 | assert!(!rejected.status.success(), "unknown key must not clone"); |
| 286 | |
| 287 | // 5. Injected or malformed exec requests are rejected by the parser. |
| 288 | let injections = [ |
| 289 | "git-upload-pack '--help'", |
| 290 | "git-upload-pack '/test/repo'; id", |
| 291 | "git-upload-pack '../x/y'", |
| 292 | "git-upload-pack 'test/repo/extra'", |
| 293 | "git-upload-archive '/test/repo'", |
| 294 | ]; |
| 295 | for cmd in injections { |
| 296 | let out = ssh_exec_fails(&key, port, cmd).await; |
| 297 | let stderr = String::from_utf8_lossy(&out.stderr); |
| 298 | assert!( |
| 299 | stderr.contains("malformed repository path") || stderr.contains("unsupported command"), |
| 300 | "unexpected rejection for {cmd:?}: {stderr}" |
| 301 | ); |
| 302 | assert!(out.stdout.is_empty(), "no stdout for rejected {cmd:?}"); |
| 303 | } |
| 304 | |
| 305 | // 6. A well-formed but unknown repository is refused by the backend. |
| 306 | let out = ssh_exec_fails(&key, port, "git-upload-pack '/test/missing.git'").await; |
| 307 | assert!(String::from_utf8_lossy(&out.stderr).contains("repository not found")); |
| 308 | } |