| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | mod common; |
| 8 | use common::{BIN, describe, launch, pick_ports, tool_on_path, write_config}; |
| 9 | use std::fs; |
| 10 | use std::path::{Path, PathBuf}; |
| 11 | use std::process::{Command, Output}; |
| 12 | use std::time::Duration; |
| 13 | |
| 14 | use reqwest::header::{LOCATION, SET_COOKIE, WWW_AUTHENTICATE}; |
| 15 | |
| 16 | const ALICE_PW: &str = "alice-password-123"; |
| 17 | const BOB_PW: &str = "bob-password-4567"; |
| 18 | |
| 19 | |
| 20 | struct Resp { |
| 21 | status: u16, |
| 22 | location: Option<String>, |
| 23 | www_authenticate: Option<String>, |
| 24 | set_cookies: Vec<String>, |
| 25 | body: String, |
| 26 | } |
| 27 | |
| 28 | struct Http { |
| 29 | client: reqwest::Client, |
| 30 | base: String, |
| 31 | } |
| 32 | |
| 33 | impl Http { |
| 34 | fn new(port: u16) -> Self { |
| 35 | let client = reqwest::Client::builder() |
| 36 | .redirect(reqwest::redirect::Policy::none()) |
| 37 | .timeout(Duration::from_secs(60)) |
| 38 | .build() |
| 39 | .expect("http client"); |
| 40 | Self { |
| 41 | client, |
| 42 | base: format!("http://127.0.0.1:{port}"), |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | async fn get(&self, path: &str) -> Resp { |
| 47 | self.send(self.client.get(format!("{}{path}", self.base)), None, None) |
| 48 | .await |
| 49 | } |
| 50 | |
| 51 | async fn get_cookie(&self, path: &str, cookie: &str) -> Resp { |
| 52 | self.send( |
| 53 | self.client.get(format!("{}{path}", self.base)), |
| 54 | Some(cookie), |
| 55 | None, |
| 56 | ) |
| 57 | .await |
| 58 | } |
| 59 | |
| 60 | async fn get_basic(&self, path: &str, user: &str, password: &str) -> Resp { |
| 61 | self.send( |
| 62 | self.client.get(format!("{}{path}", self.base)), |
| 63 | None, |
| 64 | Some((user, password)), |
| 65 | ) |
| 66 | .await |
| 67 | } |
| 68 | |
| 69 | async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { |
| 70 | self.send( |
| 71 | self.client.post(format!("{}{path}", self.base)).form(form), |
| 72 | cookie, |
| 73 | None, |
| 74 | ) |
| 75 | .await |
| 76 | } |
| 77 | |
| 78 | async fn send( |
| 79 | &self, |
| 80 | req: reqwest::RequestBuilder, |
| 81 | cookie: Option<&str>, |
| 82 | basic: Option<(&str, &str)>, |
| 83 | ) -> Resp { |
| 84 | let mut req = req; |
| 85 | if let Some(cookie) = cookie { |
| 86 | req = req.header(reqwest::header::COOKIE, cookie); |
| 87 | } |
| 88 | if let Some((user, password)) = basic { |
| 89 | req = req.basic_auth(user, Some(password)); |
| 90 | } |
| 91 | let res = req.send().await.expect("http request"); |
| 92 | let status = res.status().as_u16(); |
| 93 | let location = header_text(&res, LOCATION); |
| 94 | let www_authenticate = header_text(&res, WWW_AUTHENTICATE); |
| 95 | let set_cookies = res |
| 96 | .headers() |
| 97 | .get_all(SET_COOKIE) |
| 98 | .iter() |
| 99 | .filter_map(|v| v.to_str().ok().map(str::to_owned)) |
| 100 | .collect(); |
| 101 | let body = res.text().await.unwrap_or_default(); |
| 102 | Resp { |
| 103 | status, |
| 104 | location, |
| 105 | www_authenticate, |
| 106 | set_cookies, |
| 107 | body, |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | fn header_text(res: &reqwest::Response, name: reqwest::header::HeaderName) -> Option<String> { |
| 113 | res.headers() |
| 114 | .get(name) |
| 115 | .and_then(|v| v.to_str().ok()) |
| 116 | .map(str::to_owned) |
| 117 | } |
| 118 | |
| 119 | |
| 120 | |
| 121 | fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> { |
| 122 | resp.set_cookies.iter().find_map(|set| { |
| 123 | let pair = set.split(';').next()?; |
| 124 | let (name, value) = pair.split_once('=')?; |
| 125 | (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}")) |
| 126 | }) |
| 127 | } |
| 128 | |
| 129 | |
| 130 | fn input_value(body: &str, name: &str) -> String { |
| 131 | let doc = scraper::Html::parse_document(body); |
| 132 | let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector"); |
| 133 | doc.select(&selector) |
| 134 | .next() |
| 135 | .and_then(|el| el.value().attr("value")) |
| 136 | .unwrap_or_else(|| panic!("no input named {name} on page")) |
| 137 | .to_owned() |
| 138 | } |
| 139 | |
| 140 | fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) { |
| 141 | let out = Command::new(BIN) |
| 142 | .arg("--config") |
| 143 | .arg(config) |
| 144 | .args(["admin", "create-user", username]) |
| 145 | .args(extra) |
| 146 | .env("FORGE_PASSWORD", password) |
| 147 | .output() |
| 148 | .expect("run admin create-user"); |
| 149 | ok(&out, &format!("create-user {username}")); |
| 150 | } |
| 151 | |
| 152 | fn ok(out: &Output, what: &str) { |
| 153 | assert!(out.status.success(), "{what} failed:\n{}", describe(out)); |
| 154 | } |
| 155 | |
| 156 | |
| 157 | fn rejected(out: &Output, what: &str) -> String { |
| 158 | assert!( |
| 159 | !out.status.success(), |
| 160 | "{what} unexpectedly succeeded:\n{}", |
| 161 | describe(out) |
| 162 | ); |
| 163 | describe(out) |
| 164 | } |
| 165 | |
| 166 | fn git(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { |
| 167 | let mut cmd = Command::new("git"); |
| 168 | cmd.current_dir(cwd) |
| 169 | .args(["-c", "credential.helper="]) |
| 170 | .args(args) |
| 171 | .env("GIT_TERMINAL_PROMPT", "0"); |
| 172 | for (key, value) in env { |
| 173 | cmd.env(key, value); |
| 174 | } |
| 175 | cmd.output().expect("run git") |
| 176 | } |
| 177 | |
| 178 | fn path_str(path: &Path) -> &str { |
| 179 | path.to_str().expect("utf-8 path") |
| 180 | } |
| 181 | |
| 182 | |
| 183 | |
| 184 | fn ssh_opts(key: &Path, ssh_port: u16) -> Vec<String> { |
| 185 | [ |
| 186 | "-i", |
| 187 | path_str(key), |
| 188 | "-o", |
| 189 | "IdentitiesOnly=yes", |
| 190 | "-o", |
| 191 | "StrictHostKeyChecking=no", |
| 192 | "-o", |
| 193 | "UserKnownHostsFile=/dev/null", |
| 194 | "-o", |
| 195 | "BatchMode=yes", |
| 196 | "-o", |
| 197 | "LogLevel=ERROR", |
| 198 | "-p", |
| 199 | ] |
| 200 | .iter() |
| 201 | .map(|s| (*s).to_owned()) |
| 202 | .chain(std::iter::once(ssh_port.to_string())) |
| 203 | .collect() |
| 204 | } |
| 205 | |
| 206 | fn ssh_command(key: &Path, ssh_port: u16) -> String { |
| 207 | format!("ssh {}", ssh_opts(key, ssh_port).join(" ")) |
| 208 | } |
| 209 | |
| 210 | fn gen_key(path: &Path) { |
| 211 | let out = Command::new("ssh-keygen") |
| 212 | .args(["-t", "ed25519", "-N", "", "-q", "-f"]) |
| 213 | .arg(path) |
| 214 | .output() |
| 215 | .expect("run ssh-keygen"); |
| 216 | ok(&out, "ssh-keygen"); |
| 217 | } |
| 218 | |
| 219 | fn set_identity(repo: &Path) { |
| 220 | ok( |
| 221 | &git(repo, &["config", "user.name", "alice"], &[]), |
| 222 | "set user.name", |
| 223 | ); |
| 224 | ok( |
| 225 | &git(repo, &["config", "user.email", "alice@example.com"], &[]), |
| 226 | "set user.email", |
| 227 | ); |
| 228 | } |
| 229 | |
| 230 | |
| 231 | fn commit_file(repo: &Path, name: &str, content: &str, message: &str) { |
| 232 | fs::write(repo.join(name), content).expect("write file"); |
| 233 | set_identity(repo); |
| 234 | ok(&git(repo, &["add", name], &[]), "git add"); |
| 235 | ok( |
| 236 | &git( |
| 237 | repo, |
| 238 | &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message], |
| 239 | &[], |
| 240 | ), |
| 241 | "git commit", |
| 242 | ); |
| 243 | } |
| 244 | |
| 245 | fn head_sha(repo: &Path) -> String { |
| 246 | let out = git(repo, &["rev-parse", "HEAD"], &[]); |
| 247 | ok(&out, "rev-parse HEAD"); |
| 248 | String::from_utf8_lossy(&out.stdout).trim().to_owned() |
| 249 | } |
| 250 | |
| 251 | fn read(path: PathBuf) -> String { |
| 252 | fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) |
| 253 | } |
| 254 | |
| 255 | |
| 256 | async fn login(http: &Http, username: &str, password: &str) -> String { |
| 257 | let page = http.get("/login").await; |
| 258 | assert_eq!(page.status, 200, "GET /login"); |
| 259 | let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie"); |
| 260 | let precsrf = input_value(&page.body, "precsrf"); |
| 261 | let res = http |
| 262 | .post_form( |
| 263 | "/login", |
| 264 | &[ |
| 265 | ("username", username), |
| 266 | ("password", password), |
| 267 | ("next", "/"), |
| 268 | ("precsrf", &precsrf), |
| 269 | ], |
| 270 | Some(&precsrf_cookie), |
| 271 | ) |
| 272 | .await; |
| 273 | assert_eq!(res.status, 303, "login as {username}: {}", res.body); |
| 274 | cookie_named(&res, "forge_session").expect("session cookie after login") |
| 275 | } |
| 276 | |
| 277 | |
| 278 | async fn create_token(http: &Http, session: &str, name: &str) -> String { |
| 279 | let page = http.get_cookie("/settings/tokens", session).await; |
| 280 | assert_eq!(page.status, 200, "GET /settings/tokens"); |
| 281 | let csrf = input_value(&page.body, "csrf"); |
| 282 | let res = http |
| 283 | .post_form( |
| 284 | "/settings/tokens", |
| 285 | &[("csrf", &csrf), ("name", name), ("expires", "30")], |
| 286 | Some(session), |
| 287 | ) |
| 288 | .await; |
| 289 | assert_eq!(res.status, 200, "create token: {}", res.body); |
| 290 | let doc = scraper::Html::parse_document(&res.body); |
| 291 | let selector = scraper::Selector::parse("pre.code code").expect("selector"); |
| 292 | let token: String = doc |
| 293 | .select(&selector) |
| 294 | .next() |
| 295 | .expect("token shown after creation") |
| 296 | .text() |
| 297 | .collect(); |
| 298 | let token = token.trim().to_owned(); |
| 299 | assert!(token.starts_with("forge_"), "unexpected token shape"); |
| 300 | token |
| 301 | } |
| 302 | |
| 303 | |
| 304 | async fn add_key(http: &Http, session: &str, public_key: &str) { |
| 305 | let page = http.get_cookie("/settings/keys", session).await; |
| 306 | assert_eq!(page.status, 200, "GET /settings/keys"); |
| 307 | let csrf = input_value(&page.body, "csrf"); |
| 308 | let res = http |
| 309 | .post_form( |
| 310 | "/settings/keys", |
| 311 | &[ |
| 312 | ("csrf", &csrf), |
| 313 | ("title", "e2e"), |
| 314 | ("public_key", public_key), |
| 315 | ], |
| 316 | Some(session), |
| 317 | ) |
| 318 | .await; |
| 319 | assert_eq!(res.status, 303, "add SSH key: {}", res.body); |
| 320 | } |
| 321 | |
| 322 | |
| 323 | async fn create_repo(http: &Http, session: &str, name: &str, visibility: &str) { |
| 324 | let page = http.get_cookie("/new", session).await; |
| 325 | assert_eq!(page.status, 200, "GET /new"); |
| 326 | let csrf = input_value(&page.body, "csrf"); |
| 327 | let res = http |
| 328 | .post_form( |
| 329 | "/new", |
| 330 | &[ |
| 331 | ("csrf", &csrf), |
| 332 | ("name", name), |
| 333 | ("description", ""), |
| 334 | ("visibility", visibility), |
| 335 | ("default_branch", "main"), |
| 336 | ], |
| 337 | Some(session), |
| 338 | ) |
| 339 | .await; |
| 340 | assert_eq!(res.status, 303, "create repo {name}: {}", res.body); |
| 341 | assert_eq!( |
| 342 | res.location.as_deref(), |
| 343 | Some(format!("/alice/{name}").as_str()) |
| 344 | ); |
| 345 | } |
| 346 | |
| 347 | #[tokio::test] |
| 348 | async fn git_end_to_end() { |
| 349 | for tool in ["git", "ssh", "ssh-keygen"] { |
| 350 | if !tool_on_path(tool) { |
| 351 | eprintln!( |
| 352 | "SKIP git_e2e: `{tool}` was not found on PATH, so the end-to-end test is skipped" |
| 353 | ); |
| 354 | return; |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | |
| 359 | let root = tempfile::Builder::new() |
| 360 | .prefix("fe2e") |
| 361 | .tempdir_in("/tmp") |
| 362 | .expect("temp dir"); |
| 363 | let dir = root.path().to_path_buf(); |
| 364 | let ports = pick_ports(); |
| 365 | let config = write_config(&dir, ports); |
| 366 | |
| 367 | run_admin(&config, "alice", &["--admin"], ALICE_PW); |
| 368 | run_admin(&config, "bob", &[], BOB_PW); |
| 369 | |
| 370 | let (_server, ports) = launch(&dir, ports).await; |
| 371 | let http = Http::new(ports.http); |
| 372 | let (http_port, ssh_port) = (ports.http, ports.ssh); |
| 373 | |
| 374 | |
| 375 | let alice = login(&http, "alice", ALICE_PW).await; |
| 376 | let alice_token = create_token(&http, &alice, "e2e-token").await; |
| 377 | |
| 378 | let key = dir.join("id"); |
| 379 | gen_key(&key); |
| 380 | let public_key = fs::read_to_string(dir.join("id.pub")).expect("read id.pub"); |
| 381 | add_key(&http, &alice, public_key.trim()).await; |
| 382 | |
| 383 | create_repo(&http, &alice, "pub", "public").await; |
| 384 | create_repo(&http, &alice, "priv", "private").await; |
| 385 | |
| 386 | |
| 387 | let pub_url = format!("http://alice:{alice_token}@127.0.0.1:{http_port}/alice/pub.git"); |
| 388 | let pub_work = dir.join("pub-work"); |
| 389 | ok( |
| 390 | &git(&dir, &["clone", &pub_url, path_str(&pub_work)], &[]), |
| 391 | "https clone of empty public repo", |
| 392 | ); |
| 393 | commit_file(&pub_work, "hello.txt", "hello over https\n", "https commit"); |
| 394 | ok( |
| 395 | &git(&pub_work, &["push", "origin", "HEAD:main"], &[]), |
| 396 | "https push", |
| 397 | ); |
| 398 | let pushed_main = head_sha(&pub_work); |
| 399 | |
| 400 | let pub_verify = dir.join("pub-verify"); |
| 401 | ok( |
| 402 | &git(&dir, &["clone", &pub_url, path_str(&pub_verify)], &[]), |
| 403 | "https fresh clone", |
| 404 | ); |
| 405 | assert_eq!(read(pub_verify.join("hello.txt")), "hello over https\n"); |
| 406 | |
| 407 | |
| 408 | let anon_url = format!("http://127.0.0.1:{http_port}/alice/pub.git"); |
| 409 | let pub_anon = dir.join("pub-anon"); |
| 410 | ok( |
| 411 | &git(&dir, &["clone", &anon_url, path_str(&pub_anon)], &[]), |
| 412 | "anonymous clone of public repo", |
| 413 | ); |
| 414 | assert_eq!(read(pub_anon.join("hello.txt")), "hello over https\n"); |
| 415 | |
| 416 | |
| 417 | let ssh_cmd = ssh_command(&key, ssh_port); |
| 418 | let ssh_env = [("GIT_SSH_COMMAND", ssh_cmd.as_str())]; |
| 419 | let priv_ssh_url = format!("ssh://git@127.0.0.1:{ssh_port}/alice/priv.git"); |
| 420 | let priv_work = dir.join("priv-work"); |
| 421 | ok( |
| 422 | &git( |
| 423 | &dir, |
| 424 | &["clone", &priv_ssh_url, path_str(&priv_work)], |
| 425 | &ssh_env, |
| 426 | ), |
| 427 | "ssh clone of empty private repo", |
| 428 | ); |
| 429 | commit_file(&priv_work, "secret.txt", "over ssh\n", "ssh commit"); |
| 430 | ok( |
| 431 | &git(&priv_work, &["push", "origin", "HEAD:main"], &ssh_env), |
| 432 | "ssh push", |
| 433 | ); |
| 434 | let priv_verify = dir.join("priv-verify"); |
| 435 | ok( |
| 436 | &git( |
| 437 | &dir, |
| 438 | &["clone", &priv_ssh_url, path_str(&priv_verify)], |
| 439 | &ssh_env, |
| 440 | ), |
| 441 | "ssh fresh clone", |
| 442 | ); |
| 443 | assert_eq!(read(priv_verify.join("secret.txt")), "over ssh\n"); |
| 444 | |
| 445 | |
| 446 | let priv_https_anon = format!("http://127.0.0.1:{http_port}/alice/priv.git"); |
| 447 | rejected( |
| 448 | &git(&dir, &["clone", &priv_https_anon, "anon-priv"], &[]), |
| 449 | "anonymous clone of private repo", |
| 450 | ); |
| 451 | let refs_path = "/alice/priv.git/info/refs?service=git-upload-pack"; |
| 452 | let anon_refs = http.get(refs_path).await; |
| 453 | assert_eq!(anon_refs.status, 401, "anonymous info/refs on private repo"); |
| 454 | assert!( |
| 455 | anon_refs.www_authenticate.is_some(), |
| 456 | "401 must carry WWW-Authenticate" |
| 457 | ); |
| 458 | |
| 459 | |
| 460 | let bob = login(&http, "bob", BOB_PW).await; |
| 461 | let bob_page = http.get_cookie("/alice/priv", &bob).await; |
| 462 | assert_eq!(bob_page.status, 404, "bob's web view of alice/priv"); |
| 463 | let bob_token = create_token(&http, &bob, "bob-token").await; |
| 464 | let bob_refs = http.get_basic(refs_path, "bob", &bob_token).await; |
| 465 | assert_eq!(bob_refs.status, 404, "bob's info/refs on alice/priv"); |
| 466 | |
| 467 | |
| 468 | let wrong = http |
| 469 | .get_basic(refs_path, "alice", "forge_notarealtoken") |
| 470 | .await; |
| 471 | assert_eq!(wrong.status, 401, "wrong token on info/refs"); |
| 472 | let wrong_url = |
| 473 | format!("http://alice:forge_notarealtoken@127.0.0.1:{http_port}/alice/priv.git"); |
| 474 | rejected( |
| 475 | &git(&dir, &["clone", &wrong_url, "wrong-token-clone"], &[]), |
| 476 | "clone with wrong token", |
| 477 | ); |
| 478 | |
| 479 | |
| 480 | let delete = git(&pub_verify, &["push", "origin", "--delete", "main"], &[]); |
| 481 | let text = rejected(&delete, "deleting main"); |
| 482 | assert!( |
| 483 | text.contains("deleting protected branch 'main' is not allowed"), |
| 484 | "delete message: {text}" |
| 485 | ); |
| 486 | |
| 487 | |
| 488 | set_identity(&pub_verify); |
| 489 | ok( |
| 490 | &git( |
| 491 | &pub_verify, |
| 492 | &[ |
| 493 | "-c", |
| 494 | "commit.gpgsign=false", |
| 495 | "commit", |
| 496 | "--amend", |
| 497 | "-q", |
| 498 | "-m", |
| 499 | "rewritten", |
| 500 | ], |
| 501 | &[], |
| 502 | ), |
| 503 | "amend commit", |
| 504 | ); |
| 505 | let force = git(&pub_verify, &["push", "-f", "origin", "HEAD:main"], &[]); |
| 506 | let text = rejected(&force, "force-push to main"); |
| 507 | assert!( |
| 508 | text.contains("force-push to protected branch 'main' is not allowed"), |
| 509 | "force-push message: {text}" |
| 510 | ); |
| 511 | |
| 512 | |
| 513 | let ls = git(&dir, &["ls-remote", &pub_url, "refs/heads/main"], &[]); |
| 514 | ok(&ls, "ls-remote main"); |
| 515 | let remote_main = String::from_utf8_lossy(&ls.stdout); |
| 516 | assert!( |
| 517 | remote_main.starts_with(&pushed_main), |
| 518 | "main moved after rejected pushes: {remote_main}" |
| 519 | ); |
| 520 | |
| 521 | |
| 522 | let rogue = dir.join("rogue"); |
| 523 | gen_key(&rogue); |
| 524 | let rogue_cmd = ssh_command(&rogue, ssh_port); |
| 525 | let rogue_env = [("GIT_SSH_COMMAND", rogue_cmd.as_str())]; |
| 526 | let text = rejected( |
| 527 | &git(&dir, &["clone", &priv_ssh_url, "rogue-clone"], &rogue_env), |
| 528 | "clone with unregistered key", |
| 529 | ); |
| 530 | assert!( |
| 531 | text.contains("Permission denied"), |
| 532 | "rogue key message: {text}" |
| 533 | ); |
| 534 | |
| 535 | |
| 536 | let injected = Command::new("ssh") |
| 537 | .args(ssh_opts(&key, ssh_port)) |
| 538 | .arg("git@127.0.0.1") |
| 539 | .arg("git-upload-pack '../../etc'") |
| 540 | .output() |
| 541 | .expect("run ssh"); |
| 542 | rejected(&injected, "ssh exec with injected path"); |
| 543 | |
| 544 | |
| 545 | let no_csrf = http |
| 546 | .post_form( |
| 547 | "/new", |
| 548 | &[ |
| 549 | ("name", "nocsrf"), |
| 550 | ("visibility", "public"), |
| 551 | ("default_branch", "main"), |
| 552 | ], |
| 553 | Some(&alice), |
| 554 | ) |
| 555 | .await; |
| 556 | assert_eq!(no_csrf.status, 403, "POST /new without csrf"); |
| 557 | |
| 558 | |
| 559 | let dumb = http.get("/alice/pub.git/info/refs").await; |
| 560 | assert_eq!(dumb.status, 403, "info/refs without service"); |
| 561 | assert!( |
| 562 | dumb.body.contains("dumb protocol"), |
| 563 | "dumb body: {}", |
| 564 | dumb.body |
| 565 | ); |
| 566 | } |