| 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 | } |