Skip to content

Commit

The receive-pack advertisement asks git for packs without outside bases (no-thin), for empty repositories too, so a push carries every delta's base and its checks read nothing from the store; a push that still arrives thin says so in Server-Timing (thin;desc=yes) and in the logs with the client's user agent.

syntaqxcommitted Parent2ffbb7cBrowse files
1 file+151−10/1 viewed
+151−1
745745 changed.then(|| encode(&packets))
746746 }
747747
748+/// The capability that asks git to send a push's pack whole: every delta's
749+/// base inside it, none left for the receiving end to find (a "thin" pack).
750+const NO_THIN: &[u8] = b"no-thin";
751+
752+/// The receive-pack ref advertisement (`info/refs?service=git-receive-pack`,
753+/// protocol v0 or v1) with `no-thin` among its capabilities, so that git
754+/// sends a pack whose deltas have their bases in it (git's `send-pack`
755+/// turns thin packs off when the server says `no-thin`). Push protection
756+/// and the rules then read every object from the pack, and none from the
757+/// store (secret_scan.rs `supply_bases`). The capabilities follow the NUL
758+/// on the first ref line, or on the `capabilities^{}` line of an empty
759+/// repository. `None` when there is nothing to change or it cannot be
760+/// changed safely: `no-thin` is there already, or the answer is not a whole
761+/// pkt-line advertisement in that shape. Never for upload-pack.
762+pub fn with_no_thin(body: &[u8]) -> Option<Vec<u8>> {
763+ let mut packets = packets(body)?;
764+ // Past the `# service=` line and its flush, and v1's `version 1`: the
765+ // first ref line, which carries the capabilities.
766+ let line = packets.iter_mut().find_map(|packet| match packet {
767+ Packet::Data(data) if !data.starts_with(b"# service=") && !data.starts_with(b"version ") => Some(data),
768+ _ => None,
769+ })?;
770+ let nul = line.iter().position(|byte| *byte == 0)?;
771+ // `<oid> <ref>` before the NUL: 40 (SHA-1) or 64 (SHA-256) hex digits.
772+ let (oid, name) = std::str::from_utf8(&line[..nul]).ok()?.split_once(' ')?;
773+ if !matches!(oid.len(), 40 | 64) || !oid.bytes().all(|byte| byte.is_ascii_hexdigit()) || name.is_empty() {
774+ return None;
775+ }
776+ let end = if line.ends_with(b"\n") { line.len() - 1 } else { line.len() };
777+ let capabilities = &line[nul + 1..end];
778+ if capabilities.split(|byte| *byte == b' ').any(|capability| capability == NO_THIN) {
779+ return None;
780+ }
781+ let mut added = Vec::with_capacity(NO_THIN.len() + 1);
782+ if !capabilities.is_empty() && !capabilities.ends_with(b" ") {
783+ added.push(b' ');
784+ }
785+ added.extend_from_slice(NO_THIN);
786+ // A pkt-line holds at most 65516 bytes of data.
787+ if line.len() + added.len() > 65516 {
788+ return None;
789+ }
790+ line.splice(end..end, added);
791+ Some(encode(&packets))
792+}
793+
748794 /// Whether a request to the git store is one whose answer names `HEAD`:
749795 /// the ref advertisement for a fetch, or a protocol v2 `ls-refs`.
750796 fn names_head(git: &GitRequest, body: Option<&[u8]>) -> bool {
9601006 let body = with_head(&body, branch).unwrap_or(body);
9611007 response = Response::from_bytes(body)?.with_headers(headers);
9621008 }
1009+ // A push's ref advertisement asks for a pack without outside bases.
1010+ if method == Method::Get
1011+ && git.service == GitService::ReceivePack
1012+ && git.endpoint == "info/refs"
1013+ && response.status_code() == 200
1014+ {
1015+ let headers = response.headers().clone();
1016+ headers.delete("content-length")?;
1017+ let body = response.bytes().await?;
1018+ let body = with_no_thin(&body).unwrap_or(body);
1019+ response = Response::from_bytes(body)?.with_headers(headers);
1020+ }
9631021 if git.endpoint == "git-upload-pack"
9641022 && response.status_code() == 200
9651023 && body.as_deref().is_some_and(negotiating)
10171075 for (part, ms) in checked.spans {
10181076 timing.part(part, ms);
10191077 }
1078+ // Whether the pack came thin: g1t asks for whole ones (`no-thin`, see
1079+ // [`with_no_thin`]), so a thin one is a client that ignored it, and its
1080+ // bases were read from the store (`read`).
1081+ if let Some(bases) = checked.bases {
1082+ timing.note("thin", if bases.thin() { "yes" } else { "no" });
1083+ if bases.thin() {
1084+ let agent = request.headers().get("user-agent").ok().flatten().unwrap_or_default();
1085+ worker::console_warn!(
1086+ "a thin push from {agent}: {} bases outside the pack, {} asked of the store, {} left unresolved",
1087+ bases.missing,
1088+ bases.asked,
1089+ bases.left
1090+ );
1091+ }
1092+ }
10201093 timing.mark("checks");
10211094 let blocked = match checked.verdict {
10221095 Verdict::Refused(response) => {
11271200
11281201 #[cfg(test)]
11291202 mod tests {
1130− use super::{Acknowledged, GitService, Pushed, RepoPath, Url, ZERO_ID, acknowledged, framed, negotiating, pack_bytes, parse, pushed_branches, refusal, server_timing, transferred, with_head, with_namespace};
1203+ use super::{Acknowledged, GitService, Pushed, RepoPath, Url, ZERO_ID, acknowledged, framed, negotiating, pack_bytes, parse, pushed_branches, refusal, server_timing, transferred, with_head, with_namespace, with_no_thin};
11311204
11321205 #[test]
11331206 fn server_timing_names_each_step_and_the_total() {
11851258 }
11861259
11871260 #[test]
1261+ fn a_push_advertisement_asks_for_a_pack_without_outside_bases() {
1262+ let main = "1111111111111111111111111111111111111111";
1263+ let topic = "2222222222222222222222222222222222222222";
1264+ let body = [
1265+ pkt("# service=git-receive-pack\n"),
1266+ b"0000".to_vec(),
1267+ pkt(&format!("{main} refs/heads/main\0report-status delete-refs side-band-64k quiet ofs-delta agent=git/2.45\n")),
1268+ pkt(&format!("{topic} refs/heads/topic\n")),
1269+ b"0000".to_vec(),
1270+ ]
1271+ .concat();
1272+ let changed = with_no_thin(&body).unwrap();
1273+ let expected = [
1274+ pkt("# service=git-receive-pack\n"),
1275+ b"0000".to_vec(),
1276+ pkt(&format!("{main} refs/heads/main\0report-status delete-refs side-band-64k quiet ofs-delta agent=git/2.45 no-thin\n")),
1277+ pkt(&format!("{topic} refs/heads/topic\n")),
1278+ b"0000".to_vec(),
1279+ ]
1280+ .concat();
1281+ assert_eq!(String::from_utf8(changed.clone()).unwrap(), String::from_utf8(expected).unwrap());
1282+ // The length of the line that grew is its new one: the whole parses.
1283+ assert!(super::packets(&changed).is_some());
1284+ // Said once: an answer that has it already is left alone.
1285+ assert!(with_no_thin(&changed).is_none());
1286+
1287+ // Protocol v1 begins with `version 1`.
1288+ let v1 = [
1289+ pkt("# service=git-receive-pack\n"),
1290+ b"0000".to_vec(),
1291+ pkt("version 1\n"),
1292+ pkt(&format!("{main} refs/heads/main\0report-status ofs-delta\n")),
1293+ b"0000".to_vec(),
1294+ ]
1295+ .concat();
1296+ let changed = String::from_utf8(with_no_thin(&v1).unwrap()).unwrap();
1297+ assert!(changed.contains(&String::from_utf8(pkt(&format!("{main} refs/heads/main\0report-status ofs-delta no-thin\n"))).unwrap()));
1298+ assert!(changed.contains("000eversion 1\n"));
1299+ }
1300+
1301+ #[test]
1302+ fn an_empty_repository_advertisement_asks_for_a_whole_pack_too() {
1303+ let zero = "0000000000000000000000000000000000000000";
1304+ let body = [
1305+ pkt("# service=git-receive-pack\n"),
1306+ b"0000".to_vec(),
1307+ pkt(&format!("{zero} capabilities^{{}}\0report-status delete-refs ofs-delta\n")),
1308+ b"0000".to_vec(),
1309+ ]
1310+ .concat();
1311+ let changed = String::from_utf8(with_no_thin(&body).unwrap()).unwrap();
1312+ assert!(changed.contains(&String::from_utf8(pkt(&format!("{zero} capabilities^{{}}\0report-status delete-refs ofs-delta no-thin\n"))).unwrap()));
1313+ // No capabilities at all, and no newline: still one list.
1314+ let bare = [pkt("# service=git-receive-pack\n"), b"0000".to_vec(), pkt(&format!("{zero} capabilities^{{}}\0")), b"0000".to_vec()].concat();
1315+ let changed = String::from_utf8(with_no_thin(&bare).unwrap()).unwrap();
1316+ assert!(changed.contains(&String::from_utf8(pkt(&format!("{zero} capabilities^{{}}\0no-thin"))).unwrap()));
1317+ }
1318+
1319+ #[test]
1320+ fn an_advertisement_that_cannot_be_read_goes_through_untouched() {
1321+ let main = "1111111111111111111111111111111111111111";
1322+ // Not pkt-lines; a length past the end; an error page.
1323+ assert!(with_no_thin(b"not a git answer").is_none());
1324+ assert!(with_no_thin(b"00ff1111").is_none());
1325+ assert!(with_no_thin(b"<html>503 Service Unavailable</html>").is_none());
1326+ assert!(with_no_thin(b"").is_none());
1327+ // A first ref line without capabilities, or without an object id.
1328+ let without = [pkt("# service=git-receive-pack\n"), b"0000".to_vec(), pkt(&format!("{main} refs/heads/main\n")), b"0000".to_vec()].concat();
1329+ assert!(with_no_thin(&without).is_none());
1330+ let unnamed = [pkt("# service=git-receive-pack\n"), b"0000".to_vec(), pkt("nothing here\0report-status\n"), b"0000".to_vec()].concat();
1331+ assert!(with_no_thin(&unnamed).is_none());
1332+ // `no-thin` inside another capability's value is not `no-thin`.
1333+ let lookalike = [pkt("# service=git-receive-pack\n"), b"0000".to_vec(), pkt(&format!("{main} refs/heads/main\0agent=no-thin-ish\n")), b"0000".to_vec()].concat();
1334+ assert!(String::from_utf8(with_no_thin(&lookalike).unwrap()).unwrap().contains("agent=no-thin-ish no-thin\n"));
1335+ }
1336+
1337+ #[test]
11881338 fn head_follows_the_default_branch_in_a_v2_listing() {
11891339 let main = "1111111111111111111111111111111111111111";
11901340 let trunk = "2222222222222222222222222222222222222222";