Skip to content
399 linesCodeBlameRaw
1//! Refs g1t keeps for itself, which nobody pushes to: a pull request's
2//! `refs/pull/<number>/head` and `refs/pull/<number>/merge` (test_merge.rs,
3//! forks.rs). As on GitHub, they are hidden refs: fetched like any other,
4//! left out of the advertisement a push reads, and refused in a push with
5//! `deny updating a hidden ref`, ref by ref, while the rest of the push is
6//! applied. So `git push --mirror` of a repository cloned with `--mirror`
7//! from GitHub moves every branch and tag and reports only its
8//! `refs/pull/*` as rejected.
9//!
10//! g1t moves them itself straight on the git store (land.rs `push_ref`),
11//! never through the git remote people push to, so none of this touches
12//! that.
13
14use crate::git_http::{Packet, encode, framed, packets, pkt_line};
15
16/// The refs g1t keeps.
17pub const HIDDEN_PREFIX: &str = "refs/pull/";
18
19/// What git is told for each of them, as GitHub (and git's own
20/// `receive.hideRefs`) tells it.
21pub const HIDDEN_REASON: &str = "deny updating a hidden ref";
22
23/// What the other refs of an `--atomic` push are told, as git's
24/// receive-pack tells them.
25const ATOMIC_REASON: &str = "atomic transaction failed";
26
27pub fn is_hidden(name: &str) -> bool {
28 name.starts_with(HIDDEN_PREFIX)
29}
30
31/// A push's commands with those to hidden refs taken out.
32#[derive(Debug, PartialEq, Eq)]
33pub enum Split {
34 /// No command names a hidden ref: the push goes on as it came.
35 Untouched,
36 /// The push without them, to send on, and the refs that were taken
37 /// out, to report as refused once the store has answered for the rest.
38 Without { body: Vec<u8>, hidden: Vec<String> },
39 /// Nothing is sent on: every command names a hidden ref, or the push is
40 /// atomic and one of them does. The report to answer with.
41 Refused { report: Vec<u8>, hidden: Vec<String> },
42}
43
44/// One pkt-line of a push's command list, as it came.
45struct Line {
46 data: Vec<u8>,
47 /// The ref, when the line is a command (`<old> <new> <ref>`).
48 name: Option<String>,
49}
50
51fn command_name(data: &[u8]) -> Option<String> {
52 let command = data.split(|byte| *byte == 0).next()?;
53 let command = std::str::from_utf8(command).ok()?.trim_end();
54 let mut parts = command.splitn(3, ' ');
55 let (old, new, name) = (parts.next()?, parts.next()?, parts.next()?);
56 let oid = |value: &str| matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit());
57 (oid(old) && oid(new) && !name.is_empty()).then(|| name.to_owned())
58}
59
60/// The command lines at the start of a receive-pack request, up to the
61/// flush that ends them, and where the rest (push options, the pack)
62/// starts. `None` when they do not end within `body`.
63fn command_lines(body: &[u8]) -> Option<(Vec<Line>, usize)> {
64 let mut lines = Vec::new();
65 let mut position = 0;
66 loop {
67 let header = body.get(position..position + 4)?;
68 let length = usize::from_str_radix(std::str::from_utf8(header).ok()?, 16).ok()?;
69 if length == 0 {
70 return Some((lines, position + 4));
71 }
72 let data = body.get(position + 4..position + length)?.to_vec();
73 position += length;
74 let name = command_name(&data);
75 lines.push(Line { data, name });
76 }
77}
78
79/// The capabilities on the first command, after its NUL, as sent
80/// (with the newline, when there is one).
81fn capabilities_of(lines: &[Line]) -> Option<Vec<u8>> {
82 let first = lines.iter().find(|line| line.name.is_some())?;
83 let nul = first.data.iter().position(|byte| *byte == 0)?;
84 Some(first.data[nul + 1..].to_vec())
85}
86
87fn has_capability(capabilities: &[u8], wanted: &str) -> bool {
88 String::from_utf8_lossy(capabilities)
89 .split_ascii_whitespace()
90 .any(|capability| capability == wanted)
91}
92
93/// Takes the commands to hidden refs out of a receive-pack request (see
94/// [`Split`]). The capabilities stay on the first command that is left.
95pub fn split(body: &[u8]) -> Split {
96 let Some((lines, rest)) = command_lines(body) else {
97 return Split::Untouched;
98 };
99 let hidden: Vec<String> = lines
100 .iter()
101 .filter_map(|line| line.name.as_deref())
102 .filter(|name| is_hidden(name))
103 .map(str::to_owned)
104 .collect();
105 if hidden.is_empty() {
106 return Split::Untouched;
107 }
108 let capabilities = capabilities_of(&lines).unwrap_or_default();
109 let caps_text = String::from_utf8_lossy(&capabilities).trim().to_owned();
110 let kept: Vec<&Line> = lines
111 .iter()
112 .filter(|line| line.name.as_deref().is_some_and(|name| !is_hidden(name)))
113 .collect();
114 if kept.is_empty() || has_capability(&capabilities, "atomic") {
115 let mut report = pkt_line(b"unpack ok\n");
116 for line in lines.iter().filter_map(|line| line.name.as_deref()) {
117 let reason = if is_hidden(line) { HIDDEN_REASON } else { ATOMIC_REASON };
118 report.extend(pkt_line(format!("ng {line} {reason}\n").as_bytes()));
119 }
120 report.extend_from_slice(b"0000");
121 return Split::Refused { report: framed(report, &caps_text, &[]), hidden };
122 }
123 let mut out = Vec::with_capacity(body.len());
124 let mut capabilities_placed = false;
125 for line in &lines {
126 match line.name.as_deref() {
127 Some(name) if is_hidden(name) => continue,
128 Some(_) => {
129 // `<old> <new> <ref>` without what followed a NUL, then the
130 // capabilities on the first command that is sent.
131 let command = line.data.split(|byte| *byte == 0).next().unwrap_or_default();
132 let command = command.strip_suffix(b"\n").unwrap_or(command);
133 let mut data = command.to_vec();
134 if !capabilities_placed && !capabilities.is_empty() {
135 data.push(0);
136 data.extend_from_slice(&capabilities);
137 } else {
138 data.push(b'\n');
139 }
140 capabilities_placed = true;
141 out.extend(pkt_line(&data));
142 }
143 // `shallow` lines and the like stay where they were.
144 None => out.extend(pkt_line(&line.data)),
145 }
146 }
147 out.extend_from_slice(b"0000");
148 out.extend_from_slice(&body[rest..]);
149 Split::Without { body: out, hidden }
150}
151
152fn ng_lines(hidden: &[String]) -> Vec<Packet> {
153 hidden
154 .iter()
155 .map(|name| Packet::Data(format!("ng {name} {HIDDEN_REASON}\n").into_bytes()))
156 .collect()
157}
158
159/// A report-status (bare packets ending in a flush) with `ng` lines for
160/// `hidden` before its flush.
161fn report_with(report: &[u8], hidden: &[String]) -> Option<Vec<u8>> {
162 let mut packets = packets(report)?;
163 let flush = packets.iter().rposition(|packet| *packet == Packet::Special(*b"0000"))?;
164 let ng = ng_lines(hidden);
165 packets.splice(flush..flush, ng);
166 Some(encode(&packets))
167}
168
169/// The store's answer to a push it was sent without the commands to
170/// hidden refs, with each of those refused in its report, so git prints
171/// `! [remote rejected] refs/pull/1/head (deny updating a hidden ref)`
172/// beside the refs that moved. Inside channel 1 when the client asked for
173/// side-band; progress and errors on the other channels are kept as they
174/// were. `None` when the answer is not a report this can read.
175pub fn with_refused(answer: &[u8], hidden: &[String], sideband: bool) -> Option<Vec<u8>> {
176 if !sideband {
177 return report_with(answer, hidden);
178 }
179 let packets = packets(answer)?;
180 let mut report = Vec::new();
181 let mut others = Vec::new();
182 for packet in packets {
183 match &packet {
184 Packet::Data(data) if data.first() == Some(&1) => report.extend_from_slice(&data[1..]),
185 Packet::Special([b'0', b'0', b'0', b'0']) => {}
186 _ => others.push(packet),
187 }
188 }
189 let report = report_with(&report, hidden)?;
190 let mut out = encode(&others);
191 // side-band (not -64k) packets carry at most 1000 bytes.
192 for chunk in report.chunks(990) {
193 let mut packet = vec![1u8];
194 packet.extend_from_slice(chunk);
195 out.extend(pkt_line(&packet));
196 }
197 out.extend_from_slice(b"0000");
198 Some(out)
199}
200
201/// The receive-pack ref advertisement without hidden refs, as GitHub
202/// advertises it, so `git push --mirror` does not try to delete what g1t
203/// keeps. The capabilities move to the first ref left, or to a
204/// `capabilities^{}` line when none is. `None` when there is nothing to
205/// change or the answer is not a whole advertisement.
206pub fn advertisement_without_hidden(body: &[u8]) -> Option<Vec<u8>> {
207 let packets = packets(body)?;
208 let name_of = |data: &[u8]| -> Option<String> {
209 let line = data.split(|byte| *byte == 0).next()?;
210 let (oid, name) = std::str::from_utf8(line).ok()?.trim_end().split_once(' ')?;
211 (matches!(oid.len(), 40 | 64) && oid.bytes().all(|byte| byte.is_ascii_hexdigit())).then(|| name.to_owned())
212 };
213 let is_ref = |packet: &Packet| matches!(packet, Packet::Data(data) if !data.starts_with(b"# service=") && !data.starts_with(b"version ") && name_of(data).is_some());
214 if !packets.iter().any(|packet| matches!(packet, Packet::Data(data) if is_ref(packet) && name_of(data).is_some_and(|name| is_hidden(&name)))) {
215 return None;
216 }
217 let first = packets.iter().position(is_ref)?;
218 let Packet::Data(first_line) = &packets[first] else { return None };
219 let nul = first_line.iter().position(|byte| *byte == 0)?;
220 let capabilities = first_line[nul + 1..].to_vec();
221 let mut out: Vec<Packet> = Vec::with_capacity(packets.len());
222 let mut placed = false;
223 let mut end_of_refs = None;
224 for (index, packet) in packets.into_iter().enumerate() {
225 if index < first || !is_ref(&packet) {
226 if index > first && end_of_refs.is_none() {
227 end_of_refs = Some(out.len());
228 }
229 out.push(packet);
230 continue;
231 }
232 let Packet::Data(data) = packet else { continue };
233 let name = name_of(&data).unwrap_or_default();
234 if is_hidden(&name) {
235 continue;
236 }
237 let command = data.split(|byte| *byte == 0).next().unwrap_or_default();
238 let command = command.strip_suffix(b"\n").unwrap_or(command).to_vec();
239 let mut line = command;
240 if placed {
241 line.push(b'\n');
242 } else {
243 line.push(0);
244 line.extend_from_slice(&capabilities);
245 placed = true;
246 }
247 out.push(Packet::Data(line));
248 }
249 if !placed {
250 let zero = "0".repeat(40);
251 let mut line = format!("{zero} capabilities^{{}}").into_bytes();
252 line.push(0);
253 line.extend_from_slice(&capabilities);
254 out.insert(end_of_refs.unwrap_or(out.len()).min(out.len()), Packet::Data(line));
255 }
256 Some(encode(&out))
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 const ZERO: &str = "0000000000000000000000000000000000000000";
264 const OLD: &str = "c71546fcd893ef8b0f57388b65e620d759705dda";
265 const NEW: &str = "4807077b296e6edbf410d55e72749d3e1170c291";
266
267 fn pkt(payload: &str) -> Vec<u8> {
268 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
269 }
270
271 fn text(bytes: &[u8]) -> String {
272 String::from_utf8_lossy(bytes).into_owned()
273 }
274
275 const PACK: &[u8] = b"PACK\0\0\0\x02\0\0\0\0";
276
277 #[test]
278 fn a_push_without_hidden_refs_is_untouched() {
279 let body = [pkt(&format!("{OLD} {NEW} refs/heads/main\0 report-status\n")), b"0000".to_vec(), PACK.to_vec()].concat();
280 assert_eq!(split(&body), Split::Untouched);
281 }
282
283 #[test]
284 fn hidden_refs_are_taken_out_and_the_rest_sent_on() {
285 // A mirror push: refs/pull first, carrying the capabilities.
286 let body = [
287 pkt(&format!("{ZERO} {NEW} refs/pull/1/head\0 report-status side-band-64k quiet\n")),
288 pkt(&format!("{OLD} {NEW} refs/heads/main\n")),
289 pkt(&format!("{ZERO} {NEW} refs/pull/1/merge\n")),
290 pkt(&format!("{ZERO} {NEW} refs/tags/v1\n")),
291 b"0000".to_vec(),
292 PACK.to_vec(),
293 ]
294 .concat();
295 let Split::Without { body: sent, hidden } = split(&body) else { panic!("expected a split") };
296 assert_eq!(hidden, ["refs/pull/1/head", "refs/pull/1/merge"]);
297 let expected = [
298 pkt(&format!("{OLD} {NEW} refs/heads/main\0 report-status side-band-64k quiet\n")),
299 pkt(&format!("{ZERO} {NEW} refs/tags/v1\n")),
300 b"0000".to_vec(),
301 PACK.to_vec(),
302 ]
303 .concat();
304 assert_eq!(text(&sent), text(&expected));
305 }
306
307 #[test]
308 fn a_push_of_hidden_refs_alone_is_answered_here() {
309 let body = [
310 pkt(&format!("{OLD} {ZERO} refs/pull/7/head\0 report-status\n")),
311 b"0000".to_vec(),
312 ]
313 .concat();
314 let Split::Refused { report, hidden } = split(&body) else { panic!("expected a refusal") };
315 assert_eq!(hidden, ["refs/pull/7/head"]);
316 assert_eq!(text(&report), text(&[pkt("unpack ok\n"), pkt("ng refs/pull/7/head deny updating a hidden ref\n"), b"0000".to_vec()].concat()));
317 }
318
319 #[test]
320 fn an_atomic_push_with_a_hidden_ref_is_refused_whole() {
321 let body = [
322 pkt(&format!("{OLD} {NEW} refs/heads/main\0 report-status atomic side-band-64k\n")),
323 pkt(&format!("{ZERO} {NEW} refs/pull/1/head\n")),
324 b"0000".to_vec(),
325 PACK.to_vec(),
326 ]
327 .concat();
328 let Split::Refused { report, .. } = split(&body) else { panic!("expected a refusal") };
329 let report = text(&report);
330 // Framed on channel 1 for side-band.
331 assert!(report.contains("ng refs/heads/main atomic transaction failed\n"));
332 assert!(report.contains("ng refs/pull/1/head deny updating a hidden ref\n"));
333 assert!(report.ends_with("0000"));
334 }
335
336 #[test]
337 fn the_stores_report_gains_the_refusals() {
338 let hidden = vec!["refs/pull/1/head".to_owned()];
339 let bare = [pkt("unpack ok\n"), pkt("ok refs/heads/main\n"), b"0000".to_vec()].concat();
340 let changed = with_refused(&bare, &hidden, false).unwrap();
341 assert_eq!(
342 text(&changed),
343 text(&[pkt("unpack ok\n"), pkt("ok refs/heads/main\n"), pkt("ng refs/pull/1/head deny updating a hidden ref\n"), b"0000".to_vec()].concat())
344 );
345 // Side-band: progress kept, the report re-framed on channel 1.
346 let inner = [pkt("unpack ok\n"), pkt("ok refs/heads/main\n"), b"0000".to_vec()].concat();
347 let mut channel_one = vec![1u8];
348 channel_one.extend_from_slice(&inner);
349 let framed_answer = [pkt("\x02Resolving deltas: 100%\n"), pkt(&text(&channel_one)), b"0000".to_vec()].concat();
350 let changed = with_refused(&framed_answer, &hidden, true).unwrap();
351 let packets = packets(&changed).unwrap();
352 assert_eq!(packets[0], Packet::Data(b"\x02Resolving deltas: 100%\n".to_vec()));
353 let Packet::Data(report) = &packets[1] else { panic!() };
354 assert_eq!(report[0], 1);
355 assert!(text(&report[1..]).contains("ng refs/pull/1/head deny updating a hidden ref\n"));
356 assert!(text(&report[1..]).ends_with("0000"));
357 assert_eq!(packets.last(), Some(&Packet::Special(*b"0000")));
358 // Not a report: left alone.
359 assert_eq!(with_refused(b"garbage", &hidden, false), None);
360 }
361
362 #[test]
363 fn the_push_advertisement_leaves_hidden_refs_out() {
364 let advertisement = [
365 pkt("# service=git-receive-pack\n"),
366 b"0000".to_vec(),
367 pkt(&format!("{OLD} refs/heads/main\0report-status delete-refs side-band-64k ofs-delta\n")),
368 pkt(&format!("{NEW} refs/pull/1/head\n")),
369 pkt(&format!("{NEW} refs/pull/1/merge\n")),
370 pkt(&format!("{OLD} refs/tags/v1\n")),
371 b"0000".to_vec(),
372 ]
373 .concat();
374 let changed = advertisement_without_hidden(&advertisement).unwrap();
375 assert_eq!(
376 text(&changed),
377 text(
378 &[
379 pkt("# service=git-receive-pack\n"),
380 b"0000".to_vec(),
381 pkt(&format!("{OLD} refs/heads/main\0report-status delete-refs side-band-64k ofs-delta\n")),
382 pkt(&format!("{OLD} refs/tags/v1\n")),
383 b"0000".to_vec(),
384 ]
385 .concat()
386 )
387 );
388 // Nothing hidden: nothing to change.
389 let plain = [pkt("# service=git-receive-pack\n"), b"0000".to_vec(), pkt(&format!("{OLD} refs/heads/main\0report-status\n")), b"0000".to_vec()].concat();
390 assert_eq!(advertisement_without_hidden(&plain), None);
391 // Only hidden refs: the capabilities stay, on capabilities^{}.
392 let only = [pkt("# service=git-receive-pack\n"), b"0000".to_vec(), pkt(&format!("{NEW} refs/pull/1/head\0report-status\n")), b"0000".to_vec()].concat();
393 let changed = advertisement_without_hidden(&only).unwrap();
394 assert_eq!(
395 text(&changed),
396 text(&[pkt("# service=git-receive-pack\n"), b"0000".to_vec(), pkt(&format!("{ZERO} capabilities^{{}}\0report-status\n")), b"0000".to_vec()].concat())
397 );
398 }
399}