forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
10.1 KiB
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
4use std::collections::HashMap;
5use std::net::TcpListener as StdListener;
6use std::path::{Path, PathBuf};
7use std::process::Output;
8use std::time::Duration;
9
10use forge_git::GitService;
11use forge_ssh::{GitBackend, LfsOperation, SshConfig};
12use russh::keys::PublicKey;
13use tokio::process::Command;
14
15/// Accepts one registered key and maps `test/repo` to a bare repository on disk.
16struct TestBackend {
17 algorithm: String,
18 blob: Vec<u8>,
19 user_id: String,
20 repos: HashMap<String, PathBuf>,
21}
22
23impl 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
67fn 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
73fn 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
78fn 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
86async 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.
102async 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
123async 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
129async 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")]
143async 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}