forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
20.1 KiB
1//! Black-box browse test. It starts the real `forge` binary with three listeners (app, ssh and
2//! the raw user-content origin), pushes fixtures over HTTPS with a token, and checks the app pages
3//! and the raw origin with plain HTTP.
4//!
5//! Cookies are handled by hand, as in `git_e2e.rs`: the session cookie is `__Host-forge_session`
6//! with `Secure`, so a cookie jar would not send it over plain http.
7
8mod common;
9use common::{BIN, describe, launch, pick_ports, tool_on_path, write_config};
10use std::fs;
11use std::path::Path;
12use std::process::{Command, Output};
13use std::time::Duration;
14
15use reqwest::header::{
16 CONTENT_DISPOSITION, CONTENT_SECURITY_POLICY, CONTENT_TYPE, COOKIE, HeaderMap, HeaderName,
17 LOCATION, SET_COOKIE, X_CONTENT_TYPE_OPTIONS,
18};
19
20const ALICE_PW: &str = "alice-password-123";
21const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x00IEND\xaeB`\x82";
22
23/// A received HTTP response. The body is kept as bytes because the archive is binary.
24struct Resp {
25 status: u16,
26 headers: HeaderMap,
27 bytes: Vec<u8>,
28}
29
30impl Resp {
31 fn body(&self) -> String {
32 String::from_utf8_lossy(&self.bytes).into_owned()
33 }
34
35 fn header(&self, name: HeaderName) -> Option<String> {
36 self.headers
37 .get(name)
38 .and_then(|v| v.to_str().ok())
39 .map(str::to_owned)
40 }
41
42 fn set_cookies(&self) -> Vec<String> {
43 self.headers
44 .get_all(SET_COOKIE)
45 .iter()
46 .filter_map(|v| v.to_str().ok().map(str::to_owned))
47 .collect()
48 }
49}
50
51/// A client for one listener. `base` is the origin, e.g. `http://127.0.0.1:8080`.
52struct Http {
53 client: reqwest::Client,
54 base: String,
55}
56
57impl Http {
58 fn new(port: u16) -> Self {
59 let client = reqwest::Client::builder()
60 .redirect(reqwest::redirect::Policy::none())
61 .timeout(Duration::from_secs(60))
62 .build()
63 .expect("http client");
64 Self {
65 client,
66 base: format!("http://127.0.0.1:{port}"),
67 }
68 }
69
70 async fn get(&self, path: &str, cookie: Option<&str>) -> Resp {
71 self.send(self.client.get(format!("{}{path}", self.base)), cookie)
72 .await
73 }
74
75 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
76 self.send(
77 self.client.post(format!("{}{path}", self.base)).form(form),
78 cookie,
79 )
80 .await
81 }
82
83 async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp {
84 let req = match cookie {
85 Some(cookie) => req.header(COOKIE, cookie),
86 None => req,
87 };
88 let res = req.send().await.expect("http request");
89 let status = res.status().as_u16();
90 let headers = res.headers().clone();
91 let bytes = res.bytes().await.expect("read body").to_vec();
92 Resp {
93 status,
94 headers,
95 bytes,
96 }
97 }
98}
99
100/// Returns `name=value` for the cookie whose name ends with `suffix`, e.g. `forge_session`
101/// matches `__Host-forge_session`. Cleared cookies (empty value) are ignored.
102fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
103 resp.set_cookies().iter().find_map(|set| {
104 let pair = set.split(';').next()?;
105 let (name, value) = pair.split_once('=')?;
106 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
107 })
108}
109
110/// The value of the first `<input name="...">` on a page.
111fn input_value(body: &str, name: &str) -> String {
112 let doc = scraper::Html::parse_document(body);
113 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
114 doc.select(&selector)
115 .next()
116 .and_then(|el| el.value().attr("value"))
117 .unwrap_or_else(|| panic!("no input named {name} on page"))
118 .to_owned()
119}
120
121/// The values of `attr` on every element matching `selector`, in document order.
122fn attr_values(body: &str, selector: &str, attr: &str) -> Vec<String> {
123 let doc = scraper::Html::parse_document(body);
124 let selector = scraper::Selector::parse(selector).expect("selector");
125 doc.select(&selector)
126 .filter_map(|el| el.value().attr(attr).map(str::to_owned))
127 .collect()
128}
129
130/// The text of every element matching `selector`.
131fn element_texts(body: &str, selector: &str) -> Vec<String> {
132 let doc = scraper::Html::parse_document(body);
133 let selector = scraper::Selector::parse(selector).expect("selector");
134 doc.select(&selector)
135 .map(|el| el.text().collect::<String>())
136 .collect()
137}
138
139fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) {
140 let out = Command::new(BIN)
141 .arg("--config")
142 .arg(config)
143 .args(["admin", "create-user", username])
144 .args(extra)
145 .env("FORGE_PASSWORD", password)
146 .output()
147 .expect("run admin create-user");
148 ok(&out, &format!("create-user {username}"));
149}
150
151fn ok(out: &Output, what: &str) {
152 assert!(out.status.success(), "{what} failed:\n{}", describe(out));
153}
154
155fn git(cwd: &Path, args: &[&str]) -> Output {
156 Command::new("git")
157 .current_dir(cwd)
158 .args(["-c", "credential.helper="])
159 .args(args)
160 .env("GIT_TERMINAL_PROMPT", "0")
161 .output()
162 .expect("run git")
163}
164
165fn path_str(path: &Path) -> &str {
166 path.to_str().expect("utf-8 path")
167}
168
169fn set_identity(repo: &Path) {
170 ok(
171 &git(repo, &["config", "user.name", "alice"]),
172 "set user.name",
173 );
174 ok(
175 &git(repo, &["config", "user.email", "alice@example.com"]),
176 "set user.email",
177 );
178}
179
180/// Writes the browse fixtures into `work`: a README with a heading, a relative link and a relative
181/// image, a Rust source file, a PNG, and an HTML file that must never render inline.
182fn write_fixtures(work: &Path) {
183 fs::create_dir_all(work.join("src")).expect("create src");
184 fs::write(
185 work.join("README.md"),
186 "# Browse Test\n\nSee [the code](src/main.rs).\n\n![logo](img.png)\n",
187 )
188 .expect("write README.md");
189 fs::write(work.join("src/main.rs"), "fn main() {}\n").expect("write main.rs");
190 fs::write(work.join("img.png"), PNG).expect("write img.png");
191 fs::write(work.join("evil.html"), "<script>alert(1)</script>\n").expect("write evil.html");
192 // A Git LFS pointer whose object is not stored, so the blob page says so.
193 fs::write(
194 work.join("weights.bin"),
195 "version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n",
196 )
197 .expect("write weights.bin");
198}
199
200/// Stages everything in `repo` and commits it with the repo's local identity.
201fn commit_all(repo: &Path, message: &str) {
202 set_identity(repo);
203 ok(&git(repo, &["add", "-A"]), "git add");
204 ok(
205 &git(
206 repo,
207 &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message],
208 ),
209 "git commit",
210 );
211}
212
213fn head_sha(repo: &Path) -> String {
214 let out = git(repo, &["rev-parse", "HEAD"]);
215 ok(&out, "rev-parse HEAD");
216 String::from_utf8_lossy(&out.stdout).trim().to_owned()
217}
218
219/// Clones the empty repo at `url` into `work`, pushes the fixtures to `main` and an extra commit to
220/// `feature/x`, and returns the commit oid of `main`.
221fn push_fixtures(dir: &Path, url: &str, work: &Path) -> String {
222 ok(&git(dir, &["clone", url, path_str(work)]), "https clone");
223 write_fixtures(work);
224 commit_all(work, "browse fixtures");
225 ok(&git(work, &["push", "origin", "HEAD:main"]), "push main");
226 let main_oid = head_sha(work);
227
228 ok(
229 &git(work, &["checkout", "-q", "-b", "feature/x"]),
230 "create feature/x",
231 );
232 fs::write(work.join("feature.txt"), "on a branch\n").expect("write feature.txt");
233 commit_all(work, "feature commit");
234 ok(
235 &git(work, &["push", "origin", "feature/x"]),
236 "push feature/x",
237 );
238 main_oid
239}
240
241/// Logs in through the real `/login` form and returns the `__Host-forge_session` cookie header.
242async fn login(http: &Http, username: &str, password: &str) -> String {
243 let page = http.get("/login", None).await;
244 assert_eq!(page.status, 200, "GET /login");
245 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
246 let precsrf = input_value(&page.body(), "precsrf");
247 let res = http
248 .post_form(
249 "/login",
250 &[
251 ("username", username),
252 ("password", password),
253 ("next", "/"),
254 ("precsrf", &precsrf),
255 ],
256 Some(&precsrf_cookie),
257 )
258 .await;
259 assert_eq!(res.status, 303, "login as {username}: {}", res.body());
260 cookie_named(&res, "forge_session").expect("session cookie after login")
261}
262
263/// Creates an access token through `/settings/tokens` and returns the token shown once.
264async fn create_token(http: &Http, session: &str, name: &str) -> String {
265 let page = http.get("/settings/tokens", Some(session)).await;
266 assert_eq!(page.status, 200, "GET /settings/tokens");
267 let csrf = input_value(&page.body(), "csrf");
268 let res = http
269 .post_form(
270 "/settings/tokens",
271 &[("csrf", &csrf), ("name", name), ("expires", "30")],
272 Some(session),
273 )
274 .await;
275 assert_eq!(res.status, 200, "create token: {}", res.body());
276 let token = element_texts(&res.body(), "pre.code code")
277 .into_iter()
278 .next()
279 .expect("token shown after creation");
280 let token = token.trim().to_owned();
281 assert!(token.starts_with("forge_"), "unexpected token shape");
282 token
283}
284
285/// Creates a repo through `/new`.
286async fn create_repo(http: &Http, session: &str, name: &str, visibility: &str) {
287 let page = http.get("/new", Some(session)).await;
288 assert_eq!(page.status, 200, "GET /new");
289 let csrf = input_value(&page.body(), "csrf");
290 let res = http
291 .post_form(
292 "/new",
293 &[
294 ("csrf", &csrf),
295 ("name", name),
296 ("description", ""),
297 ("visibility", visibility),
298 ("default_branch", "main"),
299 ],
300 Some(session),
301 )
302 .await;
303 assert_eq!(res.status, 303, "create repo {name}: {}", res.body());
304 assert_eq!(
305 res.header(LOCATION).as_deref(),
306 Some(format!("/alice/{name}").as_str())
307 );
308}
309
310/// Asserts that a response on the raw origin sets no cookie.
311fn assert_no_cookie(resp: &Resp, what: &str) {
312 assert!(
313 resp.set_cookies().is_empty(),
314 "{what} must not set a cookie: {:?}",
315 resp.set_cookies()
316 );
317}
318
319#[tokio::test]
320async fn browse_end_to_end() {
321 if !tool_on_path("git") {
322 eprintln!("SKIP browse_e2e: `git` was not found on PATH, so the browse test is skipped");
323 return;
324 }
325
326 // Keep the path short, as in git_e2e.rs.
327 let root = tempfile::Builder::new()
328 .prefix("fbrw")
329 .tempdir_in("/tmp")
330 .expect("temp dir");
331 let dir = root.path().to_path_buf();
332
333 let ports = pick_ports();
334 let config = write_config(&dir, ports);
335
336 run_admin(&config, "alice", &["--admin"], ALICE_PW);
337
338 let (_server, ports) = launch(&dir, ports).await;
339 let http = Http::new(ports.http);
340 let raw = Http::new(ports.content);
341 let raw_base = raw.base.clone();
342
343 // Setup: alice logs in, makes a token and creates one public and one private repo.
344 let alice = login(&http, "alice", ALICE_PW).await;
345 let token = create_token(&http, &alice, "browse-token").await;
346 create_repo(&http, &alice, "pub", "public").await;
347 create_repo(&http, &alice, "priv", "private").await;
348
349 // Push the same fixtures to both repos over HTTPS with the token.
350 let pub_url = format!(
351 "http://alice:{token}@127.0.0.1:{}/alice/pub.git",
352 ports.http
353 );
354 let priv_url = format!(
355 "http://alice:{token}@127.0.0.1:{}/alice/priv.git",
356 ports.http
357 );
358 let pub_main = push_fixtures(&dir, &pub_url, &dir.join("pub-work"));
359 push_fixtures(&dir, &priv_url, &dir.join("priv-work"));
360
361 // A branch named `media` in the public repo, with its own README.md.
362 let pub_work = dir.join("pub-work");
363 ok(
364 &git(
365 &pub_work,
366 &["checkout", "-q", "-b", "media", pub_main.as_str()],
367 ),
368 "create media branch",
369 );
370 fs::write(pub_work.join("README.md"), "# On a media branch\n").expect("write media README");
371 commit_all(&pub_work, "media branch");
372 ok(&git(&pub_work, &["push", "origin", "media"]), "push media");
373
374 // App origin, anonymous, public repo.
375 let home = http.get("/alice/pub", None).await;
376 assert_eq!(home.status, 200, "anonymous home of public repo");
377 let csp = home
378 .header(CONTENT_SECURITY_POLICY)
379 .expect("CSP on app page");
380 let img_src = csp
381 .split(';')
382 .map(str::trim)
383 .find(|d| d.starts_with("img-src"))
384 .unwrap_or("");
385 assert!(
386 img_src.contains(&raw_base),
387 "app CSP img-src must include the raw origin: {csp}"
388 );
389
390 let home_body = home.body();
391 assert!(
392 element_texts(&home_body, "h1")
393 .iter()
394 .any(|t| t.contains("Browse Test")),
395 "README heading missing on home"
396 );
397 assert!(
398 attr_values(&home_body, "a", "href")
399 .iter()
400 .any(|h| h == "/alice/pub/blob/main/src/main.rs"),
401 "relative README link must point at the blob page"
402 );
403 let image_srcs = attr_values(&home_body, "img", "src");
404 assert!(
405 image_srcs.iter().any(
406 |s| s.starts_with(&format!("{raw_base}/alice/pub/main/")) && s.ends_with("img.png")
407 ),
408 "README image must point at the raw origin: {image_srcs:?}"
409 );
410
411 let tree = http.get("/alice/pub/tree/main/src", None).await;
412 assert_eq!(tree.status, 200, "tree of src");
413 assert!(tree.body().contains("main.rs"), "tree must list main.rs");
414
415 let blob = http.get("/alice/pub/blob/main/src/main.rs", None).await;
416 assert_eq!(blob.status, 200, "blob of main.rs");
417 let blob_body = blob.body();
418 assert!(blob_body.contains("id=\"L1\""), "blob needs L1 anchors");
419 assert!(
420 blob_body.contains("hl-"),
421 "blob needs hl- highlight classes"
422 );
423
424 let pointer = http.get("/alice/pub/blob/main/weights.bin", None).await;
425 assert_eq!(pointer.status, 200, "blob of an LFS pointer");
426 assert!(
427 pointer.body().contains("Stored with Git LFS ("),
428 "pointer blob must say it is stored with Git LFS"
429 );
430 assert!(
431 pointer
432 .body()
433 .contains("this forge does not have the object"),
434 "pointer without a stored object must say so"
435 );
436
437 let commits = http.get("/alice/pub/commits/main", None).await;
438 assert_eq!(commits.status, 200, "commits of main");
439 assert!(
440 commits.body().contains("browse fixtures"),
441 "commit list must show the commit"
442 );
443
444 let commit = http
445 .get(&format!("/alice/pub/commit/{pub_main}"), None)
446 .await;
447 assert_eq!(commit.status, 200, "commit page");
448 assert!(
449 commit.body().contains("src/main.rs"),
450 "commit page must show the diff of main.rs"
451 );
452
453 let branches = http.get("/alice/pub/branches", None).await;
454 assert_eq!(branches.status, 200, "branches");
455 assert!(
456 branches.body().contains("feature/x"),
457 "branches must list feature/x"
458 );
459
460 let feature_tree = http.get("/alice/pub/tree/feature/x", None).await;
461 assert_eq!(feature_tree.status, 200, "tree of feature/x");
462 assert!(
463 feature_tree.body().contains("feature.txt"),
464 "feature/x tree must list feature.txt"
465 );
466
467 let archive = http.get("/alice/pub/archive/main.tar.gz", None).await;
468 assert_eq!(archive.status, 200, "tar.gz archive");
469 assert!(
470 archive.bytes.starts_with(&[0x1f, 0x8b]),
471 "archive must start with gzip magic bytes"
472 );
473
474 // App origin, private repo: anonymous gets 404 everywhere.
475 for path in [
476 "/alice/priv",
477 "/alice/priv/tree/main/src",
478 "/alice/priv/blob/main/src/main.rs",
479 ] {
480 let res = http.get(path, None).await;
481 assert_eq!(res.status, 404, "anonymous {path} on private repo");
482 }
483
484 // Logged in, the image blob page carries a signed raw URL.
485 let priv_img = http
486 .get("/alice/priv/blob/main/img.png", Some(&alice))
487 .await;
488 assert_eq!(priv_img.status, 200, "owner's blob page of img.png");
489 let priv_img_src = attr_values(&priv_img.body(), "img[src*=\"?token=\"]", "src")
490 .into_iter()
491 .next()
492 .expect("img.png page must embed a raw URL with ?token=");
493 assert!(
494 priv_img_src.starts_with(&format!("{raw_base}/alice/priv/main/img.png?token=")),
495 "signed raw URL shape: {priv_img_src}"
496 );
497
498 // Raw origin: the media route 404s when the repo does not link the object.
499 let media = raw.get("/alice/pub/-/media/main/weights.bin", None).await;
500 assert_eq!(media.status, 404, "media of an unstored LFS object");
501
502 // Raw origin: a branch named `media` is an ordinary raw path, not the media route.
503 let media_branch = raw.get("/alice/pub/media/README.md", None).await;
504 assert_eq!(
505 media_branch.status, 200,
506 "raw README.md on the media branch"
507 );
508 assert_eq!(media_branch.body(), "# On a media branch\n");
509
510 // Raw origin: `-` is never a ref name, so it is a 404 and not resolved.
511 let dash = raw.get("/alice/pub/-/README.md", None).await;
512 assert_eq!(dash.status, 404, "raw path with a `-` ref");
513
514 // Raw origin: public file.
515 let main_rs = raw.get("/alice/pub/main/src/main.rs", None).await;
516 assert_eq!(main_rs.status, 200, "raw public main.rs");
517 assert_eq!(
518 main_rs.header(CONTENT_TYPE).as_deref(),
519 Some("text/plain; charset=utf-8")
520 );
521 assert_eq!(
522 main_rs.header(X_CONTENT_TYPE_OPTIONS).as_deref(),
523 Some("nosniff")
524 );
525 let raw_csp = main_rs
526 .header(CONTENT_SECURITY_POLICY)
527 .expect("CSP on raw page");
528 assert!(
529 raw_csp.contains("sandbox"),
530 "raw CSP must sandbox: {raw_csp}"
531 );
532 assert_no_cookie(&main_rs, "raw public main.rs");
533
534 // Raw origin: HTML is served as a download, never inline.
535 let evil = raw.get("/alice/pub/main/evil.html", None).await;
536 assert_eq!(evil.status, 200, "raw evil.html");
537 assert!(
538 evil.header(CONTENT_DISPOSITION)
539 .is_some_and(|v| v.starts_with("attachment")),
540 "evil.html must be Content-Disposition: attachment"
541 );
542 assert_no_cookie(&evil, "raw evil.html");
543
544 // Raw origin: images keep their image type.
545 let img = raw.get("/alice/pub/main/img.png", None).await;
546 assert_eq!(img.status, 200, "raw img.png");
547 assert_eq!(img.header(CONTENT_TYPE).as_deref(), Some("image/png"));
548 assert_no_cookie(&img, "raw img.png");
549
550 // Raw origin: private repo needs a valid token.
551 let priv_no_token = raw.get("/alice/priv/main/img.png", None).await;
552 assert_eq!(priv_no_token.status, 404, "private raw without token");
553 assert_no_cookie(&priv_no_token, "raw private without token");
554
555 let priv_path = priv_img_src
556 .strip_prefix(&raw_base)
557 .expect("raw base prefix");
558 let priv_ok = raw.get(priv_path, None).await;
559 assert_eq!(priv_ok.status, 200, "private raw with the scraped token");
560 assert_no_cookie(&priv_ok, "raw private with token");
561
562 // Tampering with the signature (its first character) must fail. The first base64 character
563 // carries six significant bits, so flipping it always changes the signature.
564 let (file_part, query) = priv_path.split_once("?token=").expect("token query");
565 let (expires, sig) = query.split_once('.').expect("token shape");
566 let flipped = if sig.starts_with('A') { 'B' } else { 'A' };
567 let tampered_sig = format!("{flipped}{}", &sig[1..]);
568 let tampered = raw
569 .get(&format!("{file_part}?token={expires}.{tampered_sig}"), None)
570 .await;
571 assert_eq!(tampered.status, 404, "tampered token must be rejected");
572 assert_no_cookie(&tampered, "raw tampered token");
573
574 // Raw origin has no app routes.
575 let login_page = raw.get("/login", None).await;
576 assert_eq!(
577 login_page.status, 404,
578 "/login must not exist on raw origin"
579 );
580 assert_no_cookie(&login_page, "raw /login");
581}