g1t/services/repos/src/git_http.rs

249 lines8,411 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Rust repos service with shipping; pull requests kept in the model1//! 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
Events service in Rust, with RFC 3339 times and accurate push events128const ZERO_ID: &str = "0000000000000000000000000000000000000000";
129const HEADS: &str = "refs/heads/";
130
131/// The branches a push asks to move, as `(branch, new commit)`, read from
132/// the commands at the start of a receive-pack request. Deletions and refs
133/// that are not branches are left out.
134fn pushed_branches(body: &[u8]) -> Vec<(String, String)> {
135 let mut branches = Vec::new();
136 let mut position = 0;
137 // Commands are pkt-lines; a flush packet ends them and the pack follows.
138 while let Some(length) = body
139 .get(position..position + 4)
140 .and_then(|hex| std::str::from_utf8(hex).ok())
141 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
142 {
143 if length < 4 || position + length > body.len() {
144 break;
145 }
146 let line = &body[position + 4..position + length];
147 position += length;
148 // `<old> <new> <ref>`, and on the first command a NUL then capabilities.
149 let line = line.split(|byte| *byte == 0).next().unwrap_or_default();
150 let Ok(line) = std::str::from_utf8(line) else {
151 continue;
152 };
153 let mut parts = line.trim_end().splitn(3, ' ');
154 let (Some(_old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) else {
155 continue;
156 };
157 if let Some(branch) = name.strip_prefix(HEADS)
158 && new != ZERO_ID
159 {
160 branches.push((branch.to_owned(), new.to_owned()));
161 }
162 }
163 branches
164}
165
166/// The git store's answer, and what the request asked it to change.
167pub struct Forwarded {
168 pub response: Response,
169 /// For a push: the branches it asks to move and the commits to move
170 /// them to. Whether each moved is for the caller to confirm.
171 pub pushed: Vec<(String, String)>,
172}
173
Rust repos service with shipping; pull requests kept in the model174/// Sends the request on to the git store and returns its response as is.
175pub async fn forward(
176 mut request: Request,
177 git: &GitRequest,
178 access: &GitAccess,
Events service in Rust, with RFC 3339 times and accurate push events179) -> Result<Forwarded> {
Rust repos service with shipping; pull requests kept in the model180 let headers = Headers::new();
181 headers.set("authorization", &format!("Bearer {}", access.token))?;
182 for name in FORWARDED_HEADERS {
183 if let Some(value) = request.headers().get(name)? {
184 headers.set(name, &value)?;
185 }
186 }
187 let query = request
188 .url()?
189 .query()
190 .map(|query| format!("?{query}"))
191 .unwrap_or_default();
192 let mut init = RequestInit::new();
193 init.with_method(request.method()).with_headers(headers);
Events service in Rust, with RFC 3339 times and accurate push events194 let mut pushed = Vec::new();
Rust repos service with shipping; pull requests kept in the model195 if request.method() == Method::Post {
196 // Pushes are capped at 100 MB by the platform, so buffering is safe.
197 let body = request.bytes().await?;
Events service in Rust, with RFC 3339 times and accurate push events198 if git.endpoint == "git-receive-pack" {
199 pushed = pushed_branches(&body);
200 }
Rust repos service with shipping; pull requests kept in the model201 init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
202 }
203 let upstream =
204 Request::new_with_init(&format!("{}/{}{query}", access.remote, git.endpoint), &init)?;
Events service in Rust, with RFC 3339 times and accurate push events205 Ok(Forwarded {
206 response: Fetch::Request(upstream).send().await?,
207 pushed,
208 })
209}
210
211#[cfg(test)]
212mod tests {
213 use super::{ZERO_ID, pushed_branches};
214
215 fn pkt(payload: &str) -> Vec<u8> {
216 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
217 }
218
219 #[test]
220 fn pushed_branches_are_read_from_the_commands() {
221 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
222 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
223 let body = [
224 pkt(&format!(
225 "{old} {new} refs/heads/main\0 report-status side-band-64k\n"
226 )),
227 pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
228 pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
229 pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
230 b"0000".to_vec(),
231 b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
232 ]
233 .concat();
234 assert_eq!(
235 pushed_branches(&body),
236 [
237 ("main".to_owned(), new.to_owned()),
238 ("feature/x".to_owned(), new.to_owned()),
239 ]
240 );
241 }
242
243 #[test]
244 fn a_fetch_request_names_no_branches() {
245 assert!(
246 pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
247 );
248 }
Rust repos service with shipping; pull requests kept in the model249}