zuka
zuka/src/api/git_http.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/api/git_http.rs
RSgit_http.rs7.0 KBDownload
1// The git wire protocol endpoints.
2//
3// These are not REST and do not use problem+json: git prints the response body at
4// the user, and a 401 without `WWW-Authenticate` makes a clone fail rather than
5// prompt for credentials (SPEC §4.7).
6//
7// Nothing here buffers a pack. The request body is handed to the CGI as a reader
8// and the CGI's stdout becomes the response body, so memory is one chunk rather
9// than two copies of the pack.
10
11use crate::error::{Error, Result};
12use crate::git::transport::{self, CgiRequest, CgiResponse, GitRoute};
13use crate::git::validate;
14use crate::http::response::{self, Body};
15use crate::http::AppState;
16use bytes::Bytes;
17use futures::TryStreamExt;
18use http_body_util::{BodyExt, StreamBody};
19use hyper::body::{Frame, Incoming};
20use hyper::{Request, Response, StatusCode};
21use std::net::SocketAddr;
22use std::sync::Arc;
23use tokio_util::io::{ReaderStream, StreamReader};
24
25pub async fn handle(
26 req: Request<Incoming>,
27 state: Arc<AppState>,
28 route: GitRoute,
29 peer: SocketAddr,
30) -> Result<(Response<Body>, Option<String>)> {
31 // Validate before anything reaches GIT_PROJECT_ROOT + PATH_INFO. Relying on a
32 // later metadata lookup to reject traversal makes the control incidental.
33 let account = validate::name(&route.account)?;
34 let repo = validate::name(&route.repo)?;
35
36 // A public repository can be cloned with no credential — that is most of what
37 // makes it public. A push never can: `route.writes` demands an identity before
38 // anything else, so the anonymous path below cannot be reached by a write.
39 let offered = crate::http::auth::presented(req.headers());
40 let identity = match state.identify(req.headers(), req.method().as_str(), req.uri().path()) {
41 Ok(identity) => Some(identity),
42 // Only a missing credential falls through to the public rule. One that was
43 // presented and rejected stays rejected: silently downgrading a bad token to
44 // an anonymous clone turns "your token expired" into a success on public
45 // repositories and a bare 404 on private ones, which is the least
46 // debuggable failure a credential can have.
47 Err(Error::Unauthorized) if !route.writes && !offered => None,
48 Err(e) => return Err(e),
49 };
50
51 // Advertising refs for receive-pack is itself a write signal, so the same check
52 // covers the advertisement and the push.
53 match (&identity, route.writes) {
54 (Some(identity), true) => identity.require_write(&account, &repo)?,
55 (Some(identity), false) => identity.require_read(&account, &repo)?,
56 (None, false) => {
57 state.open_public_repo(&account, &repo)?;
58 }
59 // Unreachable: the match above only produces `None` when `!route.writes`.
60 // Stated rather than assumed, because the cost of being wrong is anonymous
61 // push.
62 (None, true) => return Err(Error::Unauthorized),
63 }
64
65 if state.meta.get_ready_repo(&account, &repo)?.is_none() {
66 return Err(Error::NotFound("repository"));
67 }
68
69 // receive-pack interrupted by ENOSPC leaves a partial pack, so the reserve is
70 // checked before the child is spawned, not after.
71 if route.writes {
72 state.ensure_space()?;
73 state.check_write_quota(&account, &repo)?;
74 }
75
76 let method = req.method().as_str().to_string();
77 let headers = req.headers().clone();
78 let content_type = header(&headers, "content-type");
79 let content_encoding = header(&headers, "content-encoding");
80 let git_protocol = header(&headers, "git-protocol");
81 let content_length = headers
82 .get("content-length")
83 .and_then(|v| v.to_str().ok())
84 .and_then(|v| v.parse::<u64>().ok());
85
86 let peer_addr = peer.ip().to_string();
87
88 // Stream the request body into the child rather than collecting it. git switches
89 // to chunked for anything over http.postBuffer (1 MiB by default), so the
90 // buffered path was the normal path for pushes.
91 let reader = StreamReader::new(
92 req.into_body()
93 .into_data_stream()
94 .map_err(|e| std::io::Error::other(e.to_string())),
95 );
96
97 let cgi = CgiRequest {
98 method: &method,
99 path_info: &route.path_info,
100 query: &route.query,
101 content_type: content_type.as_deref(),
102 content_encoding: content_encoding.as_deref(),
103 git_protocol: git_protocol.as_deref(),
104 remote_user: account.as_str(),
105 remote_addr: &peer_addr,
106 content_length,
107 hook_env: &state.config.hook_env(),
108 };
109
110 let produced = transport::spawn(
111 state.git.git_root(),
112 cgi,
113 reader,
114 state.config.max_pack_bytes,
115 Arc::clone(&state.git_slots),
116 )
117 .await?;
118
119 let response = build_response(produced, &route)?;
120 Ok((response, Some(account.as_str().to_string())))
121}
122
123/// Turn a running CGI into a streaming response.
124///
125/// The guard rides inside the body, so the child and its concurrency permit are
126/// released exactly when the client stops reading — including on disconnect.
127fn build_response(produced: CgiResponse, route: &GitRoute) -> Result<Response<Body>> {
128 let CgiResponse {
129 status,
130 headers,
131 prefix,
132 stdout,
133 guard,
134 } = produced;
135
136 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::OK);
137
138 let forwarded: Vec<(String, String)> = headers
139 .into_iter()
140 .filter(|(name, _)| !transport::is_suppressed(name))
141 .collect();
142
143 let tail = ReaderStream::new(stdout).map_ok(Frame::data);
144 let head = futures::stream::once(async move { Ok(Frame::data(prefix)) });
145 let joined = futures::StreamExt::chain(head, tail);
146
147 // Holding the guard in the stream's scope is what ties the child's lifetime to
148 // the response body rather than to this function.
149 let body = StreamBody::new(GuardedStream {
150 inner: Box::pin(joined),
151 _guard: guard,
152 })
153 .boxed();
154
155 // A stale ref advertisement makes a client negotiate against objects the server
156 // no longer has, so it must never be cached.
157 let extra: &[(&str, &str)] = if route.path_info.ends_with("/info/refs") {
158 transport::NO_CACHE
159 } else {
160 &[]
161 };
162
163 response::stream(status, forwarded, extra, body)
164 .map_err(|e| Error::Internal(anyhow::Error::from(e).context("build git response")))
165}
166
167/// A stream that keeps a value alive for as long as it is being polled.
168struct GuardedStream<S> {
169 inner: std::pin::Pin<Box<S>>,
170 _guard: transport::CgiGuard,
171}
172
173impl<S> futures::Stream for GuardedStream<S>
174where
175 S: futures::Stream<Item = std::result::Result<Frame<Bytes>, std::io::Error>>,
176{
177 type Item = std::result::Result<Frame<Bytes>, std::io::Error>;
178
179 fn poll_next(
180 mut self: std::pin::Pin<&mut Self>,
181 cx: &mut std::task::Context<'_>,
182 ) -> std::task::Poll<Option<Self::Item>> {
183 self.inner.as_mut().poll_next(cx)
184 }
185}
186
187fn header(headers: &hyper::HeaderMap, name: &str) -> Option<String> {
188 headers
189 .get(name)
190 .and_then(|v| v.to_str().ok())
191 .map(String::from)
192}