| 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 | + | |
| 14 | + | use crate::git_http::{Packet, encode, framed, packets, pkt_line}; |
| 15 | + | |
| 16 | + | /// The refs g1t keeps. |
| 17 | + | pub 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. |
| 21 | + | pub 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. |
| 25 | + | const ATOMIC_REASON: &str = "atomic transaction failed"; |
| 26 | + | |
| 27 | + | pub 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)] |
| 33 | + | pub 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. |
| 45 | + | struct Line { |
| 46 | + | data: Vec<u8>, |
| 47 | + | /// The ref, when the line is a command (`<old> <new> <ref>`). |
| 48 | + | name: Option<String>, |
| 49 | + | } |
| 50 | + | |
| 51 | + | fn 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`. |
| 63 | + | fn 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). |
| 81 | + | fn 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 | + | |
| 87 | + | fn 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. |
| 95 | + | pub 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 | + | |
| 152 | + | fn 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. |
| 161 | + | fn 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. |
| 175 | + | pub 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. |
| 206 | + | pub 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)] |
| 260 | + | mod 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 | + | } |