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.
1 file+151−10/1 viewed
| 745 | 745 | changed.then(|| encode(&packets)) | |
| 746 | 746 | } | |
| 747 | 747 | ||
| 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 | + | ||
| 748 | 794 | /// Whether a request to the git store is one whose answer names `HEAD`: | |
| 749 | 795 | /// the ref advertisement for a fetch, or a protocol v2 `ls-refs`. | |
| 750 | 796 | fn names_head(git: &GitRequest, body: Option<&[u8]>) -> bool { | |
| ⋯ | |||
| 960 | 1006 | let body = with_head(&body, branch).unwrap_or(body); | |
| 961 | 1007 | response = Response::from_bytes(body)?.with_headers(headers); | |
| 962 | 1008 | } | |
| 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 | + | } | |
| 963 | 1021 | if git.endpoint == "git-upload-pack" | |
| 964 | 1022 | && response.status_code() == 200 | |
| 965 | 1023 | && body.as_deref().is_some_and(negotiating) | |
| ⋯ | |||
| 1017 | 1075 | for (part, ms) in checked.spans { | |
| 1018 | 1076 | timing.part(part, ms); | |
| 1019 | 1077 | } | |
| 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 | + | } | |
| 1020 | 1093 | timing.mark("checks"); | |
| 1021 | 1094 | let blocked = match checked.verdict { | |
| 1022 | 1095 | Verdict::Refused(response) => { | |
| ⋯ | |||
| 1127 | 1200 | ||
| 1128 | 1201 | #[cfg(test)] | |
| 1129 | 1202 | 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}; | |
| 1131 | 1204 | ||
| 1132 | 1205 | #[test] | |
| 1133 | 1206 | fn server_timing_names_each_step_and_the_total() { | |
| ⋯ | |||
| 1185 | 1258 | } | |
| 1186 | 1259 | ||
| 1187 | 1260 | #[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] | |
| 1188 | 1338 | fn head_follows_the_default_branch_in_a_v2_listing() { | |
| 1189 | 1339 | let main = "1111111111111111111111111111111111111111"; | |
| 1190 | 1340 | let trunk = "2222222222222222222222222222222222222222"; | |