steid

@jamesgill /

1//! The git smart-HTTP transport — `/{handle}/repos/{name}.git/…`.
2//!
3//! Three routes, one per endpoint of the protocol, each naming the service it serves as
4//! a literal. Nothing here parses an operation out of a path: the route *is* the
5//! operation, and [`serve_git`] rebuilds the path it hands to git from validated values.
6//! That matters because `git http-backend` will happily serve dumb-protocol object files
7//! under any path given to it — the router is the allowlist.
8
9use std::{
10 io,
11 pin::Pin,
12 task::{Context, Poll},
13};
14
15use bytes::Bytes;
16use futures_util::TryStreamExt;
17use http_body::Frame;
18use serde::Deserialize;
19use tokio::io::{AsyncRead, ReadBuf};
20use tokio_util::io::StreamReader;
21use topcoat::{
22 Result,
23 context::Cx,
24 router::{
25 Body, Response,
26 error::{RouterErrorExt, bad_request, forbidden, not_found},
27 parse_query_params, path_param, route,
28 },
29};
30
31use crate::{
32 application::{Error, GitClientHeaders, GitEndpoint, GitService, port::ByteStream, serve_git},
33 domain::{DomainError, RepoName},
34};
35
36use super::{
37 context::{current_actor, memberships, orgs, protocol, repos, server_error},
38 profile::handle_param,
39};
40
41/// How much of the response is read from git in one go.
42///
43/// A clone streams as fast as the client takes it, so this bounds the memory a transfer
44/// holds rather than its speed.
45const CHUNK: usize = 16 * 1024;
46
47/// `{repo}` from the path — the repository name *with* its `.git` suffix.
48#[path_param]
49struct Repo(str);
50
51#[derive(Debug, Deserialize)]
52struct ServiceQuery {
53 service: Option<String>,
54}
55
56/// The repository named by `{repo}`, or 404.
57///
58/// The `.git` suffix is required rather than optional: it is what separates the protocol
59/// from the page at `/{handle}/repos/{name}`, and `RepoName` rejects a name ending in
60/// `.git` so the two can never collide.
61fn repo_param(cx: &Cx) -> Result<RepoName> {
62 let raw = path_param::<Repo>(cx);
63 let name = raw.strip_suffix(".git").ok_or_else(not_found)?;
64
65 Ok(RepoName::new(name).map_err(|_| not_found())?)
66}
67
68/// The four request headers that change what git does.
69fn client_headers(cx: &Cx) -> GitClientHeaders {
70 let headers = topcoat::router::headers(cx);
71 let value = |name: &str| {
72 headers
73 .get(name)
74 .and_then(|value| value.to_str().ok())
75 .map(str::to_owned)
76 };
77
78 GitClientHeaders {
79 content_type: value("content-type"),
80 content_encoding: value("content-encoding"),
81 content_length: value("content-length"),
82 git_protocol: value("git-protocol"),
83 }
84}
85
86/// Runs one protocol request and turns the result into an HTTP response.
87///
88/// A repository that does not exist and one the viewer may not see are the same 404,
89/// deliberately — see [`serve_git`].
90async fn serve(cx: &Cx, endpoint: GitEndpoint, body: ByteStream) -> Result<Response<GitBody>> {
91 let handle = handle_param(cx)?;
92 let name = repo_param(cx)?;
93 let actor = current_actor(cx).await?;
94
95 let served = serve_git(
96 &handle,
97 &name,
98 endpoint,
99 client_headers(cx),
100 body,
101 &actor,
102 &orgs(cx),
103 &memberships(cx),
104 &repos(cx),
105 &protocol(cx),
106 )
107 .await
108 .map_err(|error| match error {
109 // Push, until Milestone 4b. Everything else the visitor cannot act on.
110 Error::Domain(DomainError::Forbidden) => forbidden().into(),
111 other => server_error(other),
112 })?
113 .ok_or_not_found()?;
114
115 let mut response = Response::builder().status(served.status);
116 for (name, value) in served.headers {
117 response = response.header(name, value);
118 }
119
120 // git sets its own content type and cache headers, and they are forwarded rather
121 // than reinvented: a cached advertisement makes a client fetch a stale ref list and
122 // then fail to find commits that do exist.
123 response
124 .body(GitBody::new(served.body))
125 .map_err(server_error)
126}
127
128/// The ref advertisement that opens every exchange.
129///
130/// The service names the operation, so `service=git-receive-pack` is a write and is
131/// refused here, before a client has been told a single ref exists.
132#[route(GET "/{handle}/repos/{repo}/info/refs")]
133async fn info_refs(cx: &Cx) -> Result<Response<GitBody>> {
134 let query =
135 parse_query_params::<ServiceQuery>(cx).map_err(|error| bad_request(error.to_string()))?;
136
137 // No service means the dumb protocol, which Steid does not serve. Refusing beats
138 // guessing: the dumb protocol reads object files straight off disk.
139 let service = query.service.ok_or_else(not_found)?;
140 let service: GitService = service.parse().map_err(|_| not_found())?;
141
142 serve(
143 cx,
144 GitEndpoint::Advertisement(service),
145 Box::pin(tokio::io::empty()),
146 )
147 .await
148}
149
150#[route(POST "/{handle}/repos/{repo}/git-upload-pack")]
151async fn upload_pack(cx: &Cx, body: Body) -> Result<Response<GitBody>> {
152 serve(
153 cx,
154 GitEndpoint::Rpc(GitService::UploadPack),
155 into_reader(body),
156 )
157 .await
158}
159
160#[route(POST "/{handle}/repos/{repo}/git-receive-pack")]
161async fn receive_pack(cx: &Cx, body: Body) -> Result<Response<GitBody>> {
162 serve(
163 cx,
164 GitEndpoint::Rpc(GitService::ReceivePack),
165 into_reader(body),
166 )
167 .await
168}
169
170/// Adapts the request body into the byte stream the port takes.
171///
172/// Unbuffered on purpose: a push is arbitrarily large, and `Body` is taken as itself
173/// rather than as `Bytes` precisely so nothing collects it.
174fn into_reader(body: Body) -> ByteStream {
175 Box::pin(StreamReader::new(
176 body.into_data_stream().map_err(io::Error::other),
177 ))
178}
179
180/// The response body, streaming out of git.
181///
182/// Hand-written rather than assembled from stream combinators because the port speaks
183/// `AsyncRead` and `http_body` wants frames; this is the whole of the translation.
184pub struct GitBody {
185 reader: ByteStream,
186}
187
188impl GitBody {
189 fn new(reader: ByteStream) -> Self {
190 Self { reader }
191 }
192}
193
194impl http_body::Body for GitBody {
195 type Data = Bytes;
196 type Error = io::Error;
197
198 fn poll_frame(
199 mut self: Pin<&mut Self>,
200 cx: &mut Context<'_>,
201 ) -> Poll<Option<std::result::Result<Frame<Bytes>, io::Error>>> {
202 let mut buffer = [0u8; CHUNK];
203 let mut read = ReadBuf::new(&mut buffer);
204
205 match Pin::new(&mut self.reader).poll_read(cx, &mut read) {
206 Poll::Pending => Poll::Pending,
207 Poll::Ready(Err(error)) => Poll::Ready(Some(Err(error))),
208 Poll::Ready(Ok(())) => {
209 let filled = read.filled();
210
211 // An empty read is EOF: the pack is complete and the body ends.
212 if filled.is_empty() {
213 Poll::Ready(None)
214 } else {
215 Poll::Ready(Some(Ok(Frame::data(Bytes::copy_from_slice(filled)))))
216 }
217 }
218 }
219 }
220}