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.8 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 // Anonymous and not public: challenge rather than report absence.
58 //
59 // git only runs its credential helper when it receives a 401 with a
60 // challenge. Answering 404 here — which is what the public lookup
61 // returns for both "private" and "no such repository" — would leave
62 // `git clone` of a private repository failing as "not found" for a user
63 // whose credentials were configured and never asked for.
64 //
65 // Existence is still not disclosed, because the answer is 401 whether
66 // the repository is private or absent. The distinction only becomes
67 // visible once a credential proves the caller may see it.
68 if state.open_public_repo(&account, &repo).is_err() {
69 return Err(Error::Unauthorized);
70 }
71 }
72 // Unreachable: the match above only produces `None` when `!route.writes`.
73 // Stated rather than assumed, because the cost of being wrong is anonymous
74 // push.
75 (None, true) => return Err(Error::Unauthorized),
76 }
77
78 if state.meta.get_ready_repo(&account, &repo)?.is_none() {
79 return Err(Error::NotFound("repository"));
80 }
81
82 // receive-pack interrupted by ENOSPC leaves a partial pack, so the reserve is
83 // checked before the child is spawned, not after.
84 if route.writes {
85 state.ensure_space()?;
86 state.check_write_quota(&account, &repo)?;
87 }
88
89 let method = req.method().as_str().to_string();
90 let headers = req.headers().clone();
91 let content_type = header(&headers, "content-type");
92 let content_encoding = header(&headers, "content-encoding");
93 let git_protocol = header(&headers, "git-protocol");
94 let content_length = headers
95 .get("content-length")
96 .and_then(|v| v.to_str().ok())
97 .and_then(|v| v.parse::<u64>().ok());
98
99 let peer_addr = peer.ip().to_string();
100
101 // Stream the request body into the child rather than collecting it. git switches
102 // to chunked for anything over http.postBuffer (1 MiB by default), so the
103 // buffered path was the normal path for pushes.
104 let reader = StreamReader::new(
105 req.into_body()
106 .into_data_stream()
107 .map_err(|e| std::io::Error::other(e.to_string())),
108 );
109
110 let cgi = CgiRequest {
111 method: &method,
112 path_info: &route.path_info,
113 query: &route.query,
114 content_type: content_type.as_deref(),
115 content_encoding: content_encoding.as_deref(),
116 git_protocol: git_protocol.as_deref(),
117 remote_user: account.as_str(),
118 remote_addr: &peer_addr,
119 content_length,
120 hook_env: &state.config.hook_env(),
121 };
122
123 let produced = transport::spawn(
124 state.git.git_root(),
125 cgi,
126 reader,
127 state.config.max_pack_bytes,
128 Arc::clone(&state.git_slots),
129 )
130 .await?;
131
132 let response = build_response(produced, &route)?;
133 Ok((response, Some(account.as_str().to_string())))
134}
135
136/// Turn a running CGI into a streaming response.
137///
138/// The guard rides inside the body, so the child and its concurrency permit are
139/// released exactly when the client stops reading — including on disconnect.
140fn build_response(produced: CgiResponse, route: &GitRoute) -> Result<Response<Body>> {
141 let CgiResponse {
142 status,
143 headers,
144 prefix,
145 stdout,
146 guard,
147 } = produced;
148
149 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::OK);
150
151 let forwarded: Vec<(String, String)> = headers
152 .into_iter()
153 .filter(|(name, _)| !transport::is_suppressed(name))
154 .collect();
155
156 let tail = ReaderStream::new(stdout).map_ok(Frame::data);
157 let head = futures::stream::once(async move { Ok(Frame::data(prefix)) });
158 let joined = futures::StreamExt::chain(head, tail);
159
160 // Holding the guard in the stream's scope is what ties the child's lifetime to
161 // the response body rather than to this function.
162 let body = StreamBody::new(GuardedStream {
163 inner: Box::pin(joined),
164 _guard: guard,
165 })
166 .boxed();
167
168 // A stale ref advertisement makes a client negotiate against objects the server
169 // no longer has, so it must never be cached.
170 let extra: &[(&str, &str)] = if route.path_info.ends_with("/info/refs") {
171 transport::NO_CACHE
172 } else {
173 &[]
174 };
175
176 response::stream(status, forwarded, extra, body)
177 .map_err(|e| Error::Internal(anyhow::Error::from(e).context("build git response")))
178}
179
180/// A stream that keeps a value alive for as long as it is being polled.
181struct GuardedStream<S> {
182 inner: std::pin::Pin<Box<S>>,
183 _guard: transport::CgiGuard,
184}
185
186impl<S> futures::Stream for GuardedStream<S>
187where
188 S: futures::Stream<Item = std::result::Result<Frame<Bytes>, std::io::Error>>,
189{
190 type Item = std::result::Result<Frame<Bytes>, std::io::Error>;
191
192 fn poll_next(
193 mut self: std::pin::Pin<&mut Self>,
194 cx: &mut std::task::Context<'_>,
195 ) -> std::task::Poll<Option<Self::Item>> {
196 self.inner.as_mut().poll_next(cx)
197 }
198}
199
200fn header(headers: &hyper::HeaderMap, name: &str) -> Option<String> {
201 headers
202 .get(name)
203 .and_then(|v| v.to_str().ok())
204 .map(String::from)
205}