flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/repos/src/git_http.rs

392 lines13,644 bytesCodeBlame
1//! Git over HTTPS: the smart HTTP remote at `/<namespace>/<repo>.git`,
2//! proxied to the git store with a short-lived token.
3
4use g1t_contracts::identity::GitCredentialsArgs;
5use g1t_contracts::repos::{GitAccess, GitService, RepoPath};
6use g1t_contracts::{FailureCode, Outcome, Viewer};
7use worker::js_sys::Uint8Array;
8use worker::{Fetch, Fetcher, Headers, Method, Request, RequestInit, Response, Result, Url};
9
10const ENDPOINTS: [&str; 3] = ["info/refs", "git-upload-pack", "git-receive-pack"];
11const FORWARDED_HEADERS: [&str; 5] = [
12 "accept",
13 "content-encoding",
14 "content-type",
15 "git-protocol",
16 "user-agent",
17];
18
19/// A git request, parsed from its URL.
20pub struct GitRequest {
21 pub path: RepoPath,
22 pub endpoint: &'static str,
23 pub service: GitService,
24}
25
26/// Parses `/<namespace>/<name>[.git]/<endpoint>`, or returns `None` if the
27/// request is not git's.
28pub fn parse(url: &Url) -> Option<GitRequest> {
29 let path = url.path().strip_prefix('/')?;
30 let endpoint = ENDPOINTS
31 .into_iter()
32 .find(|endpoint| path.ends_with(&format!("/{endpoint}")))?;
33 let repo = &path[..path.len() - endpoint.len() - 1];
34 let (namespace, name) = repo.split_once('/')?;
35 let name = name.strip_suffix(".git").unwrap_or(name);
36 if namespace.is_empty() || name.is_empty() || name.contains('/') {
37 return None;
38 }
39 let service = if endpoint == "info/refs" {
40 url.query_pairs()
41 .find(|(key, _)| key == "service")
42 .map(|(_, value)| value.into_owned())?
43 } else {
44 endpoint.to_owned()
45 };
46 let service = match service.as_str() {
47 "git-upload-pack" => GitService::UploadPack,
48 "git-receive-pack" => GitService::ReceivePack,
49 _ => return None,
50 };
51 Some(GitRequest {
52 path: RepoPath {
53 namespace: namespace.to_owned(),
54 name: name.to_owned(),
55 },
56 endpoint,
57 service,
58 })
59}
60
61/// The user named by an HTTP Basic `Authorization` header, as git sends it.
62pub async fn viewer(request: &Request, identity: &Fetcher) -> Result<Viewer> {
63 let Some(header) = request.headers().get("authorization")? else {
64 return Ok(None);
65 };
66 let Some((scheme, encoded)) = header.split_once(' ') else {
67 return Ok(None);
68 };
69 if !scheme.eq_ignore_ascii_case("basic") {
70 return Ok(None);
71 }
72 let Some(decoded) = decode_base64(encoded.trim()) else {
73 return Ok(None);
74 };
75 let Some((username, secret)) = decoded.split_once(':') else {
76 return Ok(None);
77 };
78 g1t_kit::call(
79 identity,
80 "user_for_git_credentials",
81 &GitCredentialsArgs {
82 username: username.to_owned(),
83 secret: secret.to_owned(),
84 },
85 )
86 .await
87}
88
89/// Standard base64 to a UTF-8 string, or `None` if either step fails.
90fn decode_base64(input: &str) -> Option<String> {
91 let mut bytes = Vec::with_capacity(input.len() * 3 / 4);
92 let mut buffer = 0u32;
93 let mut bits = 0;
94 for byte in input.bytes().filter(|byte| *byte != b'=') {
95 let value = match byte {
96 b'A'..=b'Z' => byte - b'A',
97 b'a'..=b'z' => byte - b'a' + 26,
98 b'0'..=b'9' => byte - b'0' + 52,
99 b'+' => 62,
100 b'/' => 63,
101 _ => return None,
102 };
103 buffer = (buffer << 6) | u32::from(value);
104 bits += 6;
105 if bits >= 8 {
106 bits -= 8;
107 bytes.push((buffer >> bits) as u8);
108 }
109 }
110 String::from_utf8(bytes).ok()
111}
112
113/// The response for a refused git request. Anonymous callers are asked to
114/// authenticate, which is what makes git prompt for credentials.
115pub fn refuse<T>(outcome: Outcome<T>) -> Result<Response> {
116 let Outcome::Fail(failure) = outcome else {
117 return Response::error("Not found", 404);
118 };
119 let mut response = Response::error(failure.message, failure.code.http_status())?;
120 if failure.code == FailureCode::Unauthenticated {
121 response
122 .headers_mut()
123 .set("www-authenticate", "Basic realm=\"g1t\"")?;
124 }
125 Ok(response)
126}
127
128const ZERO_ID: &str = "0000000000000000000000000000000000000000";
129const HEADS: &str = "refs/heads/";
130
131/// One ref a push asks to change.
132struct Command {
133 old: String,
134 new: String,
135 name: String,
136}
137
138/// The commands at the start of a receive-pack request, and the
139/// capabilities the client sent with the first of them.
140fn commands(body: &[u8]) -> (Vec<Command>, String) {
141 let mut commands = Vec::new();
142 let mut capabilities = String::new();
143 let mut position = 0;
144 // Commands are pkt-lines; a flush packet ends them and the pack follows.
145 while let Some(length) = body
146 .get(position..position + 4)
147 .and_then(|hex| std::str::from_utf8(hex).ok())
148 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
149 {
150 if length < 4 || position + length > body.len() {
151 break;
152 }
153 let line = &body[position + 4..position + length];
154 position += length;
155 // `<old> <new> <ref>`, and on the first command a NUL then capabilities.
156 let mut halves = line.splitn(2, |byte| *byte == 0);
157 let command = halves.next().unwrap_or_default();
158 if let Some(rest) = halves.next() {
159 capabilities = String::from_utf8_lossy(rest).trim().to_owned();
160 }
161 let Ok(command) = std::str::from_utf8(command) else {
162 continue;
163 };
164 let mut parts = command.trim_end().splitn(3, ' ');
165 if let (Some(old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) {
166 commands.push(Command {
167 old: old.to_owned(),
168 new: new.to_owned(),
169 name: name.to_owned(),
170 });
171 }
172 }
173 (commands, capabilities)
174}
175
176fn pkt_line(payload: &[u8]) -> Vec<u8> {
177 let mut line = format!("{:04x}", payload.len() + 4).into_bytes();
178 line.extend_from_slice(payload);
179 line
180}
181
182/// What git is told when a push would change a protected branch: every ref
183/// in it is declined, with the reason against the protected one, so that
184/// git prints it beside the branch. `None` if the push leaves the branch
185/// alone, or creates it in a repository that does not have it yet.
186fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> {
187 let (commands, capabilities) = commands(body);
188 let reference = format!("{HEADS}{protected}");
189 if !commands
190 .iter()
191 .any(|command| command.name == reference && command.old != ZERO_ID)
192 {
193 return None;
194 }
195 let mut report = pkt_line(b"unpack ok\n");
196 for command in &commands {
197 let reason = if command.name == reference {
198 format!("{protected} is protected: push a branch and open a pull request")
199 } else {
200 format!("not pushed, because the same push would change {protected}")
201 };
202 report.extend(pkt_line(
203 format!("ng {} {reason}\n", command.name).as_bytes(),
204 ));
205 }
206 report.extend_from_slice(b"0000");
207 // With side-band the report travels inside channel 1.
208 let sideband = capabilities
209 .split(' ')
210 .any(|capability| capability.starts_with("side-band"));
211 Some(if sideband {
212 let mut framed = vec![1u8];
213 framed.extend(report);
214 let mut body = pkt_line(&framed);
215 body.extend_from_slice(b"0000");
216 body
217 } else {
218 report
219 })
220}
221
222/// The branches a push asks to move, as `(branch, new commit)`, read from
223/// the commands at the start of a receive-pack request. Deletions and refs
224/// that are not branches are left out.
225fn pushed_branches(body: &[u8]) -> Vec<(String, String)> {
226 commands(body)
227 .0
228 .into_iter()
229 .filter(|command| command.new != ZERO_ID)
230 .filter_map(|Command { new, name, .. }| {
231 name.strip_prefix(HEADS)
232 .map(|branch| (branch.to_owned(), new))
233 })
234 .collect()
235}
236
237/// The git store's answer, and what the request asked it to change.
238pub struct Forwarded {
239 pub response: Response,
240 /// For a push: the branches it asks to move and the commits to move
241 /// them to. Whether each moved is for the caller to confirm.
242 pub pushed: Vec<(String, String)>,
243}
244
245/// What became of a git request.
246pub enum Push {
247 Forwarded(Forwarded),
248 /// A push to a protected branch, answered here without reaching the store.
249 Refused(Response),
250}
251
252/// Sends the request on to the git store and returns its response as is,
253/// unless it is a push that would change the `protected` branch.
254pub async fn forward(
255 mut request: Request,
256 git: &GitRequest,
257 access: &GitAccess,
258 protected: Option<&str>,
259) -> Result<Push> {
260 let headers = Headers::new();
261 headers.set("authorization", &format!("Bearer {}", access.token))?;
262 for name in FORWARDED_HEADERS {
263 if let Some(value) = request.headers().get(name)? {
264 headers.set(name, &value)?;
265 }
266 }
267 let query = request
268 .url()?
269 .query()
270 .map(|query| format!("?{query}"))
271 .unwrap_or_default();
272 let mut init = RequestInit::new();
273 init.with_method(request.method()).with_headers(headers);
274 let mut pushed = Vec::new();
275 if request.method() == Method::Post {
276 // Pushes are capped at 100 MB by the platform, so buffering is safe.
277 let body = request.bytes().await?;
278 if git.endpoint == "git-receive-pack" {
279 if let Some(report) = protected.and_then(|branch| refusal(&body, branch)) {
280 let headers = Headers::new();
281 headers.set("content-type", "application/x-git-receive-pack-result")?;
282 headers.set("cache-control", "no-cache")?;
283 return Ok(Push::Refused(
284 Response::from_bytes(report)?.with_headers(headers),
285 ));
286 }
287 pushed = pushed_branches(&body);
288 }
289 init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
290 }
291 let upstream =
292 Request::new_with_init(&format!("{}/{}{query}", access.remote, git.endpoint), &init)?;
293 Ok(Push::Forwarded(Forwarded {
294 response: Fetch::Request(upstream).send().await?,
295 pushed,
296 }))
297}
298
299#[cfg(test)]
300mod tests {
301 use super::{ZERO_ID, pushed_branches, refusal};
302
303 fn pkt(payload: &str) -> Vec<u8> {
304 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
305 }
306
307 #[test]
308 fn pushed_branches_are_read_from_the_commands() {
309 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
310 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
311 let body = [
312 pkt(&format!(
313 "{old} {new} refs/heads/main\0 report-status side-band-64k\n"
314 )),
315 pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
316 pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
317 pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
318 b"0000".to_vec(),
319 b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
320 ]
321 .concat();
322 assert_eq!(
323 pushed_branches(&body),
324 [
325 ("main".to_owned(), new.to_owned()),
326 ("feature/x".to_owned(), new.to_owned()),
327 ]
328 );
329 }
330
331 #[test]
332 fn a_push_to_a_protected_branch_is_declined_with_the_reason() {
333 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
334 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
335 let body = [
336 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
337 pkt(&format!("{ZERO_ID} {new} refs/heads/feature\n")),
338 b"0000".to_vec(),
339 ]
340 .concat();
341 let report = String::from_utf8(refusal(&body, "main").unwrap()).unwrap();
342 assert!(report.starts_with("000eunpack ok\n"));
343 assert!(report.contains("ng refs/heads/main main is protected"));
344 assert!(report.contains("ng refs/heads/feature not pushed"));
345 assert!(report.ends_with("0000"));
346 }
347
348 #[test]
349 fn the_report_is_framed_for_a_client_that_asked_for_side_band() {
350 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
351 let body = [
352 pkt(&format!(
353 "{old} {ZERO_ID} refs/heads/main\0 report-status side-band-64k\n"
354 )),
355 b"0000".to_vec(),
356 ]
357 .concat();
358 let report = refusal(&body, "main").unwrap();
359 // A length, then channel 1, then the report itself.
360 assert_eq!(report[4], 1);
361 assert_eq!(&report[5..18], b"000eunpack ok");
362 assert!(report.ends_with(b"00000000"));
363 }
364
365 #[test]
366 fn other_branches_and_a_first_push_are_let_through() {
367 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
368 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
369 let feature = [
370 pkt(&format!("{old} {new} refs/heads/feature\0 report-status\n")),
371 b"0000".to_vec(),
372 ]
373 .concat();
374 assert!(refusal(&feature, "main").is_none());
375 // An empty repository has to be able to receive its first commits.
376 let first = [
377 pkt(&format!(
378 "{ZERO_ID} {new} refs/heads/main\0 report-status\n"
379 )),
380 b"0000".to_vec(),
381 ]
382 .concat();
383 assert!(refusal(&first, "main").is_none());
384 }
385
386 #[test]
387 fn a_fetch_request_names_no_branches() {
388 assert!(
389 pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
390 );
391 }
392}