michal/tit
Browse tree · Show commit · Download archive
Diff
8f65f5d2f9e1 → d6ec77036a8f
Cargo.lock
Mode 100644 → 100644; object 7873306995d1 → 6750b6170adf
@@ -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 100644 → 100644; object 6ef7aa7f00b7 → eda5bb448430
@@ -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 100644 → 100644; object 758074ee633f → c7918a27dc34
@@ -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 100644 → 100644; object db96461ed5b4 → ab75bca6a05c
@@ -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 100644 → 100644; object 83bb27f4e256 → 39012e4eb437
@@ -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 100644 → 100644; object 772d5fbd758e → f51553ff5475
@@ -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 100644 → 100644; object 5a8247d54ae2 → f9387aae2cb3
@@ -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 100644 → 100644; object 9bac87610128 → 31509e1ba88e
@@ -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 }} <{{ commit.author_email }}></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 <safe>"));
+ 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 100644 → 100644; object d50e9f84fcb5 → 323a52318c99
@@ -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};