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};
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/// One configured `receive.procReceiveRefs` pattern.
23#[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/// Per-command proc-receive state carried through receive-pack execution.
33#[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
76/// Load every `receive.procReceiveRefs` value from `config`.
77pub 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    /// Incoming-object quarantine environment inherited by the hook.
186    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    // Drain the hook's stderr while its pkt-line protocol is in flight.  A hook
247    // may emit more than a pipe buffer before exiting, so waiting first can
248    // deadlock.  We still append the completed hook stream before receive-pack
249    // protocol diagnostics below, matching git's stderr mux ordering.
250    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}