//! Black-box browse test. It starts the real `forge` binary with three listeners (app, ssh and //! the raw user-content origin), pushes fixtures over HTTPS with a token, and checks the app pages //! and the raw origin with plain HTTP. //! //! Cookies are handled by hand, as in `git_e2e.rs`: the session cookie is `__Host-forge_session` //! with `Secure`, so a cookie jar would not send it over plain http. mod common; use common::{BIN, describe, launch, pick_ports, tool_on_path, write_config}; use std::fs; use std::path::Path; use std::process::{Command, Output}; use std::time::Duration; use reqwest::header::{ CONTENT_DISPOSITION, CONTENT_SECURITY_POLICY, CONTENT_TYPE, COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE, X_CONTENT_TYPE_OPTIONS, }; const ALICE_PW: &str = "alice-password-123"; const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x00IEND\xaeB`\x82"; /// A received HTTP response. The body is kept as bytes because the archive is binary. struct Resp { status: u16, headers: HeaderMap, bytes: Vec, } impl Resp { fn body(&self) -> String { String::from_utf8_lossy(&self.bytes).into_owned() } fn header(&self, name: HeaderName) -> Option { self.headers .get(name) .and_then(|v| v.to_str().ok()) .map(str::to_owned) } fn set_cookies(&self) -> Vec { self.headers .get_all(SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok().map(str::to_owned)) .collect() } } /// A client for one listener. `base` is the origin, e.g. `http://127.0.0.1:8080`. struct Http { client: reqwest::Client, base: String, } impl Http { fn new(port: u16) -> Self { let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(Duration::from_secs(60)) .build() .expect("http client"); Self { client, base: format!("http://127.0.0.1:{port}"), } } async fn get(&self, path: &str, cookie: Option<&str>) -> Resp { self.send(self.client.get(format!("{}{path}", self.base)), cookie) .await } async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { self.send( self.client.post(format!("{}{path}", self.base)).form(form), cookie, ) .await } async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp { let req = match cookie { Some(cookie) => req.header(COOKIE, cookie), None => req, }; let res = req.send().await.expect("http request"); let status = res.status().as_u16(); let headers = res.headers().clone(); let bytes = res.bytes().await.expect("read body").to_vec(); Resp { status, headers, bytes, } } } /// Returns `name=value` for the cookie whose name ends with `suffix`, e.g. `forge_session` /// matches `__Host-forge_session`. Cleared cookies (empty value) are ignored. fn cookie_named(resp: &Resp, suffix: &str) -> Option { resp.set_cookies().iter().find_map(|set| { let pair = set.split(';').next()?; let (name, value) = pair.split_once('=')?; (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}")) }) } /// The value of the first `` on a page. fn input_value(body: &str, name: &str) -> String { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector"); doc.select(&selector) .next() .and_then(|el| el.value().attr("value")) .unwrap_or_else(|| panic!("no input named {name} on page")) .to_owned() } /// The values of `attr` on every element matching `selector`, in document order. fn attr_values(body: &str, selector: &str, attr: &str) -> Vec { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse(selector).expect("selector"); doc.select(&selector) .filter_map(|el| el.value().attr(attr).map(str::to_owned)) .collect() } /// The text of every element matching `selector`. fn element_texts(body: &str, selector: &str) -> Vec { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse(selector).expect("selector"); doc.select(&selector) .map(|el| el.text().collect::()) .collect() } fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) { let out = Command::new(BIN) .arg("--config") .arg(config) .args(["admin", "create-user", username]) .args(extra) .env("FORGE_PASSWORD", password) .output() .expect("run admin create-user"); ok(&out, &format!("create-user {username}")); } fn ok(out: &Output, what: &str) { assert!(out.status.success(), "{what} failed:\n{}", describe(out)); } fn git(cwd: &Path, args: &[&str]) -> Output { Command::new("git") .current_dir(cwd) .args(["-c", "credential.helper="]) .args(args) .env("GIT_TERMINAL_PROMPT", "0") .output() .expect("run git") } fn path_str(path: &Path) -> &str { path.to_str().expect("utf-8 path") } fn set_identity(repo: &Path) { ok( &git(repo, &["config", "user.name", "alice"]), "set user.name", ); ok( &git(repo, &["config", "user.email", "alice@example.com"]), "set user.email", ); } /// Writes the browse fixtures into `work`: a README with a heading, a relative link and a relative /// image, a Rust source file, a PNG, and an HTML file that must never render inline. fn write_fixtures(work: &Path) { fs::create_dir_all(work.join("src")).expect("create src"); fs::write( work.join("README.md"), "# Browse Test\n\nSee [the code](src/main.rs).\n\n![logo](img.png)\n", ) .expect("write README.md"); fs::write(work.join("src/main.rs"), "fn main() {}\n").expect("write main.rs"); fs::write(work.join("img.png"), PNG).expect("write img.png"); fs::write(work.join("evil.html"), "\n").expect("write evil.html"); // A Git LFS pointer whose object is not stored, so the blob page says so. fs::write( work.join("weights.bin"), "version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n", ) .expect("write weights.bin"); } /// Stages everything in `repo` and commits it with the repo's local identity. fn commit_all(repo: &Path, message: &str) { set_identity(repo); ok(&git(repo, &["add", "-A"]), "git add"); ok( &git( repo, &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message], ), "git commit", ); } fn head_sha(repo: &Path) -> String { let out = git(repo, &["rev-parse", "HEAD"]); ok(&out, "rev-parse HEAD"); String::from_utf8_lossy(&out.stdout).trim().to_owned() } /// Clones the empty repo at `url` into `work`, pushes the fixtures to `main` and an extra commit to /// `feature/x`, and returns the commit oid of `main`. fn push_fixtures(dir: &Path, url: &str, work: &Path) -> String { ok(&git(dir, &["clone", url, path_str(work)]), "https clone"); write_fixtures(work); commit_all(work, "browse fixtures"); ok(&git(work, &["push", "origin", "HEAD:main"]), "push main"); let main_oid = head_sha(work); ok( &git(work, &["checkout", "-q", "-b", "feature/x"]), "create feature/x", ); fs::write(work.join("feature.txt"), "on a branch\n").expect("write feature.txt"); commit_all(work, "feature commit"); ok( &git(work, &["push", "origin", "feature/x"]), "push feature/x", ); main_oid } /// Logs in through the real `/login` form and returns the `__Host-forge_session` cookie header. async fn login(http: &Http, username: &str, password: &str) -> String { let page = http.get("/login", None).await; assert_eq!(page.status, 200, "GET /login"); let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie"); let precsrf = input_value(&page.body(), "precsrf"); let res = http .post_form( "/login", &[ ("username", username), ("password", password), ("next", "/"), ("precsrf", &precsrf), ], Some(&precsrf_cookie), ) .await; assert_eq!(res.status, 303, "login as {username}: {}", res.body()); cookie_named(&res, "forge_session").expect("session cookie after login") } /// Creates an access token through `/settings/tokens` and returns the token shown once. async fn create_token(http: &Http, session: &str, name: &str) -> String { let page = http.get("/settings/tokens", Some(session)).await; assert_eq!(page.status, 200, "GET /settings/tokens"); let csrf = input_value(&page.body(), "csrf"); let res = http .post_form( "/settings/tokens", &[("csrf", &csrf), ("name", name), ("expires", "30")], Some(session), ) .await; assert_eq!(res.status, 200, "create token: {}", res.body()); let token = element_texts(&res.body(), "pre.code code") .into_iter() .next() .expect("token shown after creation"); let token = token.trim().to_owned(); assert!(token.starts_with("forge_"), "unexpected token shape"); token } /// Creates a repo through `/new`. async fn create_repo(http: &Http, session: &str, name: &str, visibility: &str) { let page = http.get("/new", Some(session)).await; assert_eq!(page.status, 200, "GET /new"); let csrf = input_value(&page.body(), "csrf"); let res = http .post_form( "/new", &[ ("csrf", &csrf), ("name", name), ("description", ""), ("visibility", visibility), ("default_branch", "main"), ], Some(session), ) .await; assert_eq!(res.status, 303, "create repo {name}: {}", res.body()); assert_eq!( res.header(LOCATION).as_deref(), Some(format!("/alice/{name}").as_str()) ); } /// Asserts that a response on the raw origin sets no cookie. fn assert_no_cookie(resp: &Resp, what: &str) { assert!( resp.set_cookies().is_empty(), "{what} must not set a cookie: {:?}", resp.set_cookies() ); } #[tokio::test] async fn browse_end_to_end() { if !tool_on_path("git") { eprintln!("SKIP browse_e2e: `git` was not found on PATH, so the browse test is skipped"); return; } // Keep the path short, as in git_e2e.rs. let root = tempfile::Builder::new() .prefix("fbrw") .tempdir_in("/tmp") .expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); run_admin(&config, "alice", &["--admin"], ALICE_PW); let (_server, ports) = launch(&dir, ports).await; let http = Http::new(ports.http); let raw = Http::new(ports.content); let raw_base = raw.base.clone(); // Setup: alice logs in, makes a token and creates one public and one private repo. let alice = login(&http, "alice", ALICE_PW).await; let token = create_token(&http, &alice, "browse-token").await; create_repo(&http, &alice, "pub", "public").await; create_repo(&http, &alice, "priv", "private").await; // Push the same fixtures to both repos over HTTPS with the token. let pub_url = format!( "http://alice:{token}@127.0.0.1:{}/alice/pub.git", ports.http ); let priv_url = format!( "http://alice:{token}@127.0.0.1:{}/alice/priv.git", ports.http ); let pub_main = push_fixtures(&dir, &pub_url, &dir.join("pub-work")); push_fixtures(&dir, &priv_url, &dir.join("priv-work")); // A branch named `media` in the public repo, with its own README.md. let pub_work = dir.join("pub-work"); ok( &git( &pub_work, &["checkout", "-q", "-b", "media", pub_main.as_str()], ), "create media branch", ); fs::write(pub_work.join("README.md"), "# On a media branch\n").expect("write media README"); commit_all(&pub_work, "media branch"); ok(&git(&pub_work, &["push", "origin", "media"]), "push media"); // App origin, anonymous, public repo. let home = http.get("/alice/pub", None).await; assert_eq!(home.status, 200, "anonymous home of public repo"); let csp = home .header(CONTENT_SECURITY_POLICY) .expect("CSP on app page"); let img_src = csp .split(';') .map(str::trim) .find(|d| d.starts_with("img-src")) .unwrap_or(""); assert!( img_src.contains(&raw_base), "app CSP img-src must include the raw origin: {csp}" ); let home_body = home.body(); assert!( element_texts(&home_body, "h1") .iter() .any(|t| t.contains("Browse Test")), "README heading missing on home" ); assert!( attr_values(&home_body, "a", "href") .iter() .any(|h| h == "/alice/pub/blob/main/src/main.rs"), "relative README link must point at the blob page" ); let image_srcs = attr_values(&home_body, "img", "src"); assert!( image_srcs.iter().any( |s| s.starts_with(&format!("{raw_base}/alice/pub/main/")) && s.ends_with("img.png") ), "README image must point at the raw origin: {image_srcs:?}" ); let tree = http.get("/alice/pub/tree/main/src", None).await; assert_eq!(tree.status, 200, "tree of src"); assert!(tree.body().contains("main.rs"), "tree must list main.rs"); let blob = http.get("/alice/pub/blob/main/src/main.rs", None).await; assert_eq!(blob.status, 200, "blob of main.rs"); let blob_body = blob.body(); assert!(blob_body.contains("id=\"L1\""), "blob needs L1 anchors"); assert!( blob_body.contains("hl-"), "blob needs hl- highlight classes" ); let pointer = http.get("/alice/pub/blob/main/weights.bin", None).await; assert_eq!(pointer.status, 200, "blob of an LFS pointer"); assert!( pointer.body().contains("Stored with Git LFS ("), "pointer blob must say it is stored with Git LFS" ); assert!( pointer .body() .contains("this forge does not have the object"), "pointer without a stored object must say so" ); let commits = http.get("/alice/pub/commits/main", None).await; assert_eq!(commits.status, 200, "commits of main"); assert!( commits.body().contains("browse fixtures"), "commit list must show the commit" ); let commit = http .get(&format!("/alice/pub/commit/{pub_main}"), None) .await; assert_eq!(commit.status, 200, "commit page"); assert!( commit.body().contains("src/main.rs"), "commit page must show the diff of main.rs" ); let branches = http.get("/alice/pub/branches", None).await; assert_eq!(branches.status, 200, "branches"); assert!( branches.body().contains("feature/x"), "branches must list feature/x" ); let feature_tree = http.get("/alice/pub/tree/feature/x", None).await; assert_eq!(feature_tree.status, 200, "tree of feature/x"); assert!( feature_tree.body().contains("feature.txt"), "feature/x tree must list feature.txt" ); let archive = http.get("/alice/pub/archive/main.tar.gz", None).await; assert_eq!(archive.status, 200, "tar.gz archive"); assert!( archive.bytes.starts_with(&[0x1f, 0x8b]), "archive must start with gzip magic bytes" ); // App origin, private repo: anonymous gets 404 everywhere. for path in [ "/alice/priv", "/alice/priv/tree/main/src", "/alice/priv/blob/main/src/main.rs", ] { let res = http.get(path, None).await; assert_eq!(res.status, 404, "anonymous {path} on private repo"); } // Logged in, the image blob page carries a signed raw URL. let priv_img = http .get("/alice/priv/blob/main/img.png", Some(&alice)) .await; assert_eq!(priv_img.status, 200, "owner's blob page of img.png"); let priv_img_src = attr_values(&priv_img.body(), "img[src*=\"?token=\"]", "src") .into_iter() .next() .expect("img.png page must embed a raw URL with ?token="); assert!( priv_img_src.starts_with(&format!("{raw_base}/alice/priv/main/img.png?token=")), "signed raw URL shape: {priv_img_src}" ); // Raw origin: the media route 404s when the repo does not link the object. let media = raw.get("/alice/pub/-/media/main/weights.bin", None).await; assert_eq!(media.status, 404, "media of an unstored LFS object"); // Raw origin: a branch named `media` is an ordinary raw path, not the media route. let media_branch = raw.get("/alice/pub/media/README.md", None).await; assert_eq!( media_branch.status, 200, "raw README.md on the media branch" ); assert_eq!(media_branch.body(), "# On a media branch\n"); // Raw origin: `-` is never a ref name, so it is a 404 and not resolved. let dash = raw.get("/alice/pub/-/README.md", None).await; assert_eq!(dash.status, 404, "raw path with a `-` ref"); // Raw origin: public file. let main_rs = raw.get("/alice/pub/main/src/main.rs", None).await; assert_eq!(main_rs.status, 200, "raw public main.rs"); assert_eq!( main_rs.header(CONTENT_TYPE).as_deref(), Some("text/plain; charset=utf-8") ); assert_eq!( main_rs.header(X_CONTENT_TYPE_OPTIONS).as_deref(), Some("nosniff") ); let raw_csp = main_rs .header(CONTENT_SECURITY_POLICY) .expect("CSP on raw page"); assert!( raw_csp.contains("sandbox"), "raw CSP must sandbox: {raw_csp}" ); assert_no_cookie(&main_rs, "raw public main.rs"); // Raw origin: HTML is served as a download, never inline. let evil = raw.get("/alice/pub/main/evil.html", None).await; assert_eq!(evil.status, 200, "raw evil.html"); assert!( evil.header(CONTENT_DISPOSITION) .is_some_and(|v| v.starts_with("attachment")), "evil.html must be Content-Disposition: attachment" ); assert_no_cookie(&evil, "raw evil.html"); // Raw origin: images keep their image type. let img = raw.get("/alice/pub/main/img.png", None).await; assert_eq!(img.status, 200, "raw img.png"); assert_eq!(img.header(CONTENT_TYPE).as_deref(), Some("image/png")); assert_no_cookie(&img, "raw img.png"); // Raw origin: private repo needs a valid token. let priv_no_token = raw.get("/alice/priv/main/img.png", None).await; assert_eq!(priv_no_token.status, 404, "private raw without token"); assert_no_cookie(&priv_no_token, "raw private without token"); let priv_path = priv_img_src .strip_prefix(&raw_base) .expect("raw base prefix"); let priv_ok = raw.get(priv_path, None).await; assert_eq!(priv_ok.status, 200, "private raw with the scraped token"); assert_no_cookie(&priv_ok, "raw private with token"); // Tampering with the signature (its first character) must fail. The first base64 character // carries six significant bits, so flipping it always changes the signature. let (file_part, query) = priv_path.split_once("?token=").expect("token query"); let (expires, sig) = query.split_once('.').expect("token shape"); let flipped = if sig.starts_with('A') { 'B' } else { 'A' }; let tampered_sig = format!("{flipped}{}", &sig[1..]); let tampered = raw .get(&format!("{file_part}?token={expires}.{tampered_sig}"), None) .await; assert_eq!(tampered.status, 404, "tampered token must be rejected"); assert_no_cookie(&tampered, "raw tampered token"); // Raw origin has no app routes. let login_page = raw.get("/login", None).await; assert_eq!( login_page.status, 404, "/login must not exist on raw origin" ); assert_no_cookie(&login_page, "raw /login"); }