michal/tit

Browse tree · Show commit · Download archive

Diff

8f65f5d2f9e1d6ec77036a8f

Cargo.lock

Mode 100644100644; object 7873306995d16750b6170adf

@@ -3311,6 +3311,7 @@
  "tempfile",
  "thiserror",
  "tokio",
+ "tokio-stream",
  "toml",
  "url",
 ]
@@ -3339,6 +3340,17 @@
  "proc-macro2",
  "quote",
  "syn 2.0.119",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
 ]
 
 [[package]]

Cargo.toml

Mode 100644100644; object 6ef7aa7f00b7eda5bb448430

@@ -12,7 +12,7 @@
 
 [dependencies]
 askama = { version = "0.16", default-features = false, features = ["derive", "std"] }
-axum = { version = "0.8", default-features = false, features = ["http1", "query", "tokio"] }
+axum = { version = "0.8", default-features = false, features = ["http1", "original-uri", "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"] }
 gix-pack = { version = "0.71", default-features = false, features = ["generate", "sha1", "sha256", "streaming-input"] }
@@ -24,6 +24,7 @@
 ssh-key = { version = "=0.7.0-rc.11", default-features = false, features = ["ed25519", "p256", "std"] }
 thiserror = "2.0"
 tokio = { version = "1.53", default-features = false, features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
+tokio-stream = { version = "0.1", default-features = false }
 toml = { version = "1.1", default-features = false, features = ["parse", "serde", "std"] }
 url = { version = "2.5", features = ["serde"] }
 

assets/style.css

Mode 100644100644; object 758074ee633fc7918a27dc34

@@ -45,6 +45,53 @@
   max-width: 32rem;
 }
 
+.repository-header nav,
+.repository-header h1 {
+  margin-block: 0.5rem;
+}
+
+.repository-header nav a {
+  margin-right: 1rem;
+}
+
+dl {
+  display: grid;
+  grid-template-columns: max-content minmax(0, 1fr);
+  gap: 0.25rem 1rem;
+}
+
+dd {
+  margin: 0;
+  overflow-wrap: anywhere;
+}
+
+table {
+  width: 100%;
+  border-collapse: collapse;
+}
+
+th,
+td {
+  padding: 0.4rem;
+  border-bottom: 1px solid GrayText;
+  text-align: left;
+  vertical-align: top;
+}
+
+pre {
+  max-width: 100%;
+  padding: 1rem;
+  overflow: auto;
+  border: 1px solid GrayText;
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+
+.commit-list code,
+.blame-list code {
+  overflow-wrap: anywhere;
+}
+
 .field {
   margin-block: 1rem;
 }

docs/adr/0005-public-http-routes.md

Mode 100644; object c20fe3b94b85

@@ -1,0 +1,67 @@
+# Architectural decision record 0005: public HTTP routes
+
+Status: Accepted
+
+Date: 2026-07-22
+
+## Context
+
+`tit` needs stable public routes for repository content and Git clone
+discovery. The routes must operate without JavaScript. Repository names can
+change, refs can move, and Git paths can contain bytes that are not UTF-8.
+Private, archived, and missing repositories must give the same public result.
+
+## Decision
+
+Use `/{owner}/{repository}` as the canonical repository summary and HTTP clone
+URL. Accept `/{owner}/{repository}.git` for Git clients. Redirect a browser
+request for that alias to the canonical summary. Use a full commit ID in tree,
+blob, raw, commit, diff, blame, and archive URLs. Do not use a moving ref in a
+content URL.
+
+Encode each Git path byte with URL percent encoding when the byte is not an
+ASCII letter, a digit, `-`, `.`, `_`, `~`, or `/`. Decode the path to bytes
+before a repository read.
+Do not convert a Git path to UTF-8 for object lookup. Display a replacement
+character only when HTML must show a path that is not UTF-8.
+
+Use Askama version 0.16.0 for compile-time HTML templates. Its automatic HTML
+escaping keeps repository text out of active markup. Use `tokio-stream`
+version 0.1.19 only for the interface between the bounded archive channel and
+an Axum response body. This interface lets the archive writer send content
+without storing the complete archive in memory.
+
+Open repository records through the `store` module. Select only records with
+public visibility and active state. Resolve the filesystem path from the
+immutable repository ID. Require the canonical repository path to have the
+canonical repository directory as its direct parent.
+
+Run SQLite access, Git reads, archive generation, and upload-pack operations as
+blocking jobs. Permit a maximum of eight public Web blocking jobs at one time.
+Apply the read limits from milestone 2.3. Stream archive data through a bounded
+channel. Stop archive work when the HTTP client closes the channel.
+
+## Evidence
+
+The black-box test starts one public server for each Git object format. It uses
+real SQLite records and immutable repository paths. Raw HTTP requests browse
+summary, refs, commit, diff, tree, blob, raw, blame, and archive routes. The
+test verifies GET and HEAD behavior, percent-encoded paths, binary content,
+security headers, cache policy, useful 404 pages, and archive content with the
+system `tar` reader.
+
+Stock Git protocol version 2 clones through the same public server. The test
+also verifies that private and archived repositories do not appear in the
+summary, raw, or Git discovery routes. Unit tests cover path bytes that are not
+UTF-8 and malformed percent encoding.
+
+## Consequences
+
+A content URL identifies one immutable commit and does not change when a branch
+moves. A repository rename changes its owner-and-repository URL, but the
+filesystem name and repository ID do not change.
+
+HTML repository views use server-rendered templates and embedded CSS. Raw files
+use `application/octet-stream`. Archives use a streamed ustar body. The first
+public interface shows plain-text README content. Milestone 2.6 owns Markdown
+rendering and sanitization.

scripts/check-m2-5

Mode 100755; object 32928b414855

@@ -1,0 +1,5 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test public_routes

src/git/mod.rs

Mode 100644100644; object db96461ed5b4ab75bca6a05c

@@ -2,7 +2,7 @@
 pub(crate) mod packetline;
 #[allow(
     dead_code,
-    reason = "M2.3 establishes repository reads before the HTTP handlers call them"
+    reason = "some protocol tests import Git without the public HTTP routes"
 )]
 pub(crate) mod read;
 pub(crate) mod receive_pack;

src/git/read.rs

Mode 100644100644; object 83bb27f4e25639012e4eb437

@@ -478,8 +478,9 @@
         budget.check()?;
         let object = self
             .repository
-            .find_object(id)
-            .map_err(|error| ReadError::Git(error.to_string()))?;
+            .try_find_object(id)
+            .map_err(|error| ReadError::Git(error.to_string()))?
+            .ok_or(ReadError::ObjectNotFound(id))?;
         if object.kind != ObjectKind::Commit {
             return Err(ReadError::WrongObjectKind {
                 expected: "commit",
@@ -547,8 +548,9 @@
         budget.check()?;
         let object = self
             .repository
-            .find_object(id)
-            .map_err(|error| ReadError::Git(error.to_string()))?;
+            .try_find_object(id)
+            .map_err(|error| ReadError::Git(error.to_string()))?
+            .ok_or(ReadError::ObjectNotFound(id))?;
         if object.kind != ObjectKind::Tree {
             return Err(ReadError::WrongObjectKind {
                 expected: "tree",
@@ -627,8 +629,9 @@
         budget.check()?;
         let object = self
             .repository
-            .find_object(id)
-            .map_err(|error| ReadError::Git(error.to_string()))?;
+            .try_find_object(id)
+            .map_err(|error| ReadError::Git(error.to_string()))?
+            .ok_or(ReadError::ObjectNotFound(id))?;
         if object.kind != ObjectKind::Blob {
             return Err(ReadError::WrongObjectKind {
                 expected: "blob",
@@ -1009,6 +1012,8 @@
     NotBare(PathBuf),
     #[error("Git read failed: {0}")]
     Git(String),
+    #[error("Git object does not exist: {0}")]
+    ObjectNotFound(ObjectId),
     #[error("expected a {expected} object, found {actual}")]
     WrongObjectKind {
         expected: &'static str,

src/http/mod.rs

Mode 100644100644; object 772d5fbd758ef51553ff5475

@@ -1,4 +1,8 @@
+mod public;
+
 use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::sync::Arc;
 
 use askama::Template;
 use axum::Router;
@@ -10,15 +14,30 @@
 use axum::routing::get;
 use thiserror::Error;
 use tokio::net::TcpListener;
-use tokio::sync::oneshot;
+use tokio::sync::{Semaphore, oneshot};
 use tokio::task::JoinHandle;
 
 use crate::auth::validate_username;
 use crate::domain::repository::validate_slug;
 
+use self::public::PublicWeb;
+
 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'";
+const MAX_BLOCKING_WEB_JOBS: usize = 8;
+
+#[derive(Clone)]
+struct WebState {
+    public: Option<PublicWeb>,
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct PublicWebConfig {
+    pub(crate) instance_dir: PathBuf,
+    pub(crate) http_clone_base: String,
+    pub(crate) ssh_clone_base: String,
+}
 
 pub(crate) struct RunningWebServer {
     address: SocketAddr,
@@ -28,11 +47,29 @@
 
 impl RunningWebServer {
     pub(crate) async fn start(address: SocketAddr) -> Result<Self, WebError> {
+        Self::start_with_state(address, WebState { public: None }).await
+    }
+
+    pub(crate) async fn start_public(
+        address: SocketAddr,
+        config: PublicWebConfig,
+    ) -> Result<Self, WebError> {
+        let public = PublicWeb::open(config, Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)))?;
+        Self::start_with_state(
+            address,
+            WebState {
+                public: Some(public),
+            },
+        )
+        .await
+    }
+
+    async fn start_with_state(address: SocketAddr, state: WebState) -> 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())
+            axum::serve(listener, router_with_state(state))
                 .with_graceful_shutdown(async {
                     let _ = receiver.await;
                 })
@@ -57,12 +94,18 @@
 }
 
 pub(crate) fn router() -> Router {
+    router_with_state(WebState { public: None })
+}
+
+fn router_with_state(state: WebState) -> Router {
     Router::new()
         .route("/", get(home))
         .route("/go", get(go_to_repository))
         .route("/assets/style.css", get(style))
+        .merge(public::routes())
         .fallback(not_found)
         .method_not_allowed_fallback(method_not_allowed)
+        .with_state(state)
         .layer(middleware::from_fn(response_policy))
 }
 
@@ -294,6 +337,8 @@
 pub(crate) enum WebError {
     #[error("HTTP listener error: {0}")]
     Io(#[from] std::io::Error),
+    #[error("public Web configuration error: {0}")]
+    Public(#[from] public::PublicWebError),
     #[error("HTTP server task failed")]
     Join,
 }

src/http/public.rs

Mode 100644; object c548315e36b3

@@ -1,0 +1,1225 @@
+use std::fs;
+use std::io::Write;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use askama::Template;
+use axum::Router;
+use axum::body::{Body, Bytes};
+use axum::extract::{DefaultBodyLimit, Extension, OriginalUri, Path as AxumPath, State};
+use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
+use axum::response::Response;
+use axum::routing::{get, post};
+use gix::bstr::ByteSlice;
+use gix::hash::ObjectId;
+use gix::objs::tree::EntryKind;
+use serde::Deserialize;
+use thiserror::Error;
+use tokio::sync::{Semaphore, mpsc};
+use tokio_stream::wrappers::ReceiverStream;
+
+use crate::auth::validate_username;
+use crate::domain::repository::validate_slug;
+use crate::git::packetline::MAX_REQUEST_BYTES;
+use crate::git::read::{
+    BlameHunk, CommitInfo, DiffFile, ReadCancellation, ReadError, ReadLimits,
+    RepositoryReadService, TreeEntryInfo,
+};
+use crate::git::upload_pack::{ProtocolVersion, UploadPack};
+use crate::store::{DATABASE_FILE, RepositoryRecord, Store, StoreError};
+
+use super::{PublicWebConfig, RequestId, WebState, render_error};
+
+const MAX_HISTORY_COMMITS: usize = 10_000;
+const MAX_SUMMARY_COMMITS: usize = 50;
+
+#[derive(Clone)]
+pub(super) struct PublicWeb {
+    database: PathBuf,
+    repositories: PathBuf,
+    http_clone_base: String,
+    ssh_clone_base: String,
+    jobs: Arc<Semaphore>,
+}
+
+impl PublicWeb {
+    pub(super) fn open(
+        config: PublicWebConfig,
+        jobs: Arc<Semaphore>,
+    ) -> Result<Self, PublicWebError> {
+        let repositories = fs::canonicalize(config.instance_dir.join("repositories"))
+            .map_err(PublicWebError::RepositoryDirectory)?;
+        if !repositories.is_dir() {
+            return Err(PublicWebError::InvalidRepositoryDirectory);
+        }
+        let database = config.instance_dir.join(DATABASE_FILE);
+        Store::open(&database)?;
+        let http_clone_base = clone_base(&config.http_clone_base)?;
+        let ssh_clone_base = clone_base(&config.ssh_clone_base)?;
+        Ok(Self {
+            database,
+            repositories,
+            http_clone_base,
+            ssh_clone_base,
+            jobs,
+        })
+    }
+
+    async fn read<T, F>(
+        &self,
+        owner: String,
+        repository: String,
+        operation: F,
+    ) -> Result<T, RouteError>
+    where
+        T: Send + 'static,
+        F: FnOnce(RepositoryRecord, RepositoryReadService) -> Result<T, RouteError>
+            + Send
+            + 'static,
+    {
+        let permit = self
+            .jobs
+            .clone()
+            .acquire_owned()
+            .await
+            .map_err(|_| RouteError::Unavailable)?;
+        let web = self.clone();
+        tokio::task::spawn_blocking(move || {
+            let _permit = permit;
+            let (repository, path) = web.resolve_repository(&owner, &repository)?;
+            let limits = ReadLimits {
+                max_history_commits: MAX_HISTORY_COMMITS,
+                ..ReadLimits::default()
+            };
+            let service = RepositoryReadService::open(&path, limits)?;
+            operation(repository, service)
+        })
+        .await
+        .map_err(|_| RouteError::Internal)?
+    }
+
+    async fn path_job<T, F>(
+        &self,
+        owner: String,
+        repository: String,
+        operation: F,
+    ) -> Result<T, RouteError>
+    where
+        T: Send + 'static,
+        F: FnOnce(RepositoryRecord, PathBuf) -> Result<T, RouteError> + Send + 'static,
+    {
+        let permit = self
+            .jobs
+            .clone()
+            .acquire_owned()
+            .await
+            .map_err(|_| RouteError::Unavailable)?;
+        let web = self.clone();
+        tokio::task::spawn_blocking(move || {
+            let _permit = permit;
+            let (record, path) = web.resolve_repository(&owner, &repository)?;
+            operation(record, path)
+        })
+        .await
+        .map_err(|_| RouteError::Internal)?
+    }
+
+    fn resolve_repository(
+        &self,
+        owner: &str,
+        repository: &str,
+    ) -> Result<(RepositoryRecord, PathBuf), RouteError> {
+        if validate_username(owner).is_err() || validate_slug(repository).is_err() {
+            return Err(RouteError::NotFound);
+        }
+        let store = Store::open(&self.database)?;
+        let record = store.public_repository(owner, repository)?;
+        let candidate = self.repositories.join(format!("{}.git", record.id));
+        let path = fs::canonicalize(&candidate).map_err(|_| RouteError::Internal)?;
+        if path.parent() != Some(self.repositories.as_path()) || !path.is_dir() {
+            return Err(RouteError::Internal);
+        }
+        Ok((record, path))
+    }
+
+    fn clone_urls(&self, owner: &str, repository: &str) -> (String, String) {
+        (
+            format!("{}/{owner}/{repository}", self.http_clone_base),
+            format!("{}/{owner}/{repository}", self.ssh_clone_base),
+        )
+    }
+
+    async fn archive(
+        &self,
+        owner: String,
+        repository: String,
+        id: ObjectId,
+    ) -> Result<Body, RouteError> {
+        let path = self
+            .path_job(owner, repository, move |record, path| {
+                require_id_format(id, &record)?;
+                let service = RepositoryReadService::open(&path, ReadLimits::default())?;
+                let cancellation = ReadCancellation::default();
+                service.commit(id, &cancellation)?;
+                Ok(path)
+            })
+            .await?;
+        let permit = self
+            .jobs
+            .clone()
+            .acquire_owned()
+            .await
+            .map_err(|_| RouteError::Unavailable)?;
+        let (sender, receiver) = mpsc::channel(8);
+        tokio::task::spawn_blocking(move || {
+            let _permit = permit;
+            let result =
+                RepositoryReadService::open(&path, ReadLimits::default()).and_then(|service| {
+                    let cancellation = ReadCancellation::default();
+                    service
+                        .archive(id, &cancellation, &mut ChannelWriter { sender: &sender })
+                        .map(|_| ())
+                });
+            if let Err(error) = result {
+                let _ = sender.blocking_send(Err(std::io::Error::other(error.to_string())));
+            }
+        });
+        Ok(Body::from_stream(ReceiverStream::new(receiver)))
+    }
+}
+
+pub(super) fn routes() -> Router<WebState> {
+    Router::new()
+        .route("/{owner}/{repository}/info/refs", get(info_refs))
+        .route(
+            "/{owner}/{repository}/git-upload-pack",
+            post(git_upload_pack).layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)),
+        )
+        .route("/{owner}/{repository}/refs", get(refs))
+        .route("/{owner}/{repository}/commit/{commit}", get(commit))
+        .route("/{owner}/{repository}/diff/{old}/{new}", get(diff))
+        .route("/{owner}/{repository}/tree/{commit}", get(tree_root))
+        .route("/{owner}/{repository}/tree/{commit}/{*path}", get(tree))
+        .route("/{owner}/{repository}/blob/{commit}/{*path}", get(blob))
+        .route("/{owner}/{repository}/raw/{commit}/{*path}", get(raw))
+        .route("/{owner}/{repository}/blame/{commit}/{*path}", get(blame))
+        .route("/{owner}/{repository}/archive/{archive}", get(archive))
+        .route("/{owner}/{repository}", get(summary))
+}
+
+async fn summary(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    AxumPath(path): AxumPath<RepositoryPath>,
+) -> Response {
+    if let Some(repository) = path.repository.strip_suffix(".git")
+        && validate_username(&path.owner).is_ok()
+        && validate_slug(repository).is_ok()
+    {
+        return redirect(format!("/{}/{repository}", path.owner));
+    }
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let clone_urls = web.clone_urls(&path.owner, &path.repository);
+    let result = web
+        .read(path.owner, path.repository, move |record, service| {
+            let cancellation = ReadCancellation::default();
+            let references = service.references(&cancellation)?;
+            let head = references
+                .iter()
+                .find(|reference| reference.name == b"HEAD")
+                .map(|reference| reference.target);
+            let (history, readme) = match head {
+                Some(head) => (
+                    service.history(head, &cancellation)?,
+                    service.readme(head, &cancellation)?,
+                ),
+                None => (Vec::new(), None),
+            };
+            Ok(RepositoryPage::summary(
+                record,
+                clone_urls,
+                head,
+                history,
+                readme.map(|readme| (readme.path, readme.blob.data)),
+            ))
+        })
+        .await;
+    render_page(result, &request_id.0)
+}
+
+async fn refs(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    AxumPath(path): AxumPath<RepositoryPath>,
+) -> Response {
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let result = web
+        .read(path.owner, path.repository, |record, service| {
+            let cancellation = ReadCancellation::default();
+            let references = service.references(&cancellation)?;
+            Ok(RepositoryPage::refs(record, references))
+        })
+        .await;
+    render_page(result, &request_id.0)
+}
+
+async fn commit(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    AxumPath(path): AxumPath<CommitPath>,
+) -> Response {
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let id = match parse_id(&path.commit) {
+        Ok(id) => id,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    let result = web
+        .read(path.owner, path.repository, move |record, service| {
+            require_id_format(id, &record)?;
+            let cancellation = ReadCancellation::default();
+            let commit = service.commit(id, &cancellation)?;
+            Ok(RepositoryPage::commit(record, commit))
+        })
+        .await;
+    render_page(result, &request_id.0)
+}
+
+async fn diff(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    AxumPath(path): AxumPath<DiffPath>,
+) -> Response {
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let (old, new) = match (parse_id(&path.old), parse_id(&path.new)) {
+        (Ok(old), Ok(new)) => (old, new),
+        _ => return route_error(RouteError::NotFound, &request_id.0),
+    };
+    let result = web
+        .read(path.owner, path.repository, move |record, service| {
+            require_id_format(old, &record)?;
+            require_id_format(new, &record)?;
+            let cancellation = ReadCancellation::default();
+            let files = service.diff(old, new, &cancellation)?;
+            Ok(RepositoryPage::diff(record, old, new, files))
+        })
+        .await;
+    render_page(result, &request_id.0)
+}
+
+async fn tree_root(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    AxumPath(path): AxumPath<CommitPath>,
+) -> Response {
+    tree_response(state, request_id, path, Vec::new()).await
+}
+
+async fn tree(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    OriginalUri(uri): OriginalUri,
+) -> Response {
+    let (path, git_path) = match content_route(uri.path(), "tree") {
+        Ok(route) => route,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    tree_response(state, request_id, path, git_path).await
+}
+
+async fn tree_response(
+    state: WebState,
+    request_id: RequestId,
+    path: CommitPath,
+    git_path: Vec<u8>,
+) -> Response {
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let id = match parse_id(&path.commit) {
+        Ok(id) => id,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    let result = web
+        .read(path.owner, path.repository, move |record, service| {
+            require_id_format(id, &record)?;
+            let cancellation = ReadCancellation::default();
+            let entries = service.tree(id, &git_path, &cancellation)?;
+            Ok(RepositoryPage::tree(record, id, git_path, entries))
+        })
+        .await;
+    render_page(result, &request_id.0)
+}
+
+async fn blob(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    OriginalUri(uri): OriginalUri,
+) -> Response {
+    let (path, git_path) = match content_route(uri.path(), "blob") {
+        Ok(route) => route,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let id = match parse_id(&path.commit) {
+        Ok(id) => id,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    let result = web
+        .read(path.owner, path.repository, move |record, service| {
+            require_id_format(id, &record)?;
+            let cancellation = ReadCancellation::default();
+            let blob = service.blob(id, &git_path, &cancellation)?;
+            Ok(RepositoryPage::blob(record, id, git_path, blob.data))
+        })
+        .await;
+    render_page(result, &request_id.0)
+}
+
+async fn raw(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    OriginalUri(uri): OriginalUri,
+) -> Response {
+    let (path, git_path) = match content_route(uri.path(), "raw") {
+        Ok(route) => route,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let id = match parse_id(&path.commit) {
+        Ok(id) => id,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    let result = web
+        .read(path.owner, path.repository, move |record, service| {
+            require_id_format(id, &record)?;
+            let cancellation = ReadCancellation::default();
+            let mut content = Vec::new();
+            service.raw(id, &git_path, &cancellation, &mut content)?;
+            Ok(content)
+        })
+        .await;
+    match result {
+        Ok(content) => Response::builder()
+            .status(StatusCode::OK)
+            .header(header::CONTENT_TYPE, "application/octet-stream")
+            .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
+            .body(Body::from(content))
+            .expect("the raw response is valid"),
+        Err(error) => route_error(error, &request_id.0),
+    }
+}
+
+async fn blame(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    OriginalUri(uri): OriginalUri,
+) -> Response {
+    let (path, git_path) = match content_route(uri.path(), "blame") {
+        Ok(route) => route,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let id = match parse_id(&path.commit) {
+        Ok(id) => id,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    let result = web
+        .read(path.owner, path.repository, move |record, service| {
+            require_id_format(id, &record)?;
+            let cancellation = ReadCancellation::default();
+            let blob = service.blob(id, &git_path, &cancellation)?;
+            let hunks = service.blame(id, &git_path, &cancellation)?;
+            Ok(RepositoryPage::blame(
+                record, id, git_path, blob.data, hunks,
+            ))
+        })
+        .await;
+    render_page(result, &request_id.0)
+}
+
+async fn archive(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    AxumPath(path): AxumPath<ArchivePath>,
+) -> Response {
+    let Some(commit) = path.archive.strip_suffix(".tar") else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let id = match parse_id(commit) {
+        Ok(id) => id,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let result = web.archive(path.owner, path.repository, id).await;
+    match result {
+        Ok(body) => Response::builder()
+            .status(StatusCode::OK)
+            .header(header::CONTENT_TYPE, "application/x-tar")
+            .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
+            .header(
+                header::CONTENT_DISPOSITION,
+                HeaderValue::from_static("attachment; filename=repository.tar"),
+            )
+            .body(body)
+            .expect("the archive response is valid"),
+        Err(error) => route_error(error, &request_id.0),
+    }
+}
+
+async fn info_refs(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    AxumPath(mut path): AxumPath<RepositoryPath>,
+    OriginalUri(uri): OriginalUri,
+    headers: HeaderMap,
+) -> Response {
+    if uri.query() != Some("service=git-upload-pack") {
+        return plain_error(StatusCode::BAD_REQUEST, "Invalid Git service query.\n");
+    }
+    path.repository = path
+        .repository
+        .strip_suffix(".git")
+        .unwrap_or(&path.repository)
+        .to_owned();
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let version = match protocol_version(&headers) {
+        Ok(version) => version,
+        Err(()) => return plain_error(StatusCode::BAD_REQUEST, "Invalid Git protocol version.\n"),
+    };
+    let result = web
+        .path_job(path.owner, path.repository, move |_record, path| {
+            UploadPack::open(&path)
+                .and_then(|service| service.advertisement(version, true))
+                .map_err(|_| RouteError::Internal)
+        })
+        .await;
+    match result {
+        Ok(body) => git_response("application/x-git-upload-pack-advertisement", body),
+        Err(RouteError::NotFound) => plain_error(StatusCode::NOT_FOUND, "Repository not found.\n"),
+        Err(_) => plain_error(StatusCode::INTERNAL_SERVER_ERROR, "Git service failed.\n"),
+    }
+}
+
+async fn git_upload_pack(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    AxumPath(mut path): AxumPath<RepositoryPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    if headers
+        .get(header::CONTENT_TYPE)
+        .and_then(|value| value.to_str().ok())
+        != Some("application/x-git-upload-pack-request")
+    {
+        return plain_error(
+            StatusCode::UNSUPPORTED_MEDIA_TYPE,
+            "Invalid content type.\n",
+        );
+    }
+    path.repository = path
+        .repository
+        .strip_suffix(".git")
+        .unwrap_or(&path.repository)
+        .to_owned();
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let version = match protocol_version(&headers) {
+        Ok(version) => version,
+        Err(()) => return plain_error(StatusCode::BAD_REQUEST, "Invalid Git protocol version.\n"),
+    };
+    let result = web
+        .path_job(path.owner, path.repository, move |_record, path| {
+            UploadPack::open(&path)
+                .and_then(|service| service.respond(version, &body))
+                .map_err(|_| RouteError::InvalidRequest)
+        })
+        .await;
+    match result {
+        Ok(body) => git_response("application/x-git-upload-pack-result", body),
+        Err(RouteError::NotFound) => plain_error(StatusCode::NOT_FOUND, "Repository not found.\n"),
+        Err(RouteError::InvalidRequest) => {
+            plain_error(StatusCode::BAD_REQUEST, "Invalid Git request.\n")
+        }
+        Err(_) => plain_error(StatusCode::INTERNAL_SERVER_ERROR, "Git service failed.\n"),
+    }
+}
+
+fn render_page(result: Result<RepositoryPage, RouteError>, request_id: &str) -> Response {
+    match result {
+        Ok(mut page) => {
+            page.request_id = request_id.to_owned();
+            match page.render() {
+                Ok(body) => Response::builder()
+                    .status(StatusCode::OK)
+                    .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+                    .header(header::CACHE_CONTROL, "no-store")
+                    .body(Body::from(body))
+                    .expect("the repository HTML response is valid"),
+                Err(_) => route_error(RouteError::Internal, request_id),
+            }
+        }
+        Err(error) => route_error(error, request_id),
+    }
+}
+
+fn route_error(error: RouteError, request_id: &str) -> Response {
+    match error {
+        RouteError::NotFound => render_error(
+            StatusCode::NOT_FOUND,
+            request_id,
+            "Repository content not found",
+            "The requested repository content does not exist.",
+        ),
+        RouteError::Unavailable => render_error(
+            StatusCode::SERVICE_UNAVAILABLE,
+            request_id,
+            "Repository service unavailable",
+            "The repository service cannot complete this request now.",
+        ),
+        RouteError::Internal => render_error(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            request_id,
+            "Repository service error",
+            "The repository service cannot complete this request.",
+        ),
+        RouteError::InvalidRequest => render_error(
+            StatusCode::BAD_REQUEST,
+            request_id,
+            "Invalid repository request",
+            "The repository request is not valid.",
+        ),
+    }
+}
+
+fn redirect(location: String) -> Response {
+    Response::builder()
+        .status(StatusCode::PERMANENT_REDIRECT)
+        .header(header::LOCATION, location)
+        .header(header::CACHE_CONTROL, "no-store")
+        .body(Body::empty())
+        .expect("the canonical repository redirect is valid")
+}
+
+fn git_response(content_type: &'static str, body: Vec<u8>) -> Response {
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(header::CONTENT_TYPE, content_type)
+        .header(
+            header::CACHE_CONTROL,
+            "no-cache, max-age=0, must-revalidate",
+        )
+        .header(header::PRAGMA, "no-cache")
+        .body(Body::from(body))
+        .expect("the Git HTTP response is valid")
+}
+
+fn plain_error(status: StatusCode, message: &'static str) -> Response {
+    Response::builder()
+        .status(status)
+        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
+        .header(header::CACHE_CONTROL, "no-store")
+        .body(Body::from(message))
+        .expect("the plain HTTP error response is valid")
+}
+
+fn protocol_version(headers: &HeaderMap) -> Result<ProtocolVersion, ()> {
+    match headers
+        .get("git-protocol")
+        .and_then(|value| value.to_str().ok())
+    {
+        Some("version=2") => Ok(ProtocolVersion::V2),
+        None | Some("version=0") => Ok(ProtocolVersion::V0),
+        Some("version=1") => Ok(ProtocolVersion::V1),
+        Some(_) => Err(()),
+    }
+}
+
+fn parse_id(value: &str) -> Result<ObjectId, RouteError> {
+    if !matches!(value.len(), 40 | 64)
+        || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
+        || value.bytes().any(|byte| byte.is_ascii_uppercase())
+    {
+        return Err(RouteError::NotFound);
+    }
+    ObjectId::from_hex(value.as_bytes()).map_err(|_| RouteError::NotFound)
+}
+
+fn require_id_format(id: ObjectId, repository: &RepositoryRecord) -> Result<(), RouteError> {
+    let expected = match repository.object_format.as_str() {
+        "sha1" => gix::hash::Kind::Sha1,
+        "sha256" => gix::hash::Kind::Sha256,
+        _ => return Err(RouteError::Internal),
+    };
+    if id.kind() == expected {
+        Ok(())
+    } else {
+        Err(RouteError::NotFound)
+    }
+}
+
+fn content_route(path: &str, expected_route: &str) -> Result<(CommitPath, Vec<u8>), RouteError> {
+    let mut components = path.splitn(6, '/');
+    if components.next() != Some("") {
+        return Err(RouteError::NotFound);
+    }
+    let owner = components.next().ok_or(RouteError::NotFound)?;
+    let repository = components.next().ok_or(RouteError::NotFound)?;
+    let route = components.next().ok_or(RouteError::NotFound)?;
+    let commit = components.next().ok_or(RouteError::NotFound)?;
+    let encoded_path = components.next().ok_or(RouteError::NotFound)?;
+    if route != expected_route || encoded_path.is_empty() {
+        return Err(RouteError::NotFound);
+    }
+    Ok((
+        CommitPath {
+            owner: owner.to_owned(),
+            repository: repository.to_owned(),
+            commit: commit.to_owned(),
+        },
+        decode_path(encoded_path)?,
+    ))
+}
+
+fn decode_path(value: &str) -> Result<Vec<u8>, RouteError> {
+    let bytes = value.as_bytes();
+    let mut output = Vec::with_capacity(bytes.len());
+    let mut index = 0;
+    while index < bytes.len() {
+        if bytes[index] == b'%' {
+            if index + 2 >= bytes.len() {
+                return Err(RouteError::NotFound);
+            }
+            let high = hex_value(bytes[index + 1]).ok_or(RouteError::NotFound)?;
+            let low = hex_value(bytes[index + 2]).ok_or(RouteError::NotFound)?;
+            output.push((high << 4) | low);
+            index += 3;
+        } else {
+            output.push(bytes[index]);
+            index += 1;
+        }
+    }
+    Ok(output)
+}
+
+fn hex_value(byte: u8) -> Option<u8> {
+    match byte {
+        b'0'..=b'9' => Some(byte - b'0'),
+        b'a'..=b'f' => Some(byte - b'a' + 10),
+        b'A'..=b'F' => Some(byte - b'A' + 10),
+        _ => None,
+    }
+}
+
+fn encode_path(path: &[u8]) -> String {
+    let mut output = String::new();
+    for byte in path {
+        if byte.is_ascii_alphanumeric() || b"-._~/".contains(byte) {
+            output.push(char::from(*byte));
+        } else {
+            output.push('%');
+            output.push(char::from(b"0123456789ABCDEF"[usize::from(byte >> 4)]));
+            output.push(char::from(b"0123456789ABCDEF"[usize::from(byte & 0x0f)]));
+        }
+    }
+    output
+}
+
+fn clone_base(value: &str) -> Result<String, PublicWebError> {
+    let value = value.trim_end_matches('/');
+    if value.is_empty() || value.contains(['\r', '\n']) {
+        return Err(PublicWebError::CloneBase);
+    }
+    Ok(value.to_owned())
+}
+
+fn display_bytes(value: &[u8]) -> String {
+    value.as_bstr().to_str_lossy().into_owned()
+}
+
+struct ChannelWriter<'a> {
+    sender: &'a mpsc::Sender<Result<Bytes, std::io::Error>>,
+}
+
+impl Write for ChannelWriter<'_> {
+    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
+        self.sender
+            .blocking_send(Ok(Bytes::copy_from_slice(buffer)))
+            .map_err(|_| {
+                std::io::Error::new(std::io::ErrorKind::BrokenPipe, "HTTP client closed")
+            })?;
+        Ok(buffer.len())
+    }
+
+    fn flush(&mut self) -> std::io::Result<()> {
+        Ok(())
+    }
+}
+
+fn message_summary(message: &[u8]) -> String {
+    display_bytes(message)
+        .lines()
+        .next()
+        .unwrap_or_default()
+        .to_owned()
+}
+
+#[derive(Deserialize)]
+struct RepositoryPath {
+    owner: String,
+    repository: String,
+}
+
+#[derive(Debug, Deserialize, Eq, PartialEq)]
+struct CommitPath {
+    owner: String,
+    repository: String,
+    commit: String,
+}
+
+#[derive(Deserialize)]
+struct DiffPath {
+    owner: String,
+    repository: String,
+    old: String,
+    new: String,
+}
+
+#[derive(Deserialize)]
+struct ArchivePath {
+    owner: String,
+    repository: String,
+    archive: String,
+}
+
+#[derive(Template)]
+#[template(path = "repository.html")]
+struct RepositoryPage {
+    request_id: String,
+    owner: String,
+    repository: String,
+    object_format: String,
+    created_at: i64,
+    page_title: String,
+    page_kind: &'static str,
+    commit_id: String,
+    secondary_id: String,
+    path: String,
+    encoded_path: String,
+    http_clone_url: String,
+    ssh_clone_url: String,
+    has_head: bool,
+    has_readme: bool,
+    readme_path: String,
+    readme_content: String,
+    readme_binary: bool,
+    history: Vec<CommitView>,
+    entries: Vec<TreeView>,
+    blob_content: String,
+    blob_binary: bool,
+    commit: CommitView,
+    diffs: Vec<DiffView>,
+    refs: Vec<RefView>,
+    blame: Vec<BlameView>,
+}
+
+impl RepositoryPage {
+    fn base(record: RepositoryRecord, page_kind: &'static str, title: String) -> Self {
+        Self {
+            request_id: String::new(),
+            owner: record.owner,
+            repository: record.slug,
+            object_format: record.object_format,
+            created_at: record.created_at,
+            page_title: title,
+            page_kind,
+            commit_id: String::new(),
+            secondary_id: String::new(),
+            path: String::new(),
+            encoded_path: String::new(),
+            http_clone_url: String::new(),
+            ssh_clone_url: String::new(),
+            has_head: false,
+            has_readme: false,
+            readme_path: String::new(),
+            readme_content: String::new(),
+            readme_binary: false,
+            history: Vec::new(),
+            entries: Vec::new(),
+            blob_content: String::new(),
+            blob_binary: false,
+            commit: CommitView::default(),
+            diffs: Vec::new(),
+            refs: Vec::new(),
+            blame: Vec::new(),
+        }
+    }
+
+    fn summary(
+        record: RepositoryRecord,
+        clone_urls: (String, String),
+        head: Option<ObjectId>,
+        history: Vec<CommitInfo>,
+        readme: Option<(Vec<u8>, Vec<u8>)>,
+    ) -> Self {
+        let title = format!("{}/{}", record.owner, record.slug);
+        let mut page = Self::base(record, "summary", title);
+        page.http_clone_url = clone_urls.0;
+        page.ssh_clone_url = clone_urls.1;
+        page.has_head = head.is_some();
+        page.commit_id = head.map(|id| id.to_string()).unwrap_or_default();
+        page.history = history
+            .into_iter()
+            .take(MAX_SUMMARY_COMMITS)
+            .map(CommitView::from)
+            .collect();
+        if let Some((path, data)) = readme {
+            page.has_readme = true;
+            page.readme_path = display_bytes(&path);
+            if let Ok(content) = std::str::from_utf8(&data)
+                && !data.contains(&0)
+            {
+                page.readme_content = content.to_owned();
+            } else {
+                page.readme_binary = true;
+            }
+        }
+        page
+    }
+
+    fn refs(record: RepositoryRecord, references: Vec<crate::git::read::RefInfo>) -> Self {
+        let mut page = Self::base(record, "refs", "Refs".to_owned());
+        page.refs = references
+            .into_iter()
+            .map(|reference| {
+                let href = reference.peeled.unwrap_or(reference.target).to_string();
+                RefView {
+                    name: display_bytes(&reference.name),
+                    target: reference.target.to_string(),
+                    href,
+                    peeled: reference
+                        .peeled
+                        .map(|id| id.to_string())
+                        .unwrap_or_default(),
+                    symbolic: reference
+                        .symbolic_target
+                        .map(|target| display_bytes(&target))
+                        .unwrap_or_default(),
+                }
+            })
+            .collect();
+        page
+    }
+
+    fn commit(record: RepositoryRecord, commit: CommitInfo) -> Self {
+        let mut page = Self::base(record, "commit", "Commit".to_owned());
+        page.has_head = true;
+        page.commit_id = commit.id.to_string();
+        page.commit = CommitView::from(commit);
+        page
+    }
+
+    fn diff(record: RepositoryRecord, old: ObjectId, new: ObjectId, files: Vec<DiffFile>) -> Self {
+        let mut page = Self::base(record, "diff", "Diff".to_owned());
+        page.has_head = true;
+        page.commit_id = new.to_string();
+        page.secondary_id = old.to_string();
+        page.diffs = files.into_iter().map(DiffView::from).collect();
+        page
+    }
+
+    fn tree(
+        record: RepositoryRecord,
+        commit: ObjectId,
+        path: Vec<u8>,
+        entries: Vec<TreeEntryInfo>,
+    ) -> Self {
+        let mut page = Self::base(record, "tree", "Tree".to_owned());
+        page.has_head = true;
+        page.commit_id = commit.to_string();
+        page.path = display_bytes(&path);
+        page.encoded_path = encode_path(&path);
+        page.entries = entries
+            .into_iter()
+            .map(|entry| TreeView::new(commit, &path, entry))
+            .collect();
+        page
+    }
+
+    fn blob(record: RepositoryRecord, commit: ObjectId, path: Vec<u8>, data: Vec<u8>) -> Self {
+        let mut page = Self::base(record, "blob", "Blob".to_owned());
+        page.has_head = true;
+        page.commit_id = commit.to_string();
+        page.path = display_bytes(&path);
+        page.encoded_path = encode_path(&path);
+        if let Ok(content) = std::str::from_utf8(&data)
+            && !data.contains(&0)
+        {
+            page.blob_content = content.to_owned();
+        } else {
+            page.blob_binary = true;
+        }
+        page
+    }
+
+    fn blame(
+        record: RepositoryRecord,
+        commit: ObjectId,
+        path: Vec<u8>,
+        data: Vec<u8>,
+        hunks: Vec<BlameHunk>,
+    ) -> Self {
+        let mut page = Self::base(record, "blame", "Blame".to_owned());
+        page.has_head = true;
+        page.commit_id = commit.to_string();
+        page.path = display_bytes(&path);
+        page.encoded_path = encode_path(&path);
+        if let Ok(content) = std::str::from_utf8(&data)
+            && !data.contains(&0)
+        {
+            let lines: Vec<&str> = content.lines().collect();
+            page.blame = hunks
+                .into_iter()
+                .map(|hunk| BlameView::new(hunk, &lines))
+                .collect();
+        } else {
+            page.blob_binary = true;
+        }
+        page
+    }
+}
+
+#[derive(Default)]
+struct CommitView {
+    id: String,
+    tree: String,
+    parents: Vec<String>,
+    author_name: String,
+    author_email: String,
+    committed_at: i64,
+    summary: String,
+    message: String,
+}
+
+impl From<CommitInfo> for CommitView {
+    fn from(commit: CommitInfo) -> Self {
+        Self {
+            id: commit.id.to_string(),
+            tree: commit.tree.to_string(),
+            parents: commit
+                .parents
+                .into_iter()
+                .map(|id| id.to_string())
+                .collect(),
+            author_name: display_bytes(&commit.author_name),
+            author_email: display_bytes(&commit.author_email),
+            committed_at: commit.committed_at,
+            summary: message_summary(&commit.message),
+            message: display_bytes(&commit.message),
+        }
+    }
+}
+
+struct TreeView {
+    name: String,
+    id: String,
+    mode: String,
+    kind: &'static str,
+    href: String,
+}
+
+impl TreeView {
+    fn new(commit: ObjectId, parent: &[u8], entry: TreeEntryInfo) -> Self {
+        let mut path = parent.to_vec();
+        if !path.is_empty() {
+            path.push(b'/');
+        }
+        path.extend_from_slice(&entry.name);
+        let (kind, href) = match entry.kind {
+            EntryKind::Tree => ("tree", format!("tree/{commit}/{}", encode_path(&path))),
+            EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link => {
+                ("blob", format!("blob/{commit}/{}", encode_path(&path)))
+            }
+            EntryKind::Commit => ("commit", format!("commit/{}", entry.id)),
+        };
+        Self {
+            name: display_bytes(&entry.name),
+            id: entry.id.to_string(),
+            mode: format!("{:06o}", entry.mode),
+            kind,
+            href,
+        }
+    }
+}
+
+struct DiffView {
+    path: String,
+    old_id: String,
+    new_id: String,
+    old_mode: String,
+    new_mode: String,
+    binary: bool,
+    hunks: String,
+}
+
+impl From<DiffFile> for DiffView {
+    fn from(file: DiffFile) -> Self {
+        Self {
+            path: display_bytes(&file.path),
+            old_id: file.old_id.map(|id| id.to_string()).unwrap_or_default(),
+            new_id: file.new_id.map(|id| id.to_string()).unwrap_or_default(),
+            old_mode: file
+                .old_mode
+                .map(|mode| format!("{mode:06o}"))
+                .unwrap_or_default(),
+            new_mode: file
+                .new_mode
+                .map(|mode| format!("{mode:06o}"))
+                .unwrap_or_default(),
+            binary: file.binary,
+            hunks: display_bytes(&file.hunks),
+        }
+    }
+}
+
+struct RefView {
+    name: String,
+    target: String,
+    href: String,
+    peeled: String,
+    symbolic: String,
+}
+
+struct BlameView {
+    start_line: u32,
+    source_start_line: u32,
+    line_count: u32,
+    end_line: u32,
+    commit_id: String,
+    source_path: String,
+    content: String,
+}
+
+impl BlameView {
+    fn new(hunk: BlameHunk, lines: &[&str]) -> Self {
+        let start = usize::try_from(hunk.start_line.saturating_sub(1)).unwrap_or(usize::MAX);
+        let count = usize::try_from(hunk.line_count).unwrap_or(usize::MAX);
+        let content = lines
+            .get(start..start.saturating_add(count))
+            .unwrap_or_default()
+            .join("\n");
+        Self {
+            start_line: hunk.start_line,
+            source_start_line: hunk.source_start_line,
+            line_count: hunk.line_count,
+            end_line: hunk
+                .source_start_line
+                .saturating_add(hunk.line_count.saturating_sub(1)),
+            commit_id: hunk.commit_id.to_string(),
+            source_path: hunk
+                .source_path
+                .map(|path| display_bytes(&path))
+                .unwrap_or_default(),
+            content,
+        }
+    }
+}
+
+#[derive(Debug)]
+enum RouteError {
+    NotFound,
+    InvalidRequest,
+    Unavailable,
+    Internal,
+}
+
+impl From<ReadError> for RouteError {
+    fn from(error: ReadError) -> Self {
+        match error {
+            ReadError::InvalidPath
+            | ReadError::ObjectNotFound(_)
+            | ReadError::PathNotFound(_)
+            | ReadError::NotTree(_)
+            | ReadError::NotBlob(_)
+            | ReadError::WrongObjectKind { .. } => Self::NotFound,
+            ReadError::Limit(_) | ReadError::Cancelled | ReadError::Deadline => Self::Unavailable,
+            _ => Self::Internal,
+        }
+    }
+}
+
+impl From<StoreError> for RouteError {
+    fn from(error: StoreError) -> Self {
+        match error {
+            StoreError::RepositoryNotFound(_, _) => Self::NotFound,
+            _ => Self::Internal,
+        }
+    }
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum PublicWebError {
+    #[error("cannot open the repository directory: {0}")]
+    RepositoryDirectory(std::io::Error),
+    #[error("the repository directory is not a directory")]
+    InvalidRepositoryDirectory,
+    #[error(transparent)]
+    Store(#[from] StoreError),
+    #[error("clone URL base is not valid")]
+    CloneBase,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn round_trips_arbitrary_git_path_bytes() {
+        let path = b"directory/non-\xff name";
+        let encoded = encode_path(path);
+        assert_eq!(encoded, "directory/non-%FF%20name");
+        assert_eq!(decode_path(&encoded).expect("decode a Git path"), path);
+    }
+
+    #[test]
+    fn rejects_malformed_percent_encoded_paths() {
+        for path in ["%", "%0", "%GG"] {
+            assert!(matches!(decode_path(path), Err(RouteError::NotFound)));
+        }
+    }
+
+    #[test]
+    fn extracts_paths_when_the_repository_matches_a_route_name() {
+        assert_eq!(
+            content_route("/alice/blob/blob/abc/file.txt", "blob")
+                .expect("extract the exact route prefix"),
+            (
+                CommitPath {
+                    owner: "alice".to_owned(),
+                    repository: "blob".to_owned(),
+                    commit: "abc".to_owned(),
+                },
+                b"file.txt".to_vec()
+            )
+        );
+    }
+}

src/main.rs

Mode 100644100644; object 5a8247d54ae2f9387aae2cb3

@@ -12,7 +12,7 @@
 mod git;
 #[allow(
     dead_code,
-    reason = "M2.4 establishes the HTTP shell before tit serve calls it"
+    reason = "M2 establishes the Web interface before tit serve calls it"
 )]
 mod http;
 mod instance;

src/store/mod.rs

Mode 100644100644; object 9bac8761012831509e1ba88e

@@ -464,6 +464,36 @@
             Err(error) => Err(error.into()),
         }
     }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests import the store without public HTTP routes"
+    )]
+    pub(crate) fn public_repository(
+        &self,
+        owner: &str,
+        slug: &str,
+    ) -> Result<RepositoryRecord, StoreError> {
+        let result = self.connection.query_row(
+            "SELECT repository.id, account.username, repository.slug,
+                    repository.visibility, repository.state, repository.object_format,
+                    repository.created_at, repository.archived_at
+             FROM repository
+             JOIN account ON account.id = repository.owner_account_id
+             WHERE account.username = ?1 AND repository.slug = ?2
+               AND repository.visibility = 'public' AND repository.state = 'active'",
+            rusqlite::params![owner, slug],
+            repository_from_row,
+        );
+        match result {
+            Ok(repository) => Ok(repository),
+            Err(rusqlite::Error::QueryReturnedNoRows) => Err(StoreError::RepositoryNotFound(
+                owner.to_owned(),
+                slug.to_owned(),
+            )),
+            Err(error) => Err(error.into()),
+        }
+    }
 }
 
 pub(crate) struct InitialAdministrator<'a> {

templates/repository.html

Mode 100644; object c77fea307600

@@ -1,0 +1,137 @@
+{% extends "base.html" %}
+{% block title %}{{ page_title }} · tit{% endblock %}
+{% block content %}
+  <header class="repository-header">
+    <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+    <nav aria-label="Repository">
+      <a href="/{{ owner }}/{{ repository }}">Summary</a>
+      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
+{% if has_head %}
+      <a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">Tree</a>
+      <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}">Commit</a>
+      <a href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar">Archive</a>
+{% endif %}
+    </nav>
+  </header>
+
+{% if page_kind == "summary" %}
+  <section aria-labelledby="clone-heading">
+    <h2 id="clone-heading">Clone</h2>
+    <dl>
+      <dt>HTTP</dt><dd><code>{{ http_clone_url }}</code></dd>
+      <dt>SSH</dt><dd><code>{{ ssh_clone_url }}</code></dd>
+      <dt>Object format</dt><dd>{{ object_format }}</dd>
+      <dt>Created</dt><dd><time>{{ created_at }}</time></dd>
+    </dl>
+  </section>
+{% if has_head %}
+  <section aria-labelledby="history-heading">
+    <h2 id="history-heading">Recent commits</h2>
+    <ol class="commit-list">
+{% for item in history %}
+      <li><a href="/{{ owner }}/{{ repository }}/commit/{{ item.id }}"><code>{{ item.id }}</code></a> {{ item.summary }} — {{ item.author_name }}</li>
+{% endfor %}
+    </ol>
+  </section>
+{% if has_readme %}
+  <section aria-labelledby="readme-heading">
+    <h2 id="readme-heading">{{ readme_path }}</h2>
+{% if readme_binary %}
+    <p>Binary README content cannot be shown.</p>
+{% else %}
+    <pre>{{ readme_content }}</pre>
+{% endif %}
+  </section>
+{% endif %}
+{% else %}
+  <p>This repository has no commits.</p>
+{% endif %}
+{% endif %}
+
+{% if page_kind == "refs" %}
+  <h2>Refs</h2>
+  <table>
+    <thead><tr><th scope="col">Name</th><th scope="col">Target</th><th scope="col">Details</th></tr></thead>
+    <tbody>
+{% for item in refs %}
+      <tr>
+        <th scope="row">{{ item.name }}</th>
+        <td><a href="/{{ owner }}/{{ repository }}/commit/{{ item.href }}"><code>{{ item.target }}</code></a></td>
+        <td>{% if item.symbolic != "" %}→ {{ item.symbolic }}{% endif %}{% if item.peeled != "" %}peeled <code>{{ item.peeled }}</code>{% endif %}</td>
+      </tr>
+{% endfor %}
+    </tbody>
+  </table>
+{% endif %}
+
+{% if page_kind == "tree" %}
+  <h2>Tree{% if path != "" %}: {{ path }}{% endif %}</h2>
+  <table>
+    <thead><tr><th scope="col">Mode</th><th scope="col">Type</th><th scope="col">Name</th><th scope="col">Object</th></tr></thead>
+    <tbody>
+{% for item in entries %}
+      <tr>
+        <td><code>{{ item.mode }}</code></td><td>{{ item.kind }}</td>
+        <th scope="row"><a href="/{{ owner }}/{{ repository }}/{{ item.href }}">{{ item.name }}</a></th>
+        <td><code>{{ item.id }}</code></td>
+      </tr>
+{% endfor %}
+    </tbody>
+  </table>
+{% endif %}
+
+{% if page_kind == "blob" %}
+  <h2>Blob: {{ path }}</h2>
+  <p><a href="/{{ owner }}/{{ repository }}/raw/{{ commit_id }}/{{ encoded_path }}">Raw</a> · <a href="/{{ owner }}/{{ repository }}/blame/{{ commit_id }}/{{ encoded_path }}">Blame</a></p>
+{% if blob_binary %}
+  <p>Binary content cannot be shown. Use the raw link to download it.</p>
+{% else %}
+  <pre>{{ blob_content }}</pre>
+{% endif %}
+{% endif %}
+
+{% if page_kind == "commit" %}
+  <article>
+    <h2>Commit <code>{{ commit.id }}</code></h2>
+    <dl>
+      <dt>Author</dt><dd>{{ commit.author_name }} &lt;{{ commit.author_email }}&gt;</dd>
+      <dt>Committed</dt><dd><time>{{ commit.committed_at }}</time></dd>
+      <dt>Tree</dt><dd><a href="/{{ owner }}/{{ repository }}/tree/{{ commit.id }}"><code>{{ commit.tree }}</code></a></dd>
+{% for parent in commit.parents %}
+      <dt>Parent</dt><dd><a href="/{{ owner }}/{{ repository }}/commit/{{ parent }}"><code>{{ parent }}</code></a> · <a href="/{{ owner }}/{{ repository }}/diff/{{ parent }}/{{ commit.id }}">Diff</a></dd>
+{% endfor %}
+    </dl>
+    <pre>{{ commit.message }}</pre>
+  </article>
+{% endif %}
+
+{% if page_kind == "diff" %}
+  <h2>Diff</h2>
+  <p><code>{{ secondary_id }}</code> → <code>{{ commit_id }}</code></p>
+{% for file in diffs %}
+  <section>
+    <h3>{{ file.path }}</h3>
+    <p>Mode <code>{{ file.old_mode }}</code> → <code>{{ file.new_mode }}</code>; object <code>{{ file.old_id }}</code> → <code>{{ file.new_id }}</code></p>
+{% if file.binary %}
+    <p>Binary content changed.</p>
+{% else %}
+    <pre>{{ file.hunks }}</pre>
+{% endif %}
+  </section>
+{% endfor %}
+{% endif %}
+
+{% if page_kind == "blame" %}
+  <h2>Blame: {{ path }}</h2>
+  <p><a href="/{{ owner }}/{{ repository }}/blob/{{ commit_id }}/{{ encoded_path }}">Blob</a> · <a href="/{{ owner }}/{{ repository }}/raw/{{ commit_id }}/{{ encoded_path }}">Raw</a></p>
+{% if blob_binary %}
+  <p>Binary content cannot be blamed.</p>
+{% else %}
+  <ol class="blame-list">
+{% for item in blame %}
+    <li value="{{ item.start_line }}"><a href="/{{ owner }}/{{ repository }}/commit/{{ item.commit_id }}"><code>{{ item.commit_id }}</code></a> lines {{ item.source_start_line }}–{{ item.end_line }}{% if item.source_path != "" %} from {{ item.source_path }}{% endif %}<pre>{{ item.content }}</pre></li>
+{% endfor %}
+  </ol>
+{% endif %}
+{% endif %}
+{% endblock %}

tests/public_routes.rs

Mode 100644; object b27d3351b5b9

@@ -1,0 +1,600 @@
+#[allow(
+    dead_code,
+    reason = "the public-route test uses only username validation"
+)]
+#[path = "../src/auth.rs"]
+mod auth;
+#[path = "../src/domain/mod.rs"]
+mod domain;
+#[allow(
+    dead_code,
+    reason = "the public-route test does not use each shared Git API"
+)]
+#[path = "../src/git/mod.rs"]
+mod git;
+#[allow(
+    dead_code,
+    reason = "the public-route test uses the public Web server only"
+)]
+#[path = "../src/http/mod.rs"]
+mod http;
+#[allow(
+    dead_code,
+    reason = "the public-route test creates instance files directly"
+)]
+#[path = "../src/instance.rs"]
+mod instance;
+#[allow(
+    dead_code,
+    reason = "the public-route test does not use each store API"
+)]
+#[path = "../src/store/mod.rs"]
+mod store;
+
+use std::collections::BTreeMap;
+use std::fs;
+use std::io::{Read, Write};
+use std::net::{Ipv4Addr, SocketAddr, TcpStream};
+use std::path::Path;
+use std::process::Command;
+
+use http::{PublicWebConfig, RunningWebServer};
+use store::{InitialAdministrator, NewRepository, Store};
+use tempfile::TempDir;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn browses_and_clones_public_repositories_for_both_hash_formats() {
+    for format in ["sha1", "sha256"] {
+        let fixture = Fixture::new(format);
+        let server = RunningWebServer::start_public(
+            SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
+            PublicWebConfig {
+                instance_dir: fixture.instance.path().to_owned(),
+                http_clone_base: "https://tit.example".to_owned(),
+                ssh_clone_base: "ssh://tit.example:2222".to_owned(),
+            },
+        )
+        .await
+        .expect("start the public Web server");
+
+        let summary = request(server.address(), "GET", "/alice/example", &[], &[]);
+        assert_eq!(summary.status, 200);
+        assert_html_policy(&summary);
+        let summary_text = summary.text();
+        assert!(summary_text.contains("<h1><a href=\"/alice/example\">alice/example</a></h1>"));
+        assert!(summary_text.contains("https://tit.example/alice/example"));
+        assert!(summary_text.contains("ssh://tit.example:2222/alice/example"));
+        assert!(summary_text.contains(&fixture.head));
+        assert!(summary_text.contains("README.md"));
+        assert!(summary_text.contains("tit fixture &#60;safe&#62;"));
+        assert!(!summary_text.contains("<script"));
+
+        let empty = request(server.address(), "GET", "/alice/empty", &[], &[]);
+        assert_eq!(empty.status, 200);
+        assert!(empty.text().contains("This repository has no commits."));
+
+        let alias = request(server.address(), "GET", "/alice/example.git", &[], &[]);
+        assert_eq!(alias.status, 308);
+        assert_eq!(alias.header("location"), "/alice/example");
+
+        let routes = [
+            "/alice/example/refs".to_owned(),
+            format!("/alice/example/commit/{}", fixture.head),
+            format!("/alice/example/tree/{}", fixture.head),
+            format!("/alice/example/tree/{}/nested", fixture.head),
+            format!("/alice/example/blob/{}/nested/file.txt", fixture.head),
+            format!("/alice/example/diff/{}/{}", fixture.parent, fixture.head),
+            format!("/alice/example/blame/{}/nested/file.txt", fixture.head),
+        ];
+        for route in &routes {
+            let response = request(server.address(), "GET", route, &[], &[]);
+            assert_eq!(response.status, 200, "route failed: {route}");
+            assert_html_policy(&response);
+            assert!(!response.text().to_ascii_lowercase().contains("<script"));
+
+            let head = request(server.address(), "HEAD", route, &[], &[]);
+            assert_eq!(head.status, 200, "HEAD failed: {route}");
+            assert!(head.body.is_empty());
+            assert_eq!(
+                head.header("content-length"),
+                response.body.len().to_string()
+            );
+        }
+
+        let tree = request(
+            server.address(),
+            "GET",
+            &format!("/alice/example/tree/{}", fixture.head),
+            &[],
+            &[],
+        );
+        let tree_text = tree.text();
+        assert!(tree_text.contains("README.md"));
+        assert!(tree_text.contains("binary.dat"));
+        assert!(tree_text.contains("non-å.txt"));
+        assert!(tree_text.contains("non-%C3%A5.txt"));
+
+        let blob = request(
+            server.address(),
+            "GET",
+            &format!("/alice/example/blob/{}/nested/file.txt", fixture.head),
+            &[],
+            &[],
+        );
+        assert!(blob.text().contains("second line"));
+        assert!(blob.text().contains(&format!(
+            "/alice/example/raw/{}/nested/file.txt",
+            fixture.head
+        )));
+
+        let binary = request(
+            server.address(),
+            "GET",
+            &format!("/alice/example/blob/{}/binary.dat", fixture.head),
+            &[],
+            &[],
+        );
+        assert_eq!(binary.status, 200);
+        assert!(binary.text().contains("Binary content cannot be shown."));
+
+        let raw = request(
+            server.address(),
+            "GET",
+            &format!("/alice/example/raw/{}/nested/file.txt", fixture.head),
+            &[],
+            &[],
+        );
+        assert_eq!(raw.status, 200);
+        assert_eq!(raw.header("content-type"), "application/octet-stream");
+        assert_eq!(raw.body, b"first line\nsecond line\n");
+        assert_eq!(
+            raw.header("cache-control"),
+            "public, max-age=31536000, immutable"
+        );
+        let raw_head = request(
+            server.address(),
+            "HEAD",
+            &format!("/alice/example/raw/{}/nested/file.txt", fixture.head),
+            &[],
+            &[],
+        );
+        assert_eq!(raw_head.status, 200);
+        assert!(raw_head.body.is_empty());
+        assert_eq!(
+            raw_head.header("content-length"),
+            raw.body.len().to_string()
+        );
+
+        let non_utf8 = request(
+            server.address(),
+            "GET",
+            &format!("/alice/example/raw/{}/non-%C3%A5.txt", fixture.head),
+            &[],
+            &[],
+        );
+        assert_eq!(non_utf8.status, 200);
+        assert_eq!(non_utf8.body, b"non-UTF-8 path\n");
+        let missing_non_utf8 = request(
+            server.address(),
+            "GET",
+            &format!("/alice/example/raw/{}/missing-%FF.txt", fixture.head),
+            &[],
+            &[],
+        );
+        assert_eq!(missing_non_utf8.status, 404);
+        assert_html_policy(&missing_non_utf8);
+
+        let archive = request(
+            server.address(),
+            "GET",
+            &format!("/alice/example/archive/{}.tar", fixture.head),
+            &[],
+            &[],
+        );
+        assert_eq!(archive.status, 200);
+        assert_eq!(archive.header("content-type"), "application/x-tar");
+        assert!(archive.body.ends_with(&[0_u8; 1024]));
+        let archive_head = request(
+            server.address(),
+            "HEAD",
+            &format!("/alice/example/archive/{}.tar", fixture.head),
+            &[],
+            &[],
+        );
+        assert_eq!(archive_head.status, 200);
+        assert!(archive_head.body.is_empty());
+        let archive_path = fixture.instance.path().join(format!("{format}.tar"));
+        fs::write(&archive_path, &archive.body).expect("write the public archive");
+        let listed = Command::new("tar")
+            .arg("-tf")
+            .arg(&archive_path)
+            .output()
+            .expect("run the system tar reader");
+        assert!(listed.status.success());
+        let names = String::from_utf8_lossy(&listed.stdout);
+        assert!(names.contains("README.md"));
+        assert!(names.contains("nested/file.txt"));
+
+        let discovery = request(
+            server.address(),
+            "GET",
+            "/alice/example.git/info/refs?service=git-upload-pack",
+            &[("Git-Protocol", "version=2")],
+            &[],
+        );
+        assert_eq!(discovery.status, 200);
+        assert_eq!(
+            discovery.header("content-type"),
+            "application/x-git-upload-pack-advertisement"
+        );
+        assert!(
+            discovery
+                .body
+                .windows(b"version 2".len())
+                .any(|window| window == b"version 2")
+        );
+        assert_eq!(
+            request(
+                server.address(),
+                "GET",
+                "/alice/example/info/refs?service=git-receive-pack",
+                &[],
+                &[],
+            )
+            .status,
+            400
+        );
+        assert_eq!(
+            request(
+                server.address(),
+                "GET",
+                "/alice/example/info/refs?service=git-upload-pack",
+                &[("Git-Protocol", "version=3")],
+                &[],
+            )
+            .status,
+            400
+        );
+        assert_eq!(
+            request(
+                server.address(),
+                "POST",
+                "/alice/example/git-upload-pack",
+                &[("Content-Type", "text/plain")],
+                b"0000",
+            )
+            .status,
+            415
+        );
+        assert_eq!(
+            request(
+                server.address(),
+                "POST",
+                "/alice/example/git-upload-pack",
+                &[
+                    ("Content-Type", "application/x-git-upload-pack-request"),
+                    ("Git-Protocol", "version=2"),
+                ],
+                b"zzzz",
+            )
+            .status,
+            400
+        );
+        assert_eq!(
+            request(
+                server.address(),
+                "POST",
+                "/alice/example/git-upload-pack",
+                &[("Content-Type", "application/x-git-upload-pack-request")],
+                &vec![b'0'; git::packetline::MAX_REQUEST_BYTES + 1],
+            )
+            .status,
+            413
+        );
+
+        let clone = fixture.instance.path().join(format!("clone-{format}"));
+        run(Command::new("git")
+            .args(["-c", "protocol.version=2", "clone", "-q"])
+            .arg(format!("http://{}/alice/example.git", server.address()))
+            .arg(&clone));
+        assert_eq!(rev_parse(&clone, "HEAD"), fixture.head);
+        assert_eq!(
+            fs::read(clone.join("nested/file.txt")).expect("read the cloned file"),
+            b"first line\nsecond line\n"
+        );
+
+        for route in [
+            "/alice/missing",
+            "/Alice/example",
+            "/alice/example/commit/not-an-object",
+            "/alice/example/tree/0000000000000000000000000000000000000000",
+            "/alice/example/raw/0000000000000000000000000000000000000000/file",
+        ] {
+            let response = request(server.address(), "GET", route, &[], &[]);
+            assert_eq!(response.status, 404, "route leaked or accepted: {route}");
+            assert_html_policy(&response);
+        }
+
+        let database = fixture.instance.path().join(store::DATABASE_FILE);
+        let hidden = Store::open(&database).expect("open the repository database");
+        hidden
+            .connection()
+            .execute(
+                "UPDATE repository SET visibility = 'private' WHERE slug = 'example'",
+                [],
+            )
+            .expect("make the repository private");
+        drop(hidden);
+        assert_hidden(server.address(), &fixture.head);
+
+        let archived = Store::open(&database).expect("reopen the repository database");
+        archived
+            .connection()
+            .execute(
+                "UPDATE repository
+                 SET visibility = 'public', state = 'archived', archived_at = 3
+                 WHERE slug = 'example'",
+                [],
+            )
+            .expect("archive the repository");
+        drop(archived);
+        assert_hidden(server.address(), &fixture.head);
+
+        server.shutdown().await.expect("stop the public Web server");
+    }
+}
+
+fn assert_hidden(address: SocketAddr, head: &str) {
+    for route in [
+        "/alice/example".to_owned(),
+        format!("/alice/example/raw/{head}/README.md"),
+        "/alice/example/info/refs?service=git-upload-pack".to_owned(),
+    ] {
+        let response = request(address, "GET", &route, &[], &[]);
+        assert_eq!(response.status, 404, "repository was visible at {route}");
+    }
+}
+
+struct Fixture {
+    instance: TempDir,
+    head: String,
+    parent: String,
+}
+
+impl Fixture {
+    fn new(format: &str) -> Self {
+        let instance = TempDir::new().expect("create an instance directory");
+        let repositories = instance.path().join("repositories");
+        fs::create_dir(&repositories).expect("create the repository directory");
+        let id = if format == "sha1" {
+            "11111111111111111111111111111111"
+        } else {
+            "22222222222222222222222222222222"
+        };
+        let bare = repositories.join(format!("{id}.git"));
+        let empty_bare = repositories.join("33333333333333333333333333333333.git");
+        let worktree = instance.path().join("worktree");
+
+        run(Command::new("git")
+            .args(["init", "-q", "-b", "main", "--object-format", format])
+            .arg(&worktree));
+        fs::write(worktree.join("README.md"), b"# tit fixture <safe>\n").expect("write the README");
+        fs::create_dir(worktree.join("nested")).expect("create a nested directory");
+        fs::write(worktree.join("nested/file.txt"), b"first line\n").expect("write the text file");
+        fs::write(worktree.join("binary.dat"), b"binary\0content").expect("write the binary file");
+        fs::write(worktree.join("non-å.txt"), b"non-UTF-8 path\n")
+            .expect("write the percent-encoded path");
+        commit_all(&worktree, "first commit");
+        let parent = rev_parse(&worktree, "HEAD");
+        fs::write(
+            worktree.join("nested/file.txt"),
+            b"first line\nsecond line\n",
+        )
+        .expect("update the text file");
+        commit_all(&worktree, "second commit");
+        let head = rev_parse(&worktree, "HEAD");
+
+        run(Command::new("git")
+            .args(["init", "-q", "--bare", "--object-format", format])
+            .arg(&bare));
+        run(Command::new("git")
+            .args(["init", "-q", "--bare", "--object-format", format])
+            .arg(&empty_bare));
+        run(Command::new("git").arg("-C").arg(&bare).args([
+            "symbolic-ref",
+            "HEAD",
+            "refs/heads/main",
+        ]));
+        run(Command::new("git")
+            .arg("-C")
+            .arg(&worktree)
+            .args(["push", "-q"])
+            .arg(&bare)
+            .arg("main"));
+
+        let database = instance.path().join(store::DATABASE_FILE);
+        let mut store = Store::open(&database).expect("open the fixture database");
+        store
+            .create_initial_administrator(&InitialAdministrator {
+                username: "alice",
+                canonical_key: "ssh-ed25519 AAAA",
+                fingerprint: "SHA256:fixture",
+                recovery_hash: &[7_u8; 32],
+                created_at: 1,
+            })
+            .expect("create the repository owner");
+        store
+            .create_repository(&NewRepository {
+                id,
+                owner: "alice",
+                slug: "example",
+                object_format: format,
+                created_at: 2,
+            })
+            .expect("create the repository record");
+        store
+            .create_repository(&NewRepository {
+                id: "33333333333333333333333333333333",
+                owner: "alice",
+                slug: "empty",
+                object_format: format,
+                created_at: 2,
+            })
+            .expect("create the empty repository record");
+        drop(store);
+
+        Self {
+            instance,
+            head,
+            parent,
+        }
+    }
+}
+
+fn commit_all(worktree: &Path, message: &str) {
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["add", "-A"]));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["commit", "-q", "-m", message])
+        .env("GIT_AUTHOR_NAME", "Fixture Author")
+        .env("GIT_AUTHOR_EMAIL", "fixture@example.test")
+        .env("GIT_COMMITTER_NAME", "Fixture Author")
+        .env("GIT_COMMITTER_EMAIL", "fixture@example.test"));
+}
+
+fn rev_parse(repository: &Path, revision: &str) -> String {
+    let output = Command::new("git")
+        .arg("-C")
+        .arg(repository)
+        .args(["rev-parse", revision])
+        .output()
+        .expect("read a Git object ID");
+    assert!(output.status.success());
+    String::from_utf8(output.stdout)
+        .expect("a hexadecimal object ID")
+        .trim()
+        .to_owned()
+}
+
+fn run(command: &mut Command) {
+    let output = command.output().expect("run a fixture command");
+    assert!(
+        output.status.success(),
+        "command failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+}
+
+fn request(
+    address: SocketAddr,
+    method: &str,
+    path: &str,
+    headers: &[(&str, &str)],
+    body: &[u8],
+) -> HttpResponse {
+    let mut stream = TcpStream::connect(address).expect("connect to the public Web server");
+    let mut head = format!(
+        "{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n",
+        body.len()
+    );
+    for (name, value) in headers {
+        head.push_str(&format!("{name}: {value}\r\n"));
+    }
+    head.push_str("\r\n");
+    stream
+        .write_all(head.as_bytes())
+        .and_then(|()| stream.write_all(body))
+        .expect("write an HTTP request");
+    let mut response = Vec::new();
+    stream
+        .read_to_end(&mut response)
+        .expect("read an HTTP response");
+    HttpResponse::parse(&response)
+}
+
+fn assert_html_policy(response: &HttpResponse) {
+    assert_eq!(response.header("content-type"), "text/html; charset=utf-8");
+    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("cache-control"), "no-store");
+    assert_eq!(response.header("x-request-id").len(), 32);
+}
+
+struct HttpResponse {
+    status: u16,
+    headers: BTreeMap<String, String>,
+    body: Vec<u8>,
+}
+
+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::<BTreeMap<_, _>>();
+        let raw_body = &bytes[split + 4..];
+        let body = if headers.get("transfer-encoding").map(String::as_str) == Some("chunked") {
+            decode_chunked(raw_body)
+        } else {
+            raw_body.to_vec()
+        };
+        Self {
+            status,
+            headers,
+            body,
+        }
+    }
+
+    fn header(&self, name: &str) -> &str {
+        self.headers
+            .get(name)
+            .unwrap_or_else(|| panic!("missing {name} response header"))
+    }
+
+    fn text(&self) -> &str {
+        std::str::from_utf8(&self.body).expect("a UTF-8 response body")
+    }
+}
+
+fn decode_chunked(mut input: &[u8]) -> Vec<u8> {
+    let mut output = Vec::new();
+    loop {
+        let line_end = input
+            .windows(2)
+            .position(|window| window == b"\r\n")
+            .expect("a chunk-size terminator");
+        let size_text = std::str::from_utf8(&input[..line_end]).expect("an ASCII chunk size");
+        let size = usize::from_str_radix(size_text.split(';').next().expect("a chunk size"), 16)
+            .expect("a hexadecimal chunk size");
+        input = &input[line_end + 2..];
+        if size == 0 {
+            break;
+        }
+        assert!(input.len() >= size + 2, "a complete HTTP chunk");
+        output.extend_from_slice(&input[..size]);
+        assert_eq!(&input[size..size + 2], b"\r\n");
+        input = &input[size + 2..];
+    }
+    output
+}

tests/web_shell.rs

Mode 100644100644; object d50e9f84fcb5323a52318c99

@@ -7,8 +7,24 @@
 )]
 #[path = "../src/domain/mod.rs"]
 mod domain;
+#[allow(dead_code, reason = "the shell test does not use each shared Git API")]
+#[path = "../src/git/mod.rs"]
+mod git;
+#[allow(
+    dead_code,
+    reason = "the shell test does not use public repository routes"
+)]
 #[path = "../src/http/mod.rs"]
 mod http;
+#[allow(
+    dead_code,
+    reason = "the shell test does not use instance administration"
+)]
+#[path = "../src/instance.rs"]
+mod instance;
+#[allow(dead_code, reason = "the shell test does not use repository storage")]
+#[path = "../src/store/mod.rs"]
+mod store;
 
 use std::collections::BTreeMap;
 use std::io::{Read, Write};