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