Krolyxon
Commit 3cb9866

ferreniumtestsextras.rs

521 lines · 17.0 KB · Rust

1//! Blame, archives, the Atom feed, the file finder, language statistics,
2//! robots.txt and HTTPS clones, checked against the git CLI.
3
4mod common;
5
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::sync::Arc;
9
10use axum::body::{Body, to_bytes};
11use axum::http::{HeaderMap, Method, Request, StatusCode, header};
12use common::{Work, bare, git};
13use ferrenium::config::Config;
14use ferrenium::index::languages;
15use ferrenium::server;
16use ferrenium::state::{AppState, Overrides};
17use tower::ServiceExt;
18
19struct Fixture {
20 bare: PathBuf,
21 head: String,
22}
23
24/// Several authors editing one file, a rename, vendored/generated/doc
25/// files, a `.gitattributes` and a tag.
26fn fixture(root: &Path) -> Fixture {
27 let bare = bare(root, "ext.git", &[]);
28 let work = Work::clone_of(&bare);
29 let w = work.path();
30 let write = |name: &str, data: &str| {
31 let path = w.join(name);
32 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
33 std::fs::write(path, data).unwrap();
34 };
35 // A day apart, so blame ages differ.
36 let day = std::cell::Cell::new(0);
37 let commit = |message: &str, author: &str| {
38 git(w, &["add", "-A"]);
39 day.set(day.get() + 1);
40 let date = format!("{} +0000", 1_700_000_000 + day.get() * 86_400);
41 let status = Command::new("git")
42 .args(["commit", "-q", "-m", message, "--author", author])
43 .current_dir(w)
44 .env("GIT_CONFIG_NOSYSTEM", "1")
45 .env("GIT_CONFIG_GLOBAL", "/dev/null")
46 .env("GIT_COMMITTER_NAME", "Ada")
47 .env("GIT_COMMITTER_EMAIL", "ada@example.com")
48 .env("GIT_AUTHOR_DATE", &date)
49 .env("GIT_COMMITTER_DATE", &date)
50 .status()
51 .unwrap();
52 assert!(status.success());
53 };
54 write("src/old.rs", "fn a() {}\nfn b() {}\nfn c() {}\n");
55 write("README.md", "# Ext\n");
56 commit("Start", "Ada <ada@example.com>");
57 write(
58 "src/old.rs",
59 "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n",
60 );
61 commit("Edit b, add d", "Bob <bob@example.com>");
62 git(w, &["mv", "src/old.rs", "src/main.rs"]);
63 commit("Rename", "Ada <ada@example.com>");
64 write(
65 "src/main.rs",
66 "// top\nfn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n",
67 );
68 write("run.sh", "#!/bin/sh\necho run\n");
69 write("vendor/lib.js", &"var x = 1;\n".repeat(500));
70 write("docs/conf.py", &"x = 1\n".repeat(500));
71 write("app.min.js", &"var y=2;".repeat(500));
72 write("extra/big.c", &"int x;\n".repeat(500));
73 write(".gitattributes", "extra/** linguist-vendored\n");
74 commit("Add the rest", "Cy <cy@example.com>");
75 git(w, &["tag", "v1.0"]);
76 work.push("main");
77 git(w, &["push", "-q", "origin", "--tags"]);
78 let head = git(w, &["rev-parse", "HEAD"]);
79 Fixture { bare, head }
80}
81
82struct Site {
83 _root: tempfile::TempDir,
84 state: Arc<AppState>,
85 app: axum::Router,
86 f: Fixture,
87}
88
89/// `repos` is extra lines for the `[repos]` table.
90fn site(repos: &str) -> Site {
91 site_with(repos, "")
92}
93
94/// Like [`site`], with `tables` appended to the config.
95fn site_with(repos: &str, tables: &str) -> Site {
96 let root = tempfile::tempdir().unwrap();
97 let f = fixture(root.path());
98 let toml = format!(
99 "[repos]\nscan_path = {:?}\n{repos}\n[watch]\nenabled = false\n\
100 [server]\nbase_url = \"https://git.example.com\"\n{tables}\n",
101 root.path().to_str().unwrap()
102 );
103 let mut config = Config::parse(&toml).expect("parse");
104 config.finish().expect("valid");
105 let state = AppState::new(config, Overrides::default());
106 let config = state.config.load_full();
107 state.index.full_scan(&config);
108 Site {
109 app: server::app(state.clone()),
110 state,
111 f,
112 _root: root,
113 }
114}
115
116struct Res {
117 status: StatusCode,
118 headers: HeaderMap,
119 body: Vec<u8>,
120}
121
122impl Res {
123 fn text(&self) -> String {
124 String::from_utf8_lossy(&self.body).into_owned()
125 }
126}
127
128async fn request(app: &axum::Router, method: Method, uri: &str) -> Res {
129 let req = Request::builder()
130 .method(method)
131 .uri(uri)
132 .body(Body::empty())
133 .unwrap();
134 let res = app.clone().oneshot(req).await.unwrap();
135 let status = res.status();
136 let headers = res.headers().clone();
137 let body = to_bytes(res.into_body(), usize::MAX)
138 .await
139 .unwrap()
140 .to_vec();
141 Res {
142 status,
143 headers,
144 body,
145 }
146}
147
148async fn get(app: &axum::Router, uri: &str) -> Res {
149 request(app, Method::GET, uri).await
150}
151
152async fn ok(app: &axum::Router, uri: &str) -> String {
153 let res = get(app, uri).await;
154 assert_eq!(res.status, StatusCode::OK, "{uri}: {}", res.text());
155 res.text()
156}
157
158/// Commit of every line, from the page's hunk heads.
159fn blamed_lines(html: &str) -> Vec<String> {
160 let mut out = Vec::new();
161 for part in html.split(r#"class="blame-meta "#).skip(1) {
162 let rows: usize = part
163 .split(r#"rowspan=""#)
164 .nth(1)
165 .unwrap()
166 .split('"')
167 .next()
168 .unwrap()
169 .parse()
170 .unwrap();
171 let id = part.split("/commit/").nth(1).unwrap()[..40].to_string();
172 out.extend(std::iter::repeat_n(id, rows));
173 }
174 out
175}
176
177#[tokio::test]
178async fn blame_matches_git() {
179 let s = site("");
180 for path in ["src/main.rs", "run.sh", "README.md"] {
181 let html = ok(&s.app, &format!("/ext/blame/main/{path}")).await;
182 let porcelain = git(&s.f.bare, &["blame", "--porcelain", "main", "--", path]);
183 let expected: Vec<String> = porcelain
184 .lines()
185 .filter(|l| {
186 l.len() > 41
187 && l.as_bytes()[40] == b' '
188 && l[..40].bytes().all(|b| b.is_ascii_hexdigit())
189 })
190 .map(|l| l[..40].to_string())
191 .collect();
192 assert_eq!(blamed_lines(&html), expected, "{path}");
193 }
194 let html = ok(&s.app, "/ext/blame/main/src/main.rs").await;
195 // Hunk heads: author, age bar (newest is full strength), links.
196 assert!(html.contains("blame-meta age-8"), "{html}");
197 assert!(html.contains("blame-meta age-0"));
198 assert!(html.contains(">Bob<"));
199 assert!(html.contains("5 lines · 3 commits"), "{html}");
200 assert!(html.contains(r#"href="/ext/blob/main/src/main.rs">File<"#));
201 // The blob page links here; directories redirect; binaries don't blame.
202 let blob = ok(&s.app, "/ext/blob/main/src/main.rs").await;
203 assert!(blob.contains(r#"href="/ext/blame/main/src/main.rs">Blame<"#));
204 let res = get(&s.app, "/ext/blame/main/src").await;
205 assert_eq!(res.status, StatusCode::TEMPORARY_REDIRECT);
206 let res = get(&s.app, "/ext/blame/main/nope").await;
207 assert_eq!(res.status, StatusCode::NOT_FOUND);
208}
209
210fn tar_listing(data: &[u8]) -> String {
211 let mut child = Command::new("tar")
212 .args(["tzf", "-"])
213 .stdin(std::process::Stdio::piped())
214 .stdout(std::process::Stdio::piped())
215 .spawn()
216 .unwrap();
217 use std::io::Write;
218 child.stdin.take().unwrap().write_all(data).unwrap();
219 let out = child.wait_with_output().unwrap();
220 // git also lists directories; only files matter.
221 let mut lines: Vec<&str> = std::str::from_utf8(&out.stdout)
222 .unwrap()
223 .lines()
224 .filter(|l| !l.ends_with('/'))
225 .collect();
226 lines.sort();
227 lines.join("\n")
228}
229
230#[tokio::test]
231async fn archives_match_git_archive() {
232 let s = site("");
233 let res = get(&s.app, "/ext/archive/v1.0.tar.gz").await;
234 assert_eq!(res.status, StatusCode::OK);
235 assert_eq!(res.headers[header::CONTENT_TYPE], "application/gzip");
236 assert!(
237 res.headers[header::CONTENT_DISPOSITION]
238 .to_str()
239 .unwrap()
240 .contains("filename=\"ext-v1.0.tar.gz\"")
241 );
242 assert!(res.headers.get(header::CONTENT_ENCODING).is_none());
243 let ours = tar_listing(&res.body);
244 let theirs = Command::new("git")
245 .args(["archive", "--format=tar.gz", "--prefix=ext-v1.0/", "v1.0"])
246 .current_dir(&s.f.bare)
247 .output()
248 .unwrap()
249 .stdout;
250 assert_eq!(ours, tar_listing(&theirs));
251 assert!(ours.contains("ext-v1.0/src/main.rs"));
252
253 let res = get(&s.app, &format!("/ext/archive/{}.zip", s.f.head)).await;
254 assert_eq!(res.status, StatusCode::OK);
255 assert_eq!(res.headers[header::CONTENT_TYPE], "application/zip");
256 assert_eq!(res.headers[header::CACHE_CONTROL], "public, max-age=86400");
257 assert_eq!(&res.body[..2], b"PK");
258
259 for uri in [
260 "/ext/archive/nope.zip",
261 "/ext/archive/main.rar",
262 "/ext/archive/main",
263 ] {
264 assert_eq!(
265 get(&s.app, uri).await.status,
266 StatusCode::NOT_FOUND,
267 "{uri}"
268 );
269 }
270 let s = site("snapshots = [\"tar.gz\"]");
271 assert_eq!(
272 get(&s.app, "/ext/archive/main.zip").await.status,
273 StatusCode::NOT_FOUND
274 );
275 let refs = ok(&s.app, "/ext/refs").await;
276 assert!(
277 refs.contains(r#"href="/ext/archive/v1.0.tar.gz""#),
278 "{refs}"
279 );
280 assert!(!refs.contains(".zip"));
281}
282
283#[tokio::test]
284async fn atom_feed() {
285 let s = site("");
286 let res = get(&s.app, "/ext/atom").await;
287 assert_eq!(res.status, StatusCode::OK);
288 assert_eq!(
289 res.headers[header::CONTENT_TYPE],
290 "application/atom+xml; charset=utf-8"
291 );
292 let xml = res.text();
293 assert!(xml.starts_with("<?xml"));
294 assert_eq!(xml.matches("<entry>").count(), 4);
295 assert!(xml.contains("<id>https://git.example.com/ext/atom</id>"));
296 assert!(xml.contains(&format!(
297 r#"href="https://git.example.com/ext/commit/{}""#,
298 s.f.head
299 )));
300 assert!(xml.contains("<name>Cy</name>"));
301 assert_eq!(
302 get(&s.app, "/ext/atom?h=nope").await.status,
303 StatusCode::NOT_FOUND
304 );
305 // Advertised on the summary.
306 let html = ok(&s.app, "/ext").await;
307 assert!(html.contains(
308 r#"<link rel="alternate" type="application/atom+xml" title="Commits" href="/ext/atom">"#
309 ));
310}
311
312#[tokio::test]
313async fn find_files() {
314 let s = site("");
315 let html = ok(&s.app, "/ext/find/main?q=MAIN").await;
316 assert!(
317 html.contains(r#"href="/ext/blob/main/src/main.rs""#),
318 "{html}"
319 );
320 assert!(html.contains("1 of 8 files"), "{html}");
321 let html = ok(&s.app, "/ext/find/main?q=zzz").await;
322 assert!(html.contains("[No matches]"));
323 let html = ok(&s.app, "/ext/find/main").await;
324 assert!(html.contains("8 files"));
325 assert!(html.contains(r#"aria-current="page">Find<"#));
326}
327
328#[tokio::test]
329async fn language_statistics() {
330 let s = site("");
331 let repo = s.state.index.load().repos["ext"].clone();
332 let handle = ferrenium::git::handle(&repo.gix);
333 let tree = languages::root_tree(&handle, repo.meta.head.unwrap()).unwrap();
334 let stats = languages::compute(&handle, tree).unwrap();
335 let names: Vec<&str> = stats.languages.iter().map(|(n, _)| *n).collect();
336 // Vendored, documentation, minified and attribute-vendored files, and
337 // prose (README), don't count.
338 assert_eq!(names, ["Rust", "Shell"], "{stats:?}");
339
340 // The summary shows the bar once the warmer has run.
341 let html = ok(&s.app, "/ext").await;
342 assert!(!html.contains("lang-bar"));
343 s.state
344 .cache
345 .languages(tree, || Ok::<_, std::convert::Infallible>(stats))
346 .unwrap();
347 let html = ok(&s.app, "/ext").await;
348 assert!(html.contains(r#"class="lang-bar""#), "{html}");
349 assert_eq!(
350 html.matches(r#"<i class="o0"></i>"#).count()
351 + html.matches(r#"<i class="o1"></i>"#).count(),
352 40
353 );
354}
355
356#[tokio::test]
357async fn robots_and_methods() {
358 let s = site("");
359 let robots = ok(&s.app, "/robots.txt").await;
360 assert!(robots.contains("Disallow: /*/blame/"));
361 assert!(robots.contains("Disallow: /*/archive/"));
362 assert!(robots.contains("Disallow: /*?q="));
363 assert_eq!(
364 request(&s.app, Method::POST, "/ext/tree/main").await.status,
365 StatusCode::METHOD_NOT_ALLOWED
366 );
367 assert_eq!(
368 get(&s.app, "/ext/info/refs?service=git-receive-pack")
369 .await
370 .status,
371 StatusCode::FORBIDDEN
372 );
373 assert_eq!(
374 get(&s.app, "/ext/info/refs").await.status,
375 StatusCode::FORBIDDEN
376 );
377 assert_eq!(
378 request(&s.app, Method::POST, "/ext.git/git-receive-pack")
379 .await
380 .status,
381 StatusCode::FORBIDDEN
382 );
383 assert_eq!(
384 get(&s.app, "/ext.git/tree/main").await.status,
385 StatusCode::NOT_FOUND
386 );
387 let s = site("https_clone = false");
388 assert_eq!(
389 get(&s.app, "/ext/info/refs?service=git-upload-pack")
390 .await
391 .status,
392 StatusCode::NOT_FOUND
393 );
394}
395
396#[tokio::test]
397async fn hardening() {
398 let s = site_with(
399 "",
400 "[limits]\nheavy_parallel = 1\nheavy_wait_secs = 0\nmax_requests = 4",
401 );
402
403 // Error pages are styled and carry the security headers.
404 for res in [
405 request(&s.app, Method::POST, "/").await,
406 request(&s.app, Method::DELETE, "/ext/tree/main").await,
407 ] {
408 assert_eq!(res.status, StatusCode::METHOD_NOT_ALLOWED);
409 assert_eq!(res.headers[header::ALLOW], "GET, HEAD");
410 assert!(res.text().contains("[ERROR: METHOD NOT ALLOWED]"));
411 }
412 let res = get(&s.app, "/nope/nothing").await;
413 assert_eq!(res.status, StatusCode::NOT_FOUND);
414 assert!(res.text().contains("[ERROR: PAGE NOT FOUND]"));
415 for name in [
416 "content-security-policy",
417 "x-content-type-options",
418 "referrer-policy",
419 "x-frame-options",
420 "cross-origin-opener-policy",
421 ] {
422 assert!(res.headers.contains_key(name), "missing {name}");
423 }
424
425 // Heavy pages give up after `heavy_wait_secs`; others are unaffected.
426 let held = s.state.heavy_permits.clone().try_acquire_owned().unwrap();
427 let res = get(&s.app, "/ext/blame/main/src/main.rs").await;
428 assert_eq!(res.status, StatusCode::SERVICE_UNAVAILABLE);
429 assert_eq!(res.headers[header::RETRY_AFTER], "10");
430 assert_eq!(res.headers[header::CACHE_CONTROL], "no-store");
431 assert!(res.text().contains("[ERROR: SERVER BUSY]"));
432 assert_eq!(
433 get(&s.app, "/ext/info/refs?service=git-upload-pack")
434 .await
435 .status,
436 StatusCode::SERVICE_UNAVAILABLE
437 );
438 ok(&s.app, "/ext/tree/main").await;
439 drop(held);
440 ok(&s.app, "/ext/blame/main/src/main.rs").await;
441
442 // Past `max_requests`, everything is refused at once.
443 let held = s
444 .state
445 .request_permits
446 .clone()
447 .try_acquire_many_owned(4)
448 .unwrap();
449 assert_eq!(
450 get(&s.app, "/").await.status,
451 StatusCode::SERVICE_UNAVAILABLE
452 );
453 drop(held);
454 ok(&s.app, "/").await;
455
456 // Oversized fetch negotiations never reach git.
457 let req = Request::builder()
458 .method(Method::POST)
459 .uri("/ext.git/git-upload-pack")
460 .header(header::CONTENT_LENGTH, "1000000000")
461 .body(Body::empty())
462 .unwrap();
463 let res = s.app.clone().oneshot(req).await.unwrap();
464 assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE);
465}
466
467#[tokio::test(flavor = "multi_thread")]
468async fn clone_over_https() {
469 let s = site("");
470 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
471 let addr = listener.local_addr().unwrap();
472 let app = s.app.clone();
473 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
474
475 let dest = tempfile::tempdir().unwrap();
476 let head = s.f.head.clone();
477 let out = tokio::task::spawn_blocking(move || {
478 let mut results = Vec::new();
479 for (url, dir, protocol) in [
480 (format!("http://{addr}/ext.git"), "v2", "2"),
481 (format!("http://{addr}/ext"), "v0", "0"),
482 ] {
483 let target = dest.path().join(dir);
484 let status = Command::new("git")
485 .args([
486 "-c",
487 &format!("protocol.version={protocol}"),
488 "clone",
489 "-q",
490 &url,
491 ])
492 .arg(&target)
493 .env("GIT_CONFIG_NOSYSTEM", "1")
494 .env("GIT_CONFIG_GLOBAL", "/dev/null")
495 .env("GIT_TERMINAL_PROMPT", "0")
496 .status()
497 .unwrap();
498 assert!(status.success(), "clone {url}");
499 results.push(git(&target, &["rev-parse", "HEAD"]));
500 }
501 // Pushing is refused.
502 let work = dest.path().join("v2");
503 std::fs::write(work.join("new.txt"), "x").unwrap();
504 git(&work, &["add", "-A"]);
505 git(&work, &["commit", "-q", "-m", "x"]);
506 let push = Command::new("git")
507 .args(["push", "-q", "origin", "main"])
508 .current_dir(&work)
509 .env("GIT_CONFIG_NOSYSTEM", "1")
510 .env("GIT_CONFIG_GLOBAL", "/dev/null")
511 .env("GIT_TERMINAL_PROMPT", "0")
512 .output()
513 .unwrap();
514 assert!(!push.status.success());
515 assert!(String::from_utf8_lossy(&push.stderr).contains("403"));
516 results
517 })
518 .await
519 .unwrap();
520 assert_eq!(out, vec![head.clone(), head]);
521}