michal/tit
Browse tree · Show commit · Download archive
Diff
0f42c4fd7168 → c6ae270f9852
Cargo.lock
Mode 100644 → 100644; object 37a8f7dfbc6f → 7873306995d1
@@ -82,6 +82,53 @@ ] [[package]] +name = "askama" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" +dependencies = [ + "askama_parser", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "syn 2.0.119", +] + +[[package]] +name = "askama_macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" +dependencies = [ + "rustc-hash", + "unicode-ident", + "winnow", +] + +[[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1309,7 +1356,7 @@ checksum = "19753d40da53d0ec41604750eeb969097a90fb2d7f7992730d904541c04e2c19" dependencies = [ "bstr", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -2769,6 +2816,12 @@ ] [[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2887,6 +2940,19 @@ "proc-macro2", "quote", "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[package]] @@ -3231,6 +3297,7 @@ name = "tit-cde" version = "0.1.0" dependencies = [ + "askama", "axum", "clap", "gix", @@ -3691,6 +3758,9 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "wnaf" @@ -3797,3 +3867,9 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.toml
Mode 100644 → 100644; object fa8df52d4574 → 6ef7aa7f00b7
@@ -11,6 +11,7 @@
path = "src/main.rs"
[dependencies]
+askama = { version = "0.16", default-features = false, features = ["derive", "std"] }
axum = { version = "0.8", default-features = false, features = ["http1", "query", "tokio"] }
clap = { version = "4.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
gix = { version = "0.84", default-features = false, features = ["blame", "parallel", "sha1", "sha256"] }
assets/style.css
Mode → 100644; object → 758074ee633f
@@ -1,0 +1,104 @@
+:root {
+ color-scheme: light dark;
+ font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
+ line-height: 1.5;
+ background: Canvas;
+ color: CanvasText;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ width: min(100% - 2rem, 64rem);
+ margin: 0 auto;
+}
+
+.site-header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem 0;
+ border-bottom: 1px solid GrayText;
+}
+
+.brand {
+ color: inherit;
+ font-size: 1.5rem;
+ font-weight: 700;
+ text-decoration: none;
+}
+
+main {
+ min-height: 65vh;
+ padding: 2rem 0;
+}
+
+footer {
+ padding: 1rem 0;
+ border-top: 1px solid GrayText;
+}
+
+form {
+ max-width: 32rem;
+}
+
+.field {
+ margin-block: 1rem;
+}
+
+label {
+ display: block;
+ margin-bottom: 0.25rem;
+ font-weight: 700;
+}
+
+input,
+button {
+ max-width: 100%;
+ padding: 0.6rem 0.75rem;
+ border: 1px solid GrayText;
+ border-radius: 0.25rem;
+ background: Canvas;
+ color: CanvasText;
+ font: inherit;
+}
+
+input {
+ width: 100%;
+}
+
+button {
+ cursor: pointer;
+ font-weight: 700;
+}
+
+a {
+ color: LinkText;
+}
+
+:focus-visible {
+ outline: 0.2rem solid Highlight;
+ outline-offset: 0.2rem;
+}
+
+.error {
+ padding: 0.75rem;
+ border-left: 0.25rem solid #b42318;
+}
+
+.skip-link {
+ position: absolute;
+ left: -10000px;
+ top: auto;
+}
+
+.skip-link:focus {
+ left: 1rem;
+ top: 1rem;
+ z-index: 1;
+ padding: 0.5rem;
+ background: Canvas;
+}
scripts/check-m2-4
Mode → 100755; object → fc26afee2a83
@@ -1,0 +1,5 @@ +#!/bin/sh +set -eu + +./scripts/check +cargo test --locked --release --test web_shell
src/http/mod.rs
Mode → 100644; object → 772d5fbd758e
@@ -1,0 +1,299 @@
+use std::net::SocketAddr;
+
+use askama::Template;
+use axum::Router;
+use axum::body::{Body, HttpBody};
+use axum::extract::{Extension, RawQuery, Request};
+use axum::http::{HeaderName, HeaderValue, Method, StatusCode, header};
+use axum::middleware::{self, Next};
+use axum::response::Response;
+use axum::routing::get;
+use thiserror::Error;
+use tokio::net::TcpListener;
+use tokio::sync::oneshot;
+use tokio::task::JoinHandle;
+
+use crate::auth::validate_username;
+use crate::domain::repository::validate_slug;
+
+const STYLE: &str = include_str!("../../assets/style.css");
+const MAX_LOCATION_QUERY_BYTES: usize = 512;
+const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
+
+pub(crate) struct RunningWebServer {
+ address: SocketAddr,
+ shutdown: oneshot::Sender<()>,
+ task: JoinHandle<std::io::Result<()>>,
+}
+
+impl RunningWebServer {
+ pub(crate) async fn start(address: SocketAddr) -> Result<Self, WebError> {
+ let listener = TcpListener::bind(address).await?;
+ let address = listener.local_addr()?;
+ let (shutdown, receiver) = oneshot::channel();
+ let task = tokio::spawn(async move {
+ axum::serve(listener, router())
+ .with_graceful_shutdown(async {
+ let _ = receiver.await;
+ })
+ .await
+ });
+ Ok(Self {
+ address,
+ shutdown,
+ task,
+ })
+ }
+
+ pub(crate) fn address(&self) -> SocketAddr {
+ self.address
+ }
+
+ pub(crate) async fn shutdown(self) -> Result<(), WebError> {
+ let _ = self.shutdown.send(());
+ self.task.await.map_err(|_| WebError::Join)??;
+ Ok(())
+ }
+}
+
+pub(crate) fn router() -> Router {
+ Router::new()
+ .route("/", get(home))
+ .route("/go", get(go_to_repository))
+ .route("/assets/style.css", get(style))
+ .fallback(not_found)
+ .method_not_allowed_fallback(method_not_allowed)
+ .layer(middleware::from_fn(response_policy))
+}
+
+async fn home(Extension(request_id): Extension<RequestId>) -> Response {
+ render_home(StatusCode::OK, &request_id.0, "", "", "")
+}
+
+async fn go_to_repository(
+ Extension(request_id): Extension<RequestId>,
+ RawQuery(query): RawQuery,
+) -> Response {
+ match parse_location_query(query.as_deref()) {
+ Ok((owner, repository)) => {
+ let location = format!("/{owner}/{repository}");
+ Response::builder()
+ .status(StatusCode::FOUND)
+ .header(header::LOCATION, location)
+ .header(header::CACHE_CONTROL, "no-store")
+ .body(Body::empty())
+ .expect("the repository redirect is valid")
+ }
+ Err(LocationQueryError { owner, repository }) => render_home(
+ StatusCode::BAD_REQUEST,
+ &request_id.0,
+ &owner,
+ &repository,
+ "Enter a valid lowercase owner and repository.",
+ ),
+ }
+}
+
+async fn style() -> Response {
+ Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "text/css; charset=utf-8")
+ .header(header::CACHE_CONTROL, "public, max-age=3600")
+ .body(Body::from(STYLE))
+ .expect("the embedded CSS response is valid")
+}
+
+async fn not_found(Extension(request_id): Extension<RequestId>) -> Response {
+ render_error(
+ StatusCode::NOT_FOUND,
+ &request_id.0,
+ "Page not found",
+ "The requested page does not exist.",
+ )
+}
+
+async fn method_not_allowed(Extension(request_id): Extension<RequestId>) -> Response {
+ let mut response = render_error(
+ StatusCode::METHOD_NOT_ALLOWED,
+ &request_id.0,
+ "Method not allowed",
+ "This page does not accept the request method.",
+ );
+ response
+ .headers_mut()
+ .insert(header::ALLOW, HeaderValue::from_static("GET, HEAD"));
+ response
+}
+
+async fn response_policy(mut request: Request, next: Next) -> Response {
+ let request_id = RequestId(format!("{:032x}", rand::random::<u128>()));
+ let is_head = request.method() == Method::HEAD;
+ request.extensions_mut().insert(request_id.clone());
+ let mut response = next.run(request).await;
+ let headers = response.headers_mut();
+ headers.insert(
+ HeaderName::from_static("content-security-policy"),
+ HeaderValue::from_static(CONTENT_SECURITY_POLICY),
+ );
+ headers.insert(
+ HeaderName::from_static("x-content-type-options"),
+ HeaderValue::from_static("nosniff"),
+ );
+ headers.insert(
+ HeaderName::from_static("x-frame-options"),
+ HeaderValue::from_static("DENY"),
+ );
+ headers.insert(
+ HeaderName::from_static("referrer-policy"),
+ HeaderValue::from_static("no-referrer"),
+ );
+ headers.insert(
+ HeaderName::from_static("permissions-policy"),
+ HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=(), usb=()"),
+ );
+ headers.insert(
+ HeaderName::from_static("cross-origin-opener-policy"),
+ HeaderValue::from_static("same-origin"),
+ );
+ headers.insert(
+ HeaderName::from_static("x-request-id"),
+ HeaderValue::from_str(&request_id.0).expect("the generated request ID is a header value"),
+ );
+
+ if is_head {
+ let length = response.body().size_hint().exact();
+ *response.body_mut() = Body::empty();
+ if let Some(length) = length
+ && !response.headers().contains_key(header::CONTENT_LENGTH)
+ {
+ response.headers_mut().insert(
+ header::CONTENT_LENGTH,
+ HeaderValue::from_str(&length.to_string())
+ .expect("a content length is a header value"),
+ );
+ }
+ }
+ response
+}
+
+fn render_home(
+ status: StatusCode,
+ request_id: &str,
+ owner: &str,
+ repository: &str,
+ error: &str,
+) -> Response {
+ render(
+ status,
+ &HomeTemplate {
+ request_id,
+ owner,
+ repository,
+ error,
+ has_error: !error.is_empty(),
+ },
+ )
+}
+
+fn render_error(status: StatusCode, request_id: &str, heading: &str, message: &str) -> Response {
+ render(
+ status,
+ &ErrorTemplate {
+ request_id,
+ status: heading,
+ message,
+ },
+ )
+}
+
+fn render(status: StatusCode, template: &impl Template) -> Response {
+ match template.render() {
+ Ok(body) => Response::builder()
+ .status(status)
+ .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+ .header(header::CACHE_CONTROL, "no-store")
+ .body(Body::from(body))
+ .expect("the HTML response is valid"),
+ Err(_) => Response::builder()
+ .status(StatusCode::INTERNAL_SERVER_ERROR)
+ .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
+ .header(header::CACHE_CONTROL, "no-store")
+ .body(Body::from("Template rendering failed.\n"))
+ .expect("the template error response is valid"),
+ }
+}
+
+fn parse_location_query(query: Option<&str>) -> Result<(String, String), LocationQueryError> {
+ let query = query.ok_or_else(LocationQueryError::default)?;
+ if query.len() > MAX_LOCATION_QUERY_BYTES || !valid_percent_encoding(query.as_bytes()) {
+ return Err(LocationQueryError::default());
+ }
+ let mut owner = None;
+ let mut repository = None;
+ for (name, value) in url::form_urlencoded::parse(query.as_bytes()) {
+ match name.as_ref() {
+ "owner" if owner.is_none() => owner = Some(value.into_owned()),
+ "repository" if repository.is_none() => repository = Some(value.into_owned()),
+ _ => return Err(LocationQueryError::default()),
+ }
+ }
+ let owner = owner.unwrap_or_default();
+ let repository = repository.unwrap_or_default();
+ if validate_username(&owner).is_err() || validate_slug(&repository).is_err() {
+ return Err(LocationQueryError { owner, repository });
+ }
+ Ok((owner, repository))
+}
+
+fn valid_percent_encoding(input: &[u8]) -> bool {
+ let mut index = 0;
+ while index < input.len() {
+ if input[index] == b'%' {
+ if index + 2 >= input.len()
+ || !input[index + 1].is_ascii_hexdigit()
+ || !input[index + 2].is_ascii_hexdigit()
+ {
+ return false;
+ }
+ index += 3;
+ } else {
+ index += 1;
+ }
+ }
+ true
+}
+
+#[derive(Clone)]
+struct RequestId(String);
+
+#[derive(Default)]
+struct LocationQueryError {
+ owner: String,
+ repository: String,
+}
+
+#[derive(Template)]
+#[template(path = "home.html")]
+struct HomeTemplate<'a> {
+ request_id: &'a str,
+ owner: &'a str,
+ repository: &'a str,
+ error: &'a str,
+ has_error: bool,
+}
+
+#[derive(Template)]
+#[template(path = "error.html")]
+struct ErrorTemplate<'a> {
+ request_id: &'a str,
+ status: &'a str,
+ message: &'a str,
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum WebError {
+ #[error("HTTP listener error: {0}")]
+ Io(#[from] std::io::Error),
+ #[error("HTTP server task failed")]
+ Join,
+}
src/main.rs
Mode 100644 → 100644; object 241a2e8bab2c → 5a8247d54ae2
@@ -10,6 +10,11 @@ mod domain; #[allow(dead_code, reason = "M1C proves Git reads before the CLI serves them")] mod git; +#[allow( + dead_code, + reason = "M2.4 establishes the HTTP shell before tit serve calls it" +)] +mod http; mod instance; #[allow(dead_code, reason = "M1B proves the SSH server before M2 calls it")] mod ssh;
templates/base.html
Mode → 100644; object → d5831452ce03
@@ -1,0 +1,24 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>{% block title %}tit{% endblock %}</title>
+ <link rel="stylesheet" href="/assets/style.css">
+</head>
+<body>
+ <a class="skip-link" href="#main">Skip to content</a>
+ <header class="site-header">
+ <a class="brand" href="/" aria-label="tit home">tit</a>
+ <nav aria-label="Primary">
+ <a href="/">Home</a>
+ </nav>
+ </header>
+ <main id="main">
+{% block content %}{% endblock %}
+ </main>
+ <footer>
+ <small>Request <code>{{ request_id }}</code></small>
+ </footer>
+</body>
+</html>
templates/error.html
Mode → 100644; object → 33badb7d0808
@@ -1,0 +1,7 @@
+{% extends "base.html" %}
+{% block title %}{{ status }} · tit{% endblock %}
+{% block content %}
+ <h1>{{ status }}</h1>
+ <p>{{ message }}</p>
+ <p><a href="/">Return home</a></p>
+{% endblock %}
templates/home.html
Mode → 100644; object → 95483a140113
@@ -1,0 +1,20 @@
+{% extends "base.html" %}
+{% block title %}Repositories · tit{% endblock %}
+{% block content %}
+ <h1>Repositories</h1>
+ <p>Enter an owner and a repository to open its public page.</p>
+{% if has_error %}
+ <p class="error" role="alert">{{ error }}</p>
+{% endif %}
+ <form action="/go" method="get">
+ <div class="field">
+ <label for="owner">Owner</label>
+ <input id="owner" name="owner" value="{{ owner }}" required autocomplete="off" autocapitalize="none" spellcheck="false">
+ </div>
+ <div class="field">
+ <label for="repository">Repository</label>
+ <input id="repository" name="repository" value="{{ repository }}" required autocomplete="off" autocapitalize="none" spellcheck="false">
+ </div>
+ <button type="submit">Open repository</button>
+ </form>
+{% endblock %}
tests/snapshots/web/bad-request.html
Mode → 100644; object → acb84f9ae18f
@@ -1,0 +1,41 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Repositories · tit</title> + <link rel="stylesheet" href="/assets/style.css"> +</head> +<body> + <a class="skip-link" href="#main">Skip to content</a> + <header class="site-header"> + <a class="brand" href="/" aria-label="tit home">tit</a> + <nav aria-label="Primary"> + <a href="/">Home</a> + </nav> + </header> + <main id="main"> + + <h1>Repositories</h1> + <p>Enter an owner and a repository to open its public page.</p> + + <p class="error" role="alert">Enter a valid lowercase owner and repository.</p> + + <form action="/go" method="get"> + <div class="field"> + <label for="owner">Owner</label> + <input id="owner" name="owner" value="<script>" required autocomplete="off" autocapitalize="none" spellcheck="false"> + </div> + <div class="field"> + <label for="repository">Repository</label> + <input id="repository" name="repository" value="example" required autocomplete="off" autocapitalize="none" spellcheck="false"> + </div> + <button type="submit">Open repository</button> + </form> + + </main> + <footer> + <small>Request <code><request-id></code></small> + </footer> +</body> +</html>
tests/snapshots/web/home.html
Mode → 100644; object → cf76f68a79f1
@@ -1,0 +1,39 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Repositories · tit</title> + <link rel="stylesheet" href="/assets/style.css"> +</head> +<body> + <a class="skip-link" href="#main">Skip to content</a> + <header class="site-header"> + <a class="brand" href="/" aria-label="tit home">tit</a> + <nav aria-label="Primary"> + <a href="/">Home</a> + </nav> + </header> + <main id="main"> + + <h1>Repositories</h1> + <p>Enter an owner and a repository to open its public page.</p> + + <form action="/go" method="get"> + <div class="field"> + <label for="owner">Owner</label> + <input id="owner" name="owner" value="" required autocomplete="off" autocapitalize="none" spellcheck="false"> + </div> + <div class="field"> + <label for="repository">Repository</label> + <input id="repository" name="repository" value="" required autocomplete="off" autocapitalize="none" spellcheck="false"> + </div> + <button type="submit">Open repository</button> + </form> + + </main> + <footer> + <small>Request <code><request-id></code></small> + </footer> +</body> +</html>
tests/snapshots/web/method-not-allowed.html
Mode → 100644; object → 581566f04648
@@ -1,0 +1,28 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Method not allowed · tit</title> + <link rel="stylesheet" href="/assets/style.css"> +</head> +<body> + <a class="skip-link" href="#main">Skip to content</a> + <header class="site-header"> + <a class="brand" href="/" aria-label="tit home">tit</a> + <nav aria-label="Primary"> + <a href="/">Home</a> + </nav> + </header> + <main id="main"> + + <h1>Method not allowed</h1> + <p>This page does not accept the request method.</p> + <p><a href="/">Return home</a></p> + + </main> + <footer> + <small>Request <code><request-id></code></small> + </footer> +</body> +</html>
tests/snapshots/web/not-found.html
Mode → 100644; object → f80436b71f82
@@ -1,0 +1,28 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Page not found · tit</title> + <link rel="stylesheet" href="/assets/style.css"> +</head> +<body> + <a class="skip-link" href="#main">Skip to content</a> + <header class="site-header"> + <a class="brand" href="/" aria-label="tit home">tit</a> + <nav aria-label="Primary"> + <a href="/">Home</a> + </nav> + </header> + <main id="main"> + + <h1>Page not found</h1> + <p>The requested page does not exist.</p> + <p><a href="/">Return home</a></p> + + </main> + <footer> + <small>Request <code><request-id></code></small> + </footer> +</body> +</html>
tests/web_shell.rs
Mode → 100644; object → d50e9f84fcb5
@@ -1,0 +1,270 @@
+#[allow(dead_code, reason = "the Web shell test uses only username validation")]
+#[path = "../src/auth.rs"]
+mod auth;
+#[allow(
+ dead_code,
+ reason = "the Web shell test uses only repository slug validation"
+)]
+#[path = "../src/domain/mod.rs"]
+mod domain;
+#[path = "../src/http/mod.rs"]
+mod http;
+
+use std::collections::BTreeMap;
+use std::io::{Read, Write};
+use std::net::{Ipv4Addr, SocketAddr, TcpStream};
+
+use http::RunningWebServer;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn serves_the_semantic_shell_without_javascript() {
+ let server = start().await;
+
+ let home = request(server.address(), "GET", "/", &[]);
+ assert_eq!(home.status, 200);
+ assert_eq!(home.header("content-type"), "text/html; charset=utf-8");
+ assert_eq!(home.header("cache-control"), "no-store");
+ assert!(home.body.contains("<header class=\"site-header\">"));
+ assert!(home.body.contains("<nav aria-label=\"Primary\">"));
+ assert!(home.body.contains("<main id=\"main\">"));
+ assert!(home.body.contains("<footer>"));
+ assert!(home.body.contains("<form action=\"/go\" method=\"get\">"));
+ assert!(home.body.contains("name=\"owner\""));
+ assert!(home.body.contains("name=\"repository\""));
+ assert!(!home.body.to_ascii_lowercase().contains("<script"));
+ assert_security_policy(&home);
+ assert_snapshot(&home, include_str!("snapshots/web/home.html"));
+
+ let request_id = home.header("x-request-id");
+ assert_request_id(request_id);
+ assert!(home.body.contains(&format!("<code>{request_id}</code>")));
+
+ let head = request(server.address(), "HEAD", "/", &[]);
+ assert_eq!(head.status, 200);
+ assert!(head.body.is_empty());
+ assert_eq!(head.header("content-length"), home.body.len().to_string());
+ assert_security_policy(&head);
+
+ let css = request(server.address(), "GET", "/assets/style.css", &[]);
+ assert_eq!(css.status, 200);
+ assert_eq!(css.header("content-type"), "text/css; charset=utf-8");
+ assert_eq!(css.header("cache-control"), "public, max-age=3600");
+ assert_eq!(css.body, include_str!("../assets/style.css"));
+ assert_security_policy(&css);
+
+ let css_head = request(server.address(), "HEAD", "/assets/style.css", &[]);
+ assert_eq!(css_head.status, 200);
+ assert!(css_head.body.is_empty());
+ assert_eq!(
+ css_head.header("content-length"),
+ css.body.len().to_string()
+ );
+
+ server.shutdown().await.expect("stop the Web server");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn submits_the_repository_form_with_plain_http() {
+ let server = start().await;
+
+ let redirect = request(
+ server.address(),
+ "GET",
+ "/go?owner=alice&repository=example",
+ &[],
+ );
+ assert_eq!(redirect.status, 302);
+ assert_eq!(redirect.header("location"), "/alice/example");
+ assert_eq!(redirect.header("cache-control"), "no-store");
+ assert!(redirect.body.is_empty());
+ assert_security_policy(&redirect);
+
+ for path in [
+ "/go",
+ "/go?owner=Alice&repository=example",
+ "/go?owner=alice&repository=../example",
+ "/go?owner=alice&owner=bob&repository=example",
+ "/go?owner=alice&repository=example&extra=value",
+ "/go?owner=alice&repository=%",
+ ] {
+ let response = request(server.address(), "GET", path, &[]);
+ assert_eq!(response.status, 400, "unexpected status for {path}");
+ assert!(response.body.contains("role=\"alert\""));
+ assert!(
+ response
+ .body
+ .contains("Enter a valid lowercase owner and repository.")
+ );
+ assert_security_policy(&response);
+ }
+
+ let injection = request(
+ server.address(),
+ "GET",
+ "/go?owner=%3Cscript%3E&repository=example",
+ &[],
+ );
+ assert_eq!(injection.status, 400);
+ assert!(injection.body.contains("value=\"<script>\""));
+ assert!(!injection.body.contains("value=\"<script>\""));
+ assert!(!injection.body.to_ascii_lowercase().contains("<script"));
+ assert_snapshot(&injection, include_str!("snapshots/web/bad-request.html"));
+
+ let oversized = format!("/go?owner={}&repository=example", "a".repeat(512));
+ assert_eq!(
+ request(server.address(), "GET", &oversized, &[]).status,
+ 400
+ );
+
+ server.shutdown().await.expect("stop the Web server");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn serves_useful_errors_and_owns_request_ids() {
+ let server = start().await;
+
+ let missing = request(server.address(), "GET", "/missing", &[]);
+ assert_eq!(missing.status, 404);
+ assert!(missing.body.contains("<h1>Page not found</h1>"));
+ assert!(missing.body.contains("The requested page does not exist."));
+ assert_security_policy(&missing);
+ assert_snapshot(&missing, include_str!("snapshots/web/not-found.html"));
+
+ let missing_head = request(server.address(), "HEAD", "/missing", &[]);
+ assert_eq!(missing_head.status, 404);
+ assert!(missing_head.body.is_empty());
+ assert_eq!(
+ missing_head.header("content-length"),
+ missing.body.len().to_string()
+ );
+
+ let method = request(server.address(), "POST", "/", &[]);
+ assert_eq!(method.status, 405);
+ assert_eq!(method.header("allow"), "GET, HEAD");
+ assert!(method.body.contains("<h1>Method not allowed</h1>"));
+ assert!(
+ method
+ .body
+ .contains("This page does not accept the request method.")
+ );
+ assert_security_policy(&method);
+ assert_snapshot(
+ &method,
+ include_str!("snapshots/web/method-not-allowed.html"),
+ );
+
+ let first = request(
+ server.address(),
+ "GET",
+ "/",
+ &[("X-Request-ID", "attacker-controlled")],
+ );
+ let second = request(server.address(), "GET", "/", &[]);
+ assert_request_id(first.header("x-request-id"));
+ assert_request_id(second.header("x-request-id"));
+ assert_ne!(first.header("x-request-id"), "attacker-controlled");
+ assert_ne!(first.header("x-request-id"), second.header("x-request-id"));
+
+ server.shutdown().await.expect("stop the Web server");
+}
+
+async fn start() -> RunningWebServer {
+ RunningWebServer::start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
+ .await
+ .expect("start the Web server")
+}
+
+fn request(
+ address: SocketAddr,
+ method: &str,
+ path: &str,
+ headers: &[(&str, &str)],
+) -> HttpResponse {
+ let mut stream = TcpStream::connect(address).expect("connect to the Web server");
+ let mut request =
+ format!("{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n");
+ for (name, value) in headers {
+ request.push_str(&format!("{name}: {value}\r\n"));
+ }
+ request.push_str("Content-Length: 0\r\n\r\n");
+ stream
+ .write_all(request.as_bytes())
+ .expect("write an HTTP request");
+ let mut bytes = Vec::new();
+ stream
+ .read_to_end(&mut bytes)
+ .expect("read an HTTP response");
+ HttpResponse::parse(&bytes)
+}
+
+fn assert_security_policy(response: &HttpResponse) {
+ assert_eq!(
+ response.header("content-security-policy"),
+ "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
+ );
+ assert_eq!(response.header("x-content-type-options"), "nosniff");
+ assert_eq!(response.header("x-frame-options"), "DENY");
+ assert_eq!(response.header("referrer-policy"), "no-referrer");
+ assert_eq!(
+ response.header("permissions-policy"),
+ "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
+ );
+ assert_eq!(response.header("cross-origin-opener-policy"), "same-origin");
+ assert_request_id(response.header("x-request-id"));
+}
+
+fn assert_request_id(value: &str) {
+ assert_eq!(value.len(), 32);
+ assert!(value.bytes().all(|byte| byte.is_ascii_hexdigit()));
+ assert_eq!(value, value.to_ascii_lowercase());
+}
+
+fn assert_snapshot(response: &HttpResponse, expected: &str) {
+ let normalized = response
+ .body
+ .replace(response.header("x-request-id"), "<request-id>");
+ assert_eq!(normalized, expected.strip_suffix('\n').unwrap_or(expected));
+}
+
+struct HttpResponse {
+ status: u16,
+ headers: BTreeMap<String, String>,
+ body: String,
+}
+
+impl HttpResponse {
+ fn parse(bytes: &[u8]) -> Self {
+ let split = bytes
+ .windows(4)
+ .position(|window| window == b"\r\n\r\n")
+ .expect("an HTTP response header terminator");
+ let head = std::str::from_utf8(&bytes[..split]).expect("UTF-8 HTTP response headers");
+ let mut lines = head.split("\r\n");
+ let status = lines
+ .next()
+ .expect("an HTTP status line")
+ .split_whitespace()
+ .nth(1)
+ .expect("an HTTP status code")
+ .parse()
+ .expect("a numeric HTTP status code");
+ let headers = lines
+ .map(|line| {
+ let (name, value) = line.split_once(':').expect("a valid HTTP response header");
+ (name.to_ascii_lowercase(), value.trim().to_owned())
+ })
+ .collect();
+ let body = String::from_utf8(bytes[split + 4..].to_vec()).expect("a UTF-8 response body");
+ Self {
+ status,
+ headers,
+ body,
+ }
+ }
+
+ fn header(&self, name: &str) -> &str {
+ self.headers
+ .get(name)
+ .unwrap_or_else(|| panic!("missing {name} response header"))
+ }
+}