Skip to main content

sley_remote/
proc_receive.rs

1//! `proc-receive` hook protocol and `receive.procReceiveRefs` matching.
2//!
3//! Mirrors upstream `receive-pack.c` (`proc_receive_*`, `run_proc_receive_hook`,
4//! `read_proc_receive_report`).
5
6use 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/// One configured `receive.procReceiveRefs` pattern.
22#[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/// Per-command proc-receive state carried through receive-pack execution.
32#[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
75/// Load every `receive.procReceiveRefs` value from `config`.
76pub 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}