1use std::io::{Read, Write};
7use std::path::Path;
8use std::process::{Command, Stdio};
9use std::thread;
10
11use sley_config::GitConfig;
12use sley_core::{GitError, ObjectFormat, ObjectId, Result};
13use sley_odb::repository_common_dir;
14use sley_protocol::{
15 PktLineFrame, ReceivePackCommand, read_pkt_line_frame, read_pkt_line_frames_until_flush,
16 write_pkt_line_payload,
17};
18
19const RUN_PROC_RECEIVE_SCHEDULED: u8 = 1;
20const RUN_PROC_RECEIVE_RETURNED: u8 = 2;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ProcReceiveRefPattern {
25 pub prefix: String,
26 pub want_add: bool,
27 pub want_delete: bool,
28 pub want_modify: bool,
29 pub negative: bool,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ProcReceiveReport {
35 pub refname: Option<String>,
36 pub old_oid: Option<ObjectId>,
37 pub new_oid: Option<ObjectId>,
38 pub forced_update: bool,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ReceivePackCommandState {
43 pub command: ReceivePackCommand,
44 pub error_string: Option<String>,
45 pub proc_receive: u8,
46 pub was_proc_receive: bool,
47 pub reports: Vec<ProcReceiveReport>,
48}
49
50impl ReceivePackCommandState {
51 pub fn new(command: ReceivePackCommand) -> Self {
52 Self {
53 command,
54 error_string: None,
55 proc_receive: 0,
56 was_proc_receive: false,
57 reports: Vec::new(),
58 }
59 }
60
61 pub fn scheduled_for_proc_receive(&self) -> bool {
62 self.was_proc_receive
63 && self.proc_receive & RUN_PROC_RECEIVE_RETURNED == 0
64 && self.proc_receive & RUN_PROC_RECEIVE_SCHEDULED != 0
65 }
66
67 pub fn defer_ref_update(&self) -> bool {
68 self.was_proc_receive && self.proc_receive != 0
69 }
70
71 pub fn expects_proc_receive_report(&self) -> bool {
72 self.was_proc_receive && self.proc_receive != 0
73 }
74}
75
76pub fn parse_proc_receive_refs(config: &GitConfig) -> Vec<ProcReceiveRefPattern> {
78 config
79 .get_all("receive", None, "procReceiveRefs")
80 .into_iter()
81 .flatten()
82 .map(parse_proc_receive_ref_value)
83 .collect()
84}
85
86fn parse_proc_receive_ref_value(value: &str) -> ProcReceiveRefPattern {
87 let (modifiers, prefix) = match value.split_once(':') {
88 Some((mods, prefix)) => (mods, prefix),
89 None => ("adm", value),
90 };
91 let mut pattern = ProcReceiveRefPattern {
92 prefix: trim_ref_prefix(prefix),
93 want_add: false,
94 want_delete: false,
95 want_modify: false,
96 negative: false,
97 };
98 if modifiers.is_empty() {
99 pattern.want_add = true;
100 pattern.want_delete = true;
101 pattern.want_modify = true;
102 } else {
103 for ch in modifiers.chars() {
104 match ch {
105 'a' => pattern.want_add = true,
106 'd' => pattern.want_delete = true,
107 'm' => pattern.want_modify = true,
108 '!' => pattern.negative = true,
109 _ => {}
110 }
111 }
112 }
113 if !pattern.want_add && !pattern.want_delete && !pattern.want_modify {
114 pattern.want_add = true;
115 pattern.want_delete = true;
116 pattern.want_modify = true;
117 }
118 pattern
119}
120
121fn trim_ref_prefix(prefix: &str) -> String {
122 let mut out = prefix.to_string();
123 while out.ends_with('/') {
124 out.pop();
125 }
126 out
127}
128
129pub fn proc_receive_ref_matches(
130 patterns: &[ProcReceiveRefPattern],
131 command: &ReceivePackCommand,
132) -> bool {
133 for pattern in patterns {
134 if !pattern.want_add && command.old_id.is_null() {
135 continue;
136 }
137 if !pattern.want_delete && command.new_id.is_null() {
138 continue;
139 }
140 if !pattern.want_modify && !command.old_id.is_null() && !command.new_id.is_null() {
141 continue;
142 }
143 let matched = command.name.starts_with(&pattern.prefix)
144 && (command.name.len() == pattern.prefix.len()
145 || command.name.as_bytes().get(pattern.prefix.len()) == Some(&b'/'));
146 if pattern.negative {
147 if !matched {
148 return true;
149 }
150 } else if matched {
151 return true;
152 }
153 }
154 false
155}
156
157pub fn mark_proc_receive_commands(
158 patterns: &[ProcReceiveRefPattern],
159 commands: &mut [ReceivePackCommandState],
160) -> bool {
161 if patterns.is_empty() {
162 return false;
163 }
164 let mut any = false;
165 for state in commands.iter_mut() {
166 if state.error_string.is_some() {
167 continue;
168 }
169 if proc_receive_ref_matches(patterns, &state.command) {
170 state.proc_receive = RUN_PROC_RECEIVE_SCHEDULED;
171 state.was_proc_receive = true;
172 any = true;
173 }
174 }
175 any
176}
177
178pub struct ProcReceiveHookInput<'a> {
179 pub git_dir: &'a Path,
180 pub format: ObjectFormat,
181 pub commands: &'a [ReceivePackCommandState],
182 pub push_options: &'a [String],
183 pub use_atomic: bool,
184 pub use_push_options: bool,
185 pub quarantine_env: &'a [(String, String)],
187 pub remote_stderr: &'a mut Vec<u8>,
188 pub capture_stderr: bool,
189}
190
191pub struct ProcReceiveHookOutput {
192 pub commands: Vec<ReceivePackCommandState>,
193 pub hook_failed: bool,
194}
195
196pub fn run_proc_receive_hook(input: ProcReceiveHookInput<'_>) -> Result<ProcReceiveHookOutput> {
197 let hook_path = find_proc_receive_hook(input.git_dir)?;
198 let Some(hook_path) = hook_path else {
199 if input.capture_stderr {
200 input
201 .remote_stderr
202 .extend_from_slice(b"error: cannot find hook 'proc-receive'\n");
203 } else {
204 eprintln!("error: cannot find hook 'proc-receive'");
205 }
206 let mut commands = input.commands.to_vec();
207 for state in &mut commands {
208 if state.scheduled_for_proc_receive() {
209 state.error_string = Some("fail to run proc-receive hook".into());
210 }
211 }
212 return Ok(ProcReceiveHookOutput {
213 commands,
214 hook_failed: true,
215 });
216 };
217
218 let capture_stderr = input.capture_stderr;
219 let mut child = Command::new(&hook_path);
220 child
221 .current_dir(input.git_dir)
222 .env("GIT_DIR", input.git_dir)
223 .envs(input.quarantine_env.iter().map(|(key, value)| (key, value)))
224 .stdin(Stdio::piped())
225 .stdout(Stdio::piped())
226 .stderr(if capture_stderr {
227 Stdio::piped()
228 } else {
229 Stdio::inherit()
230 });
231
232 for (index, option) in input.push_options.iter().enumerate() {
233 if index == 0 {
234 child.env(
235 "GIT_PUSH_OPTION_COUNT",
236 input.push_options.len().to_string(),
237 );
238 }
239 child.env(format!("GIT_PUSH_OPTION_{index}"), option);
240 }
241
242 let mut child = child
243 .spawn()
244 .map_err(|err| GitError::Io(format!("cannot spawn proc-receive hook: {err}")))?;
245
246 let hook_stderr = if capture_stderr {
251 child.stderr.take().map(|mut stderr| {
252 thread::spawn(move || {
253 let mut output = Vec::new();
254 let _ = stderr.read_to_end(&mut output);
255 output
256 })
257 })
258 } else {
259 None
260 };
261
262 let mut stdin = child
263 .stdin
264 .take()
265 .ok_or_else(|| GitError::Io("proc-receive hook stdin unavailable".into()))?;
266 let mut stdout = child
267 .stdout
268 .take()
269 .ok_or_else(|| GitError::Io("proc-receive hook stdout unavailable".into()))?;
270
271 let mut hook_failed = false;
272 let mut protocol_messages = Vec::new();
273 if let Err(err) =
274 write_proc_receive_version(&mut stdin, input.use_atomic, input.use_push_options)
275 {
276 hook_failed = true;
277 protocol_messages.push(proc_receive_remote_error_message(&err));
278 }
279
280 if !hook_failed {
281 for state in proc_receive_command_order(input.commands) {
282 if !state.scheduled_for_proc_receive() || state.error_string.is_some() {
283 continue;
284 }
285 let cmd = &state.command;
286 let line = format!("{} {} {}\n", cmd.old_id, cmd.new_id, cmd.name);
287 if write_pkt_line_payload(&mut stdin, line.as_bytes()).is_err() {
288 hook_failed = true;
289 protocol_messages.push("fail to write commands to proc-receive hook".into());
290 break;
291 }
292 }
293 if !hook_failed {
294 stdin
295 .write_all(b"0000")
296 .map_err(|err| GitError::Io(err.to_string()))?;
297 }
298 }
299
300 let mut hook_use_push_options = false;
301 if !hook_failed {
302 match read_proc_receive_version_response(&mut stdout) {
303 Ok(use_push_options) => hook_use_push_options = use_push_options,
304 Err(err) => {
305 hook_failed = true;
306 protocol_messages.push(proc_receive_remote_error_message(&err));
307 }
308 }
309 if hook_use_push_options && input.use_push_options {
310 for option in input.push_options {
311 let mut line = option.as_bytes().to_vec();
312 line.push(b'\n');
313 if write_pkt_line_payload(&mut stdin, &line).is_err() {
314 hook_failed = true;
315 protocol_messages
316 .push("fail to write push-options to proc-receive hook".into());
317 break;
318 }
319 }
320 if !hook_failed {
321 stdin
322 .write_all(b"0000")
323 .map_err(|err| GitError::Io(err.to_string()))?;
324 }
325 }
326 }
327
328 drop(stdin);
329
330 let mut commands = input.commands.to_vec();
331 if !hook_failed {
332 let mut reader = stdout;
333 let outcome = read_proc_receive_report(input.format, &mut reader, &mut commands);
334 protocol_messages.extend(
335 outcome
336 .protocol_messages
337 .iter()
338 .map(proc_receive_remote_error_message),
339 );
340 if outcome.hook_failed {
341 hook_failed = true;
342 }
343 }
344
345 let status = child.wait().map_err(|err| GitError::Io(err.to_string()))?;
346 if let Some(hook_stderr) = hook_stderr {
347 let hook_stderr = hook_stderr
348 .join()
349 .map_err(|_| GitError::Io("proc-receive stderr reader panicked".into()))?;
350 input.remote_stderr.extend_from_slice(&hook_stderr);
351 }
352 for message in protocol_messages {
353 if input.capture_stderr {
354 input
355 .remote_stderr
356 .extend_from_slice(format!("error: {message}\n").as_bytes());
357 } else {
358 eprintln!("error: {message}");
359 }
360 }
361 if !status.success() {
362 hook_failed = true;
363 }
364
365 Ok(ProcReceiveHookOutput {
366 commands,
367 hook_failed,
368 })
369}
370
371fn proc_receive_command_order(
372 commands: &[ReceivePackCommandState],
373) -> Vec<&ReceivePackCommandState> {
374 let mut existing: Vec<_> = commands
375 .iter()
376 .filter(|state| !state.command.old_id.is_null())
377 .collect();
378 existing.sort_by(|left, right| left.command.name.cmp(&right.command.name));
379 existing.extend(
380 commands
381 .iter()
382 .filter(|state| state.command.old_id.is_null()),
383 );
384 existing
385}
386
387fn find_proc_receive_hook(git_dir: &Path) -> Result<Option<std::path::PathBuf>> {
388 let common = repository_common_dir(git_dir);
389 let path = common.join("hooks").join("proc-receive");
390 if path.is_file() {
391 Ok(Some(path))
392 } else {
393 Ok(None)
394 }
395}
396
397fn write_proc_receive_version(
398 writer: &mut impl Write,
399 use_atomic: bool,
400 use_push_options: bool,
401) -> Result<()> {
402 let mut caps = Vec::new();
403 if use_atomic {
404 caps.push("atomic");
405 }
406 if use_push_options {
407 caps.push("push-options");
408 }
409 let payload = if caps.is_empty() {
410 b"version=1\n".to_vec()
411 } else {
412 let mut out = b"version=1\0".to_vec();
413 out.extend_from_slice(caps.join(" ").as_bytes());
414 out.push(b'\n');
415 out
416 };
417 write_pkt_line_payload(writer, &payload)?;
418 writer.write_all(b"0000")?;
419 Ok(())
420}
421
422fn read_proc_receive_version_response(reader: &mut impl Read) -> Result<bool> {
423 let mut hook_use_push_options = false;
424 loop {
425 let Some(frame) = read_pkt_line_frame(reader)? else {
426 return Err(GitError::InvalidFormat(
427 "fail to negotiate version with proc-receive hook".into(),
428 ));
429 };
430 match frame {
431 PktLineFrame::Flush => return Ok(hook_use_push_options),
432 PktLineFrame::Data(payload) => {
433 let text = pkt_line_text(&payload)?;
434 if let Some(version) = text.strip_prefix("version=") {
435 let version = version
436 .split('\0')
437 .next()
438 .unwrap_or(version)
439 .parse::<u32>()
440 .map_err(|_| {
441 GitError::InvalidFormat(format!(
442 "proc-receive version '{version}' is not supported"
443 ))
444 })?;
445 if version != 0 && version != 1 {
446 return Err(GitError::InvalidFormat(format!(
447 "proc-receive version '{version}' is not supported"
448 )));
449 }
450 if text.contains('\0') {
451 let features = text.split('\0').nth(1).unwrap_or_default();
452 for feature in features.split_whitespace() {
453 if feature == "push-options" {
454 hook_use_push_options = true;
455 }
456 }
457 }
458 }
459 }
460 PktLineFrame::Delimiter | PktLineFrame::ResponseEnd => {
461 return Err(GitError::InvalidFormat(
462 "proc-receive version negotiation contains unexpected control packet".into(),
463 ));
464 }
465 }
466 }
467}
468
469struct ProcReceiveReportOutcome {
470 hook_failed: bool,
471 protocol_messages: Vec<String>,
472}
473
474fn read_proc_receive_report(
475 format: ObjectFormat,
476 reader: &mut impl Read,
477 commands: &mut [ReceivePackCommandState],
478) -> ProcReceiveReportOutcome {
479 let mut outcome = ProcReceiveReportOutcome {
480 hook_failed: false,
481 protocol_messages: Vec::new(),
482 };
483 let frames = match read_pkt_line_frames_until_flush(reader) {
484 Ok(frames) => frames,
485 Err(err) => {
486 outcome.hook_failed = true;
487 outcome
488 .protocol_messages
489 .push(proc_receive_remote_error_message(&err));
490 return outcome;
491 }
492 };
493 let mut hint_index: Option<usize> = None;
494 let mut new_report = false;
495 let mut option_without_ok = false;
496
497 for frame in frames {
498 let PktLineFrame::Data(payload) = frame else {
499 continue;
500 };
501 let text = match std::str::from_utf8(pkt_line_bytes(&payload)) {
502 Ok(text) => text.to_string(),
503 Err(err) => {
504 outcome.hook_failed = true;
505 outcome.protocol_messages.push(err.to_string());
506 return outcome;
507 }
508 };
509
510 if text.starts_with("option ") {
511 let Some(idx) = hint_index else {
512 if !option_without_ok {
513 option_without_ok = true;
514 outcome.protocol_messages.push(
515 "proc-receive reported 'option' without a matching 'ok/ng' directive"
516 .into(),
517 );
518 }
519 outcome.hook_failed = true;
520 continue;
521 };
522 if !new_report && commands[idx].reports.is_empty() {
523 if !option_without_ok {
524 option_without_ok = true;
525 outcome.protocol_messages.push(
526 "proc-receive reported 'option' without a matching 'ok/ng' directive"
527 .into(),
528 );
529 }
530 outcome.hook_failed = true;
531 continue;
532 }
533 if new_report {
534 commands[idx].reports.push(ProcReceiveReport {
535 refname: None,
536 old_oid: None,
537 new_oid: None,
538 forced_update: false,
539 });
540 new_report = false;
541 }
542 let Some(report) = commands[idx].reports.last_mut() else {
543 outcome.hook_failed = true;
544 outcome.protocol_messages.push(
545 "proc-receive reported 'option' without a matching 'ok/ng' directive".into(),
546 );
547 continue;
548 };
549 if let Some(rest) = text.strip_prefix("option refname ") {
550 report.refname = Some(rest.to_string());
551 } else if let Some(rest) = text.strip_prefix("option old-oid ") {
552 match ObjectId::from_hex(format, rest) {
553 Ok(oid) => report.old_oid = Some(oid),
554 Err(err) => {
555 outcome.hook_failed = true;
556 outcome.protocol_messages.push(err.to_string());
557 }
558 }
559 } else if let Some(rest) = text.strip_prefix("option new-oid ") {
560 match ObjectId::from_hex(format, rest) {
561 Ok(oid) => report.new_oid = Some(oid),
562 Err(err) => {
563 outcome.hook_failed = true;
564 outcome.protocol_messages.push(err.to_string());
565 }
566 }
567 } else if text == "option forced-update" {
568 report.forced_update = true;
569 } else if text == "option fall-through" {
570 commands[idx].proc_receive = 0;
571 } else {
572 outcome.hook_failed = true;
573 }
574 continue;
575 }
576
577 new_report = false;
578 let Some((head, rest)) = text.split_once(' ') else {
579 outcome.hook_failed = true;
580 outcome.protocol_messages.push(format!(
581 "proc-receive reported incomplete status line: '{text}'"
582 ));
583 continue;
584 };
585 let (refname, message) = match rest.split_once(' ') {
586 Some((refname, message)) => (refname, Some(message)),
587 None => (rest, None),
588 };
589
590 if head != "ok" && head != "ng" {
591 outcome.hook_failed = true;
592 outcome.protocol_messages.push(format!(
593 "proc-receive reported bad status '{head}' on ref '{refname}'"
594 ));
595 continue;
596 }
597
598 let Some(idx) = find_command_index(commands, refname, hint_index) else {
599 outcome.hook_failed = true;
600 outcome.protocol_messages.push(format!(
601 "proc-receive reported status on unknown ref: {refname}"
602 ));
603 continue;
604 };
605 if !commands[idx].expects_proc_receive_report() {
606 outcome.hook_failed = true;
607 outcome.protocol_messages.push(format!(
608 "proc-receive reported status on unexpected ref: {refname}"
609 ));
610 continue;
611 }
612
613 hint_index = Some(idx);
614 commands[idx].proc_receive |= RUN_PROC_RECEIVE_RETURNED;
615 if head == "ng" {
616 commands[idx].error_string = Some(message.unwrap_or("failed").to_string());
617 outcome.hook_failed = true;
618 continue;
619 }
620 new_report = true;
621 }
622
623 for state in commands.iter_mut() {
624 if state.scheduled_for_proc_receive()
625 && state.error_string.is_none()
626 && state.proc_receive & RUN_PROC_RECEIVE_RETURNED == 0
627 {
628 state.error_string = Some("proc-receive failed to report status".into());
629 outcome.hook_failed = true;
630 }
631 }
632
633 outcome
634}
635
636fn find_command_index(
637 commands: &[ReceivePackCommandState],
638 refname: &str,
639 hint: Option<usize>,
640) -> Option<usize> {
641 if let Some(hint) = hint
642 && commands
643 .get(hint)
644 .is_some_and(|state| state.command.name == refname)
645 {
646 return Some(hint);
647 }
648 commands
649 .iter()
650 .position(|state| state.command.name == refname)
651}
652
653fn pkt_line_bytes(payload: &[u8]) -> &[u8] {
654 payload.strip_suffix(b"\n").unwrap_or(payload)
655}
656
657fn pkt_line_text(payload: &[u8]) -> Result<&str> {
658 std::str::from_utf8(pkt_line_bytes(payload))
659 .map_err(|err| GitError::InvalidFormat(err.to_string()))
660}
661
662fn proc_receive_remote_error_message(err: impl std::fmt::Display) -> String {
663 let message = err.to_string();
664 message
665 .strip_prefix("invalid format: ")
666 .unwrap_or(&message)
667 .to_string()
668}
669
670pub fn apply_proc_receive_hook_failure(
671 commands: &mut [ReceivePackCommandState],
672 atomic: bool,
673 hook_failed: bool,
674) {
675 if !hook_failed {
676 return;
677 }
678 for state in commands.iter_mut() {
679 if state.error_string.is_some() {
680 continue;
681 }
682 if atomic
683 || (state.scheduled_for_proc_receive()
684 && state.proc_receive & RUN_PROC_RECEIVE_RETURNED == 0)
685 {
686 state.error_string = Some("fail to run proc-receive hook".into());
687 }
688 }
689}