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

523 lines18,945 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
128/// `url` with its first path segment, the workspace, replaced by `slug`.
129pub fn with_namespace(url: &Url, slug: &str) -> Option<String> {
130 let rest = url.path().strip_prefix('/')?.split_once('/')?.1;
131 let mut moved = url.clone();
132 moved.set_path(&format!("/{slug}/{rest}"));
133 Some(moved.to_string())
134}
135
136/// Where a git request for a renamed workspace's old address should go
137/// now, if its first segment is an old slug that still redirects.
138pub async fn renamed(url: &Url, identity: &Fetcher) -> Result<Option<String>> {
139 let Some(old) = url.path().strip_prefix('/').and_then(|path| path.split('/').next()) else {
140 return Ok(None);
141 };
142 let current: Option<String> = g1t_kit::call(
143 identity,
144 "resolve_slug",
145 &g1t_contracts::identity::SlugArgs {
146 slug: old.to_owned(),
147 },
148 )
149 .await?;
150 Ok(current.and_then(|slug| with_namespace(url, &slug)))
151}
152
153/// A permanent redirect: 301 for git's first request for refs, which it
154/// follows and then uses the new address for the rest; 308 for the
155/// others, so a POST stays a POST.
156pub fn moved(location: &str, get: bool) -> Result<Response> {
157 let mut response = Response::empty()?.with_status(if get { 301 } else { 308 });
158 response.headers_mut().set("location", location)?;
159 Ok(response)
160}
161
162const ZERO_ID: &str = "0000000000000000000000000000000000000000";
163const HEADS: &str = "refs/heads/";
164const TAGS: &str = "refs/tags/";
165
166/// One ref a push asks to change.
167struct Command {
168 old: String,
169 new: String,
170 name: String,
171}
172
173/// The commands at the start of a receive-pack request, and the
174/// capabilities the client sent with the first of them.
175fn commands(body: &[u8]) -> (Vec<Command>, String) {
176 let mut commands = Vec::new();
177 let mut capabilities = String::new();
178 let mut position = 0;
179 // Commands are pkt-lines; a flush packet ends them and the pack follows.
180 while let Some(length) = body
181 .get(position..position + 4)
182 .and_then(|hex| std::str::from_utf8(hex).ok())
183 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
184 {
185 if length < 4 || position + length > body.len() {
186 break;
187 }
188 let line = &body[position + 4..position + length];
189 position += length;
190 // `<old> <new> <ref>`, and on the first command a NUL then capabilities.
191 let mut halves = line.splitn(2, |byte| *byte == 0);
192 let command = halves.next().unwrap_or_default();
193 if let Some(rest) = halves.next() {
194 capabilities = String::from_utf8_lossy(rest).trim().to_owned();
195 }
196 let Ok(command) = std::str::from_utf8(command) else {
197 continue;
198 };
199 let mut parts = command.trim_end().splitn(3, ' ');
200 if let (Some(old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) {
201 commands.push(Command {
202 old: old.to_owned(),
203 new: new.to_owned(),
204 name: name.to_owned(),
205 });
206 }
207 }
208 (commands, capabilities)
209}
210
211fn pkt_line(payload: &[u8]) -> Vec<u8> {
212 let mut line = format!("{:04x}", payload.len() + 4).into_bytes();
213 line.extend_from_slice(payload);
214 line
215}
216
217/// What git is told when a push would change a protected branch: every ref
218/// in it is declined, with the reason against the protected one, so that
219/// git prints it beside the branch. `None` if the push leaves the branch
220/// alone, or creates it in a repository that does not have it yet.
221fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> {
222 let (commands, capabilities) = commands(body);
223 let reference = format!("{HEADS}{protected}");
224 if !commands
225 .iter()
226 .any(|command| command.name == reference && command.old != ZERO_ID)
227 {
228 return None;
229 }
230 let mut report = pkt_line(b"unpack ok\n");
231 for command in &commands {
232 let reason = if command.name == reference {
233 format!("{protected} is protected: push a branch and open a pull request")
234 } else {
235 format!("not pushed, because the same push would change {protected}")
236 };
237 report.extend(pkt_line(
238 format!("ng {} {reason}\n", command.name).as_bytes(),
239 ));
240 }
241 report.extend_from_slice(b"0000");
242 Some(framed(report, &capabilities, &[]))
243}
244
245/// A report-status as git expects it: inside channel 1 when the client
246/// asked for side-band, after `messages` on channel 2, which git prints as
247/// `remote:` lines. Without side-band the messages cannot be shown.
248fn framed(report: Vec<u8>, capabilities: &str, messages: &[String]) -> Vec<u8> {
249 let sideband = capabilities
250 .split(' ')
251 .any(|capability| capability.starts_with("side-band"));
252 if !sideband {
253 return report;
254 }
255 let mut body = Vec::new();
256 for message in messages {
257 let mut packet = vec![2u8];
258 packet.extend_from_slice(message.as_bytes());
259 packet.push(b'\n');
260 body.extend(pkt_line(&packet));
261 }
262 // side-band (not -64k) packets carry at most 1000 bytes.
263 for chunk in report.chunks(990) {
264 let mut packet = vec![1u8];
265 packet.extend_from_slice(chunk);
266 body.extend(pkt_line(&packet));
267 }
268 body.extend_from_slice(b"0000");
269 body
270}
271
272/// Declines every ref in a push with `reason`, explaining why in
273/// `messages`: what push protection answers when a push adds a secret.
274pub fn declined(body: &[u8], reason: &str, messages: &[String]) -> Result<Response> {
275 let (commands, capabilities) = commands(body);
276 let mut report = pkt_line(b"unpack ok\n");
277 for command in &commands {
278 report.extend(pkt_line(format!("ng {} {reason}\n", command.name).as_bytes()));
279 }
280 report.extend_from_slice(b"0000");
281 let headers = Headers::new();
282 headers.set("content-type", "application/x-git-receive-pack-result")?;
283 headers.set("cache-control", "no-cache")?;
284 Ok(Response::from_bytes(framed(report, &capabilities, messages))?.with_headers(headers))
285}
286
287/// A branch or tag a push asks to move.
288#[derive(Debug, PartialEq, Eq)]
289pub struct Pushed {
290 /// The full ref: `refs/heads/main`, `refs/tags/v1`.
291 pub git_ref: String,
292 /// Where it pointed before; `None` for a new ref.
293 pub before: Option<String>,
294 pub after: String,
295}
296
297impl Pushed {
298 pub fn branch(&self) -> Option<&str> {
299 self.git_ref.strip_prefix(HEADS)
300 }
301}
302
303/// The branches and tags a push asks to move, read from the commands at the
304/// start of a receive-pack request. Deletions and other refs are left out.
305fn pushed_branches(body: &[u8]) -> Vec<Pushed> {
306 commands(body)
307 .0
308 .into_iter()
309 .filter(|command| command.new != ZERO_ID)
310 .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
311 .map(|Command { old, new, name }| Pushed {
312 git_ref: name,
313 before: (old != ZERO_ID).then_some(old),
314 after: new,
315 })
316 .collect()
317}
318
319/// The git store's answer, and what the request asked it to change.
320pub struct Forwarded {
321 pub response: Response,
322 /// For a push: the branches and tags it asks to move, and the commits
323 /// to move them to. Whether each moved is for the caller to confirm.
324 pub pushed: Vec<Pushed>,
325}
326
327/// What became of a git request.
328pub enum Push {
329 Forwarded(Forwarded),
330 /// A push to a protected branch, answered here without reaching the store.
331 Refused(Response),
332 /// A push that adds a secret nobody allowed, answered the same way.
333 Blocked(Response),
334}
335
336/// Sends the request on to the git store and returns its response as is,
337/// unless it is a push that would change the `protected` branch, or one
338/// that `scan` (push protection) answers itself.
339pub async fn forward(
340 mut request: Request,
341 git: &GitRequest,
342 access: &GitAccess,
343 protected: Option<&str>,
344 scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>,
345) -> Result<Push> {
346 let headers = Headers::new();
347 headers.set("authorization", &format!("Bearer {}", access.token))?;
348 for name in FORWARDED_HEADERS {
349 if let Some(value) = request.headers().get(name)? {
350 headers.set(name, &value)?;
351 }
352 }
353 let query = request
354 .url()?
355 .query()
356 .map(|query| format!("?{query}"))
357 .unwrap_or_default();
358 let mut init = RequestInit::new();
359 init.with_method(request.method()).with_headers(headers);
360 let mut pushed = Vec::new();
361 if request.method() == Method::Post {
362 // Pushes are capped at 100 MB by the platform, so buffering is safe.
363 let body = request.bytes().await?;
364 if git.endpoint == "git-receive-pack" {
365 if let Some(report) = protected.and_then(|branch| refusal(&body, branch)) {
366 let headers = Headers::new();
367 headers.set("content-type", "application/x-git-receive-pack-result")?;
368 headers.set("cache-control", "no-cache")?;
369 return Ok(Push::Refused(
370 Response::from_bytes(report)?.with_headers(headers),
371 ));
372 }
373 if let Some(response) = scan(&body).await? {
374 return Ok(Push::Blocked(response));
375 }
376 pushed = pushed_branches(&body);
377 }
378 init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
379 }
380 let upstream =
381 Request::new_with_init(&format!("{}/{}{query}", access.remote, git.endpoint), &init)?;
382 Ok(Push::Forwarded(Forwarded {
383 response: Fetch::Request(upstream).send().await?,
384 pushed,
385 }))
386}
387
388#[cfg(test)]
389mod tests {
390 use super::{Pushed, ZERO_ID, framed, pushed_branches, refusal, with_namespace};
391
392 #[test]
393 fn a_renamed_workspace_keeps_the_rest_of_the_address() {
394 let url = worker::Url::parse(
395 "https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack",
396 )
397 .unwrap();
398 assert_eq!(
399 with_namespace(&url, "acme-inc").as_deref(),
400 Some("https://g1t.sh/acme-inc/rocket.git/info/refs?service=git-upload-pack")
401 );
402 let bare = worker::Url::parse("https://g1t.sh/acme").unwrap();
403 assert_eq!(with_namespace(&bare, "acme-inc"), None);
404 }
405
406 fn pkt(payload: &str) -> Vec<u8> {
407 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
408 }
409
410 #[test]
411 fn pushed_branches_are_read_from_the_commands() {
412 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
413 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
414 let body = [
415 pkt(&format!(
416 "{old} {new} refs/heads/main\0 report-status side-band-64k\n"
417 )),
418 pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
419 pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
420 pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
421 b"0000".to_vec(),
422 b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
423 ]
424 .concat();
425 assert_eq!(
426 pushed_branches(&body),
427 [
428 Pushed {
429 git_ref: "refs/heads/main".to_owned(),
430 before: Some(old.to_owned()),
431 after: new.to_owned()
432 },
433 Pushed {
434 git_ref: "refs/heads/feature/x".to_owned(),
435 before: None,
436 after: new.to_owned()
437 },
438 Pushed {
439 git_ref: "refs/tags/v1".to_owned(),
440 before: None,
441 after: new.to_owned()
442 },
443 ]
444 );
445 }
446
447 #[test]
448 fn a_push_to_a_protected_branch_is_declined_with_the_reason() {
449 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
450 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
451 let body = [
452 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
453 pkt(&format!("{ZERO_ID} {new} refs/heads/feature\n")),
454 b"0000".to_vec(),
455 ]
456 .concat();
457 let report = String::from_utf8(refusal(&body, "main").unwrap()).unwrap();
458 assert!(report.starts_with("000eunpack ok\n"));
459 assert!(report.contains("ng refs/heads/main main is protected"));
460 assert!(report.contains("ng refs/heads/feature not pushed"));
461 assert!(report.ends_with("0000"));
462 }
463
464 #[test]
465 fn the_report_is_framed_for_a_client_that_asked_for_side_band() {
466 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
467 let body = [
468 pkt(&format!(
469 "{old} {ZERO_ID} refs/heads/main\0 report-status side-band-64k\n"
470 )),
471 b"0000".to_vec(),
472 ]
473 .concat();
474 let report = refusal(&body, "main").unwrap();
475 // A length, then channel 1, then the report itself.
476 assert_eq!(report[4], 1);
477 assert_eq!(&report[5..18], b"000eunpack ok");
478 assert!(report.ends_with(b"00000000"));
479 }
480
481 #[test]
482 fn other_branches_and_a_first_push_are_let_through() {
483 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
484 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
485 let feature = [
486 pkt(&format!("{old} {new} refs/heads/feature\0 report-status\n")),
487 b"0000".to_vec(),
488 ]
489 .concat();
490 assert!(refusal(&feature, "main").is_none());
491 // An empty repository has to be able to receive its first commits.
492 let first = [
493 pkt(&format!(
494 "{ZERO_ID} {new} refs/heads/main\0 report-status\n"
495 )),
496 b"0000".to_vec(),
497 ]
498 .concat();
499 assert!(refusal(&first, "main").is_none());
500 }
501
502 #[test]
503 fn a_blocked_push_explains_itself_on_the_progress_channel() {
504 let report = b"000eunpack ok\n0000".to_vec();
505 let messages = vec!["g1t found a secret in this push, so nothing was pushed.".to_owned()];
506 let body = framed(report.clone(), "report-status side-band-64k", &messages);
507 // Channel 2 first, which git prints as `remote:` lines.
508 assert_eq!(body[4], 2);
509 assert!(String::from_utf8_lossy(&body).contains("so nothing was pushed.\n"));
510 let at = body.windows(5).position(|w| w == b"000eu").unwrap();
511 assert_eq!(body[at - 1], 1);
512 assert!(body.ends_with(b"0000"));
513 // A client without side-band gets the bare report.
514 assert_eq!(framed(report.clone(), "report-status", &messages), report);
515 }
516
517 #[test]
518 fn a_fetch_request_names_no_branches() {
519 assert!(
520 pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
521 );
522 }
523}