@jpgilldev / steid

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 base64::{Engine, engine::general_purpose::STANDARD};
16use bytes::Bytes;
17use futures_util::TryStreamExt;
18use http_body::Frame;
19use serde::Deserialize;
20use tokio::io::{AsyncRead, ReadBuf};
21use tokio_util::io::StreamReader;
22use topcoat::{
23 Result,
24 context::Cx,
25 router::{
26 Body, Response, StatusCode,
27 error::{bad_request, forbidden, not_found},
28 header::{AUTHORIZATION, WWW_AUTHENTICATE},
29 headers, parse_query_params, path_param, route,
30 },
31};
32
33use crate::{
34 application::{
35 Error, GitClientHeaders, GitEndpoint, GitService, authenticate_token, port::ByteStream,
36 serve_git,
37 },
38 domain::{Actor, DomainError, RepoName},
39};
40
41use super::{
42 context::{current_actor, memberships, orgs, protocol, repos, server_error, tokens},
43 profile::handle_param,
44};
45
46/// How much of the response is read from git in one go.
47///
48/// A clone streams as fast as the client takes it, so this bounds the memory a transfer
49/// holds rather than its speed.
50const CHUNK: usize = 16 * 1024;
51
52/// `{repo}` from the path — the repository name *with* its `.git` suffix.
53#[path_param]
54struct Repo(str);
55
56#[derive(Debug, Deserialize)]
57struct ServiceQuery {
58 service: Option<String>,
59}
60
61/// The repository named by `{repo}`, or 404.
62///
63/// The `.git` suffix is required rather than optional: it is what separates the protocol
64/// from the page at `/{handle}/repos/{name}`, and `RepoName` rejects a name ending in
65/// `.git` so the two can never collide.
66fn repo_param(cx: &Cx) -> Result<RepoName> {
67 let raw = path_param::<Repo>(cx);
68 let name = raw.strip_suffix(".git").ok_or_else(not_found)?;
69
70 Ok(RepoName::new(name).map_err(|_| not_found())?)
71}
72
73/// The four request headers that change what git does.
74fn client_headers(cx: &Cx) -> GitClientHeaders {
75 let headers = headers(cx);
76 let value = |name: &str| {
77 headers
78 .get(name)
79 .and_then(|value| value.to_str().ok())
80 .map(str::to_owned)
81 };
82
83 GitClientHeaders {
84 content_type: value("content-type"),
85 content_encoding: value("content-encoding"),
86 content_length: value("content-length"),
87 git_protocol: value("git-protocol"),
88 }
89}
90
91/// Who is making this git request.
92///
93/// A personal access token over HTTP Basic first, then the session cookie. Both are
94/// supported because both happen: git presents a token, and a signed-in person clicking
95/// a `.git` URL in a browser presents a cookie.
96///
97/// A credential that does not authenticate falls through to anonymous rather than
98/// failing, matching how a bad session cookie is treated. The caller then gets the same
99/// 401 challenge as someone who presented nothing, and can try again.
100async fn git_actor(cx: &Cx) -> Result<Actor> {
101 if let Some(presented) = basic_credential(cx) {
102 let actor = authenticate_token(&presented, &tokens(cx))
103 .await
104 .map_err(server_error)?;
105
106 if actor.user_id().is_some() {
107 return Ok(actor);
108 }
109 }
110
111 current_actor(cx).await
112}
113
114/// The secret from an `Authorization: Basic` header.
115///
116/// Git puts the token in the password field, so that is preferred; a token pasted into
117/// the username field with no password is accepted too, because people do that and the
118/// alternative is an authentication failure nothing explains.
119fn basic_credential(cx: &Cx) -> Option<String> {
120 let header = headers(cx).get(AUTHORIZATION)?.to_str().ok()?;
121 let encoded = header.strip_prefix("Basic ")?;
122 let decoded = STANDARD.decode(encoded).ok()?;
123 let decoded = String::from_utf8(decoded).ok()?;
124
125 let (user, password) = decoded.split_once(':')?;
126
127 if password.is_empty() {
128 Some(user.to_owned())
129 } else {
130 Some(password.to_owned())
131 }
132}
133
134/// Asks for credentials.
135///
136/// Sent for anything an anonymous caller may not have — including repositories that do
137/// not exist — so that nothing in the response distinguishes "private" from "absent".
138/// A git client only offers a credential after seeing this, so answering 404 instead
139/// would make an authenticated private clone impossible. See
140/// [0007](../../plans/decisions/0007-tokens-over-http-basic.md).
141fn challenge() -> Result<Response<GitBody>> {
142 Response::builder()
143 .status(StatusCode::UNAUTHORIZED)
144 .header(WWW_AUTHENTICATE, r#"Basic realm="steid""#)
145 .body(GitBody::new(Box::pin(tokio::io::empty())))
146 .map_err(server_error)
147}
148
149/// Runs one protocol request and turns the result into an HTTP response.
150async fn serve(cx: &Cx, endpoint: GitEndpoint, body: ByteStream) -> Result<Response<GitBody>> {
151 let handle = handle_param(cx)?;
152 let name = repo_param(cx)?;
153 let actor = git_actor(cx).await?;
154 let anonymous = actor.user_id().is_none();
155
156 let served = serve_git(
157 &handle,
158 &name,
159 endpoint,
160 client_headers(cx),
161 body,
162 &actor,
163 &orgs(cx),
164 &memberships(cx),
165 &repos(cx),
166 &protocol(cx),
167 )
168 .await;
169
170 let served = match served {
171 Ok(Some(served)) => served,
172
173 // Absent, or invisible to this caller — the two are the same answer by design.
174 // Anonymous callers are asked for credentials instead, so that a private
175 // repository and a missing one are indistinguishable from outside.
176 Ok(None) if anonymous => return challenge(),
177 Ok(None) => return Err(not_found().into()),
178
179 // Refused a write. Someone signed in is told so; someone anonymous is asked to
180 // identify themselves first, because they may well be allowed once they do.
181 Err(Error::Domain(DomainError::Forbidden)) if anonymous => return challenge(),
182 Err(Error::Domain(DomainError::Forbidden)) => return Err(forbidden().into()),
183
184 Err(other) => return Err(server_error(other)),
185 };
186
187 let mut response = Response::builder().status(served.status);
188 for (name, value) in served.headers {
189 response = response.header(name, value);
190 }
191
192 // git sets its own content type and cache headers, and they are forwarded rather
193 // than reinvented: a cached advertisement makes a client fetch a stale ref list and
194 // then fail to find commits that do exist.
195 response
196 .body(GitBody::new(served.body))
197 .map_err(server_error)
198}
199
200/// The ref advertisement that opens every exchange.
201///
202/// The service names the operation, so `service=git-receive-pack` is a write, and is
203/// authorized as one before a client has been told a single ref exists.
204#[route(GET "/{handle}/repos/{repo}/info/refs")]
205async fn info_refs(cx: &Cx) -> Result<Response<GitBody>> {
206 let query =
207 parse_query_params::<ServiceQuery>(cx).map_err(|error| bad_request(error.to_string()))?;
208
209 // No service means the dumb protocol, which Steid does not serve. Refusing beats
210 // guessing: the dumb protocol reads object files straight off disk.
211 let service = query.service.ok_or_else(not_found)?;
212 let service: GitService = service.parse().map_err(|_| not_found())?;
213
214 serve(
215 cx,
216 GitEndpoint::Advertisement(service),
217 Box::pin(tokio::io::empty()),
218 )
219 .await
220}
221
222#[route(POST "/{handle}/repos/{repo}/git-upload-pack")]
223async fn upload_pack(cx: &Cx, body: Body) -> Result<Response<GitBody>> {
224 serve(
225 cx,
226 GitEndpoint::Rpc(GitService::UploadPack),
227 into_reader(body),
228 )
229 .await
230}
231
232#[route(POST "/{handle}/repos/{repo}/git-receive-pack")]
233async fn receive_pack(cx: &Cx, body: Body) -> Result<Response<GitBody>> {
234 serve(
235 cx,
236 GitEndpoint::Rpc(GitService::ReceivePack),
237 into_reader(body),
238 )
239 .await
240}
241
242/// Adapts the request body into the byte stream the port takes.
243///
244/// Unbuffered on purpose: a push is arbitrarily large, and `Body` is taken as itself
245/// rather than as `Bytes` precisely so nothing collects it.
246fn into_reader(body: Body) -> ByteStream {
247 Box::pin(StreamReader::new(
248 body.into_data_stream().map_err(io::Error::other),
249 ))
250}
251
252/// The response body, streaming out of git.
253///
254/// Hand-written rather than assembled from stream combinators because the port speaks
255/// `AsyncRead` and `http_body` wants frames; this is the whole of the translation.
256pub struct GitBody {
257 reader: ByteStream,
258}
259
260impl GitBody {
261 fn new(reader: ByteStream) -> Self {
262 Self { reader }
263 }
264}
265
266impl http_body::Body for GitBody {
267 type Data = Bytes;
268 type Error = io::Error;
269
270 fn poll_frame(
271 mut self: Pin<&mut Self>,
272 cx: &mut Context<'_>,
273 ) -> Poll<Option<std::result::Result<Frame<Bytes>, io::Error>>> {
274 let mut buffer = [0u8; CHUNK];
275 let mut read = ReadBuf::new(&mut buffer);
276
277 match Pin::new(&mut self.reader).poll_read(cx, &mut read) {
278 Poll::Pending => Poll::Pending,
279 Poll::Ready(Err(error)) => Poll::Ready(Some(Err(error))),
280 Poll::Ready(Ok(())) => {
281 let filled = read.filled();
282
283 // An empty read is EOF: the pack is complete and the body ends.
284 if filled.is_empty() {
285 Poll::Ready(None)
286 } else {
287 Poll::Ready(Some(Ok(Frame::data(Bytes::copy_from_slice(filled)))))
288 }
289 }
290 }
291 }
292}