Skip to main content

sley_remote/
receive_pack_server.rs

1//! Full receive-pack server: hooks, proc-receive, ref updates, and status reports.
2
3use std::collections::HashSet;
4use std::io::{Cursor, Read, Write};
5use std::path::Path;
6
7use sley_config::GitConfig;
8use sley_core::{GitError, ObjectFormat, Result};
9use sley_odb::{repository_common_dir, FileObjectDatabase};
10use sley_protocol::{
11    validate_receive_pack_push_request_features, write_receive_pack_report_status,
12    write_receive_pack_report_status_v2, write_sideband_packet, ReceivePackCommand,
13    ReceivePackCommandStatus, ReceivePackCommandStatusV2, ReceivePackCommandStatusV2Options,
14    ReceivePackPushRequest, ReceivePackPushRequestHeader, ReceivePackReportStatus,
15    ReceivePackReportStatusV2, ReceivePackUnpackStatus, SideBandChannel, SideBandPacket,
16};
17use sley_refs::FileRefStore;
18
19use crate::local::{apply_receive_pack_ref_transaction, receive_pack_features};
20use crate::proc_receive::{
21    apply_proc_receive_hook_failure, mark_proc_receive_commands, parse_proc_receive_refs,
22    run_proc_receive_hook, ProcReceiveHookInput, ReceivePackCommandState,
23};
24use crate::push::stage_local_push_quarantine;
25use crate::receive_hooks::{
26    hook_exists, run_post_receive, run_post_update, run_pre_receive, run_push_to_checkout,
27    run_update_hooks,
28};
29
30pub struct ReceivePackServerOptions<'a> {
31    pub quiet: bool,
32    /// When set, traditional receive-pack hook stderr is captured for the push
33    /// client to print as `remote:` lines (in-process local transport).
34    pub remote_stderr: Option<&'a mut Vec<u8>>,
35    /// Run post-receive/post-update after ref updates. The local push client
36    /// runs these itself so hook stdin matches send-pack ordering.
37    pub run_post_hooks: bool,
38}
39
40pub struct ReceivePackServerRequest<'a> {
41    pub git_dir: &'a Path,
42    pub format: ObjectFormat,
43    pub header: &'a ReceivePackPushRequestHeader,
44    pub pack_reader: &'a mut dyn Read,
45    pub config: &'a GitConfig,
46    pub options: ReceivePackServerOptions<'a>,
47}
48
49pub enum ReceivePackServerReport {
50    V1(ReceivePackReportStatus),
51    V2(ReceivePackReportStatusV2),
52}
53
54pub struct ReceivePackServerOutcome {
55    pub report: ReceivePackServerReport,
56    pub command_states: Vec<ReceivePackCommandState>,
57}
58
59pub fn serve_receive_pack(
60    request: ReceivePackServerRequest<'_>,
61) -> Result<ReceivePackServerOutcome> {
62    let mut discard_stderr = Vec::new();
63    let capture_stderr = request.options.remote_stderr.is_some();
64    let remote_stderr = request.options.remote_stderr.unwrap_or(&mut discard_stderr);
65
66    validate_receive_pack_push_request_features(
67        &receive_pack_features(request.format),
68        &ReceivePackPushRequest {
69            commands: request.header.commands.clone(),
70            push_options: request.header.push_options.clone(),
71            packfile: Vec::new(),
72        },
73    )?;
74
75    let use_atomic = request_uses_atomic(request.header);
76    let use_report_v2 = request_uses_report_status_v2(request.header);
77    let push_options = request.header.push_options.as_deref().unwrap_or(&[]);
78
79    let mut command_states: Vec<ReceivePackCommandState> = request
80        .header
81        .commands
82        .commands
83        .iter()
84        .cloned()
85        .map(ReceivePackCommandState::new)
86        .collect();
87
88    let proc_patterns = parse_proc_receive_refs(request.config);
89    let run_proc_receive = mark_proc_receive_commands(&proc_patterns, &mut command_states);
90
91    let mut unpack_error = None;
92    if needs_pack_data(&command_states) {
93        match install_pack_from_reader(request.git_dir, request.format, request.pack_reader) {
94            Ok(()) => {}
95            Err(err) => {
96                let message = err.to_string();
97                unpack_error = Some(message.clone());
98                for state in &mut command_states {
99                    if state.error_string.is_none() {
100                        state.error_string = Some(message.clone());
101                    }
102                }
103            }
104        }
105    }
106
107    let ok_commands: Vec<ReceivePackCommand> = command_states
108        .iter()
109        .filter(|state| state.error_string.is_none())
110        .map(|state| state.command.clone())
111        .collect();
112
113    let quarantine = if unpack_error.is_none()
114        && !ok_commands.is_empty()
115        && receive_pre_hooks_may_run(request.git_dir)
116    {
117        let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
118        let common_git_dir = repository_common_dir(request.git_dir);
119        stage_local_push_quarantine(
120            request.git_dir,
121            &common_git_dir,
122            request.format,
123            &local_db,
124            &ok_commands,
125        )?
126    } else {
127        None
128    };
129    let quarantine_env = quarantine
130        .as_ref()
131        .map(|q| quarantine_hook_env(request.git_dir, q.object_dir()))
132        .unwrap_or_default();
133
134    if unpack_error.is_none() && !ok_commands.is_empty() {
135        if run_pre_receive(
136            request.git_dir,
137            &ok_commands,
138            push_options,
139            &quarantine_env,
140            remote_stderr,
141            capture_stderr,
142        )
143        .is_err()
144        {
145            for state in &mut command_states {
146                if state.error_string.is_none() {
147                    state.error_string = Some("pre-receive hook declined".into());
148                }
149            }
150        } else if let Some(name) = run_update_hooks(
151            request.git_dir,
152            &ok_commands,
153            &quarantine_env,
154            remote_stderr,
155            capture_stderr,
156        )? {
157            for state in &mut command_states {
158                if state.error_string.is_none() {
159                    if use_atomic {
160                        state.error_string = Some("atomic push failure".into());
161                    } else if state.command.name == name {
162                        state.error_string = Some("hook declined".into());
163                    }
164                }
165            }
166        }
167    }
168
169    if unpack_error.is_none() && run_proc_receive {
170        let output = run_proc_receive_hook(ProcReceiveHookInput {
171            git_dir: request.git_dir,
172            format: request.format,
173            commands: &command_states,
174            push_options,
175            use_atomic,
176            use_push_options: request_uses_push_options(request.header),
177            remote_stderr,
178            capture_stderr,
179        })?;
180        command_states = output.commands;
181        apply_proc_receive_hook_failure(&mut command_states, use_atomic, output.hook_failed);
182    }
183
184    if unpack_error.is_none() {
185        if let Err(err) = apply_command_updates(request.git_dir, request.format, &command_states) {
186            let message = err.to_string();
187            for state in &mut command_states {
188                if state.error_string.is_none() && !state.defer_ref_update() {
189                    state.error_string = Some(message.clone());
190                }
191            }
192        }
193    }
194
195    if request.options.run_post_hooks {
196        run_receive_pack_post_hooks(
197            request.git_dir,
198            &command_states,
199            push_options,
200            remote_stderr,
201            capture_stderr,
202        );
203    }
204
205    let report = build_report(&command_states, unpack_error, use_report_v2)?;
206    Ok(ReceivePackServerOutcome {
207        report,
208        command_states,
209    })
210}
211
212pub fn run_receive_pack_post_hooks(
213    git_dir: &Path,
214    command_states: &[ReceivePackCommandState],
215    push_options: &[String],
216    remote_stderr: &mut Vec<u8>,
217    capture_stderr: bool,
218) {
219    let landed: Vec<ReceivePackCommandState> = command_states
220        .iter()
221        .filter(|state| {
222            state.error_string.is_none() && state.command.old_id != state.command.new_id
223        })
224        .cloned()
225        .collect();
226    if landed.is_empty() {
227        return;
228    }
229    let _ = run_post_receive(
230        git_dir,
231        &landed,
232        push_options,
233        remote_stderr,
234        capture_stderr,
235    );
236    let _ = run_post_update(git_dir, &landed, remote_stderr, capture_stderr);
237    let _ = run_push_to_checkout(git_dir, remote_stderr, capture_stderr);
238}
239
240pub fn receive_pack_server_report_v1(report: &ReceivePackServerReport) -> ReceivePackReportStatus {
241    match report {
242        ReceivePackServerReport::V1(status) => status.clone(),
243        ReceivePackServerReport::V2(status) => ReceivePackReportStatus {
244            unpack: status.unpack.clone(),
245            commands: status
246                .commands
247                .iter()
248                .map(|command| match command {
249                    ReceivePackCommandStatusV2::Ok { name, .. } => {
250                        ReceivePackCommandStatus::Ok { name: name.clone() }
251                    }
252                    ReceivePackCommandStatusV2::Ng { name, message } => {
253                        ReceivePackCommandStatus::Ng {
254                            name: name.clone(),
255                            message: message.clone(),
256                        }
257                    }
258                })
259                .collect(),
260        },
261    }
262}
263
264/// Write hook stderr captured during receive-pack as sideband-64k progress packets.
265pub fn write_receive_pack_sideband_stderr(
266    writer: &mut impl Write,
267    stderr: &[u8],
268) -> Result<()> {
269    if stderr.is_empty() {
270        return Ok(());
271    }
272    for chunk in stderr.chunks(SIDEBAND_PAYLOAD_CHUNK) {
273        write_sideband_packet(
274            writer,
275            &SideBandPacket {
276                channel: SideBandChannel::Progress,
277                data: chunk.to_vec(),
278            },
279        )?;
280    }
281    Ok(())
282}
283
284/// Flush a sideband-64k receive-pack response stream (after progress + report packets).
285pub fn flush_receive_pack_sideband(writer: &mut impl Write) -> Result<()> {
286    writer.write_all(b"0000")?;
287    Ok(())
288}
289
290pub fn write_receive_pack_server_report(
291    writer: &mut impl Write,
292    report: &ReceivePackServerReport,
293    use_sideband: bool,
294    flush_stream: bool,
295) -> Result<()> {
296    match report {
297        ReceivePackServerReport::V1(status) => {
298            if use_sideband {
299                write_report_sideband(
300                    writer,
301                    |buf| write_receive_pack_report_status(buf, status),
302                    flush_stream,
303                )
304            } else {
305                write_receive_pack_report_status(writer, status)
306            }
307        }
308        ReceivePackServerReport::V2(status) => {
309            if use_sideband {
310                write_report_sideband(
311                    writer,
312                    |buf| write_receive_pack_report_status_v2(buf, status),
313                    flush_stream,
314                )
315            } else {
316                write_receive_pack_report_status_v2(writer, status)
317            }
318        }
319    }
320}
321
322pub fn request_uses_sideband(header: &ReceivePackPushRequestHeader) -> bool {
323    header
324        .commands
325        .capabilities
326        .iter()
327        .any(|cap| cap.name == "side-band-64k")
328}
329
330/// Maximum sideband payload bytes per packet (git `LARGE_PACKET_MAX - 5`).
331const SIDEBAND_PAYLOAD_CHUNK: usize = 65_515;
332
333fn write_report_sideband(
334    writer: &mut impl Write,
335    write_report: impl FnOnce(&mut Vec<u8>) -> Result<()>,
336    flush_stream: bool,
337) -> Result<()> {
338    let mut payload = Vec::new();
339    write_report(&mut payload)?;
340    for chunk in payload.chunks(SIDEBAND_PAYLOAD_CHUNK) {
341        write_sideband_packet(
342            writer,
343            &SideBandPacket {
344                channel: SideBandChannel::Data,
345                data: chunk.to_vec(),
346            },
347        )?;
348    }
349    if flush_stream {
350        writer.write_all(b"0000")?;
351    }
352    Ok(())
353}
354
355fn request_uses_atomic(header: &ReceivePackPushRequestHeader) -> bool {
356    header
357        .commands
358        .capabilities
359        .iter()
360        .any(|cap| cap.name == "atomic")
361}
362
363fn request_uses_report_status_v2(header: &ReceivePackPushRequestHeader) -> bool {
364    header
365        .commands
366        .capabilities
367        .iter()
368        .any(|cap| cap.name == "report-status-v2")
369}
370
371fn request_uses_push_options(header: &ReceivePackPushRequestHeader) -> bool {
372    header
373        .commands
374        .capabilities
375        .iter()
376        .any(|cap| cap.name == "push-options")
377}
378
379fn receive_pre_hooks_may_run(git_dir: &Path) -> bool {
380    hook_exists(git_dir, "pre-receive") || hook_exists(git_dir, "update")
381}
382
383fn quarantine_hook_env(git_dir: &Path, object_dir: &Path) -> Vec<(String, String)> {
384    let alternate = repository_common_dir(git_dir)
385        .join("objects")
386        .to_string_lossy()
387        .into_owned();
388    let object_dir = object_dir.to_string_lossy().into_owned();
389    vec![
390        ("GIT_OBJECT_DIRECTORY".into(), object_dir.clone()),
391        ("GIT_ALTERNATE_OBJECT_DIRECTORIES".into(), alternate),
392        ("GIT_QUARANTINE_PATH".into(), object_dir),
393    ]
394}
395
396fn needs_pack_data(states: &[ReceivePackCommandState]) -> bool {
397    states
398        .iter()
399        .any(|state| state.error_string.is_none() && !state.command.new_id.is_null())
400}
401
402fn install_pack_from_reader(
403    git_dir: &Path,
404    format: ObjectFormat,
405    reader: &mut dyn Read,
406) -> Result<()> {
407    let mut prefix = [0u8; 4];
408    match reader.read_exact(&mut prefix) {
409        Ok(()) => {}
410        Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()),
411        Err(err) => return Err(err.into()),
412    }
413    if &prefix != b"PACK" {
414        return Err(GitError::InvalidFormat(
415            "receive-pack packfile must start with PACK".into(),
416        ));
417    }
418    let db = FileObjectDatabase::from_git_dir(git_dir, format);
419    let mut stream = Cursor::new(prefix).chain(reader);
420    db.install_raw_pack_from_reader(&mut stream).map(|_| ())
421}
422
423fn apply_command_updates(
424    git_dir: &Path,
425    format: ObjectFormat,
426    states: &[ReceivePackCommandState],
427) -> Result<()> {
428    let applicable: Vec<ReceivePackCommand> = states
429        .iter()
430        .filter(|state| state.error_string.is_none() && !state.defer_ref_update())
431        .map(|state| state.command.clone())
432        .collect();
433    if applicable.is_empty() {
434        return Ok(());
435    }
436
437    let store = FileRefStore::new(git_dir, format);
438    let db = FileObjectDatabase::from_git_dir(git_dir, format);
439
440    for command in applicable.iter().filter(|c| !c.new_id.is_null()) {
441        if !db.contains(&command.new_id)? {
442            return Err(GitError::InvalidObject(format!(
443                "receive-pack packfile did not provide {}",
444                command.new_id
445            )));
446        }
447    }
448
449    for command in applicable.iter().filter(|c| c.new_id.is_null()) {
450        let current = store.read_ref(&command.name)?;
451        if !command.old_id.is_null() {
452            let Some(sley_refs::RefTarget::Direct(oid)) = current else {
453                return Err(GitError::Transaction(format!(
454                    "expected ref {} to match",
455                    command.name
456                )));
457            };
458            if oid != command.old_id {
459                return Err(GitError::Transaction(format!(
460                    "expected ref {} to match",
461                    command.name
462                )));
463            }
464        }
465    }
466
467    let updates: Vec<ReceivePackCommand> = applicable
468        .iter()
469        .filter(|c| !c.new_id.is_null())
470        .cloned()
471        .collect();
472    apply_receive_pack_ref_transaction(git_dir, format, &store, &updates, &applicable)?;
473    Ok(())
474}
475
476fn build_report(
477    command_states: &[ReceivePackCommandState],
478    unpack_error: Option<String>,
479    use_report_v2: bool,
480) -> Result<ReceivePackServerReport> {
481    let unpack = match unpack_error {
482        None => ReceivePackUnpackStatus::Ok,
483        Some(message) => ReceivePackUnpackStatus::Error(message),
484    };
485    if use_report_v2 {
486        let mut commands = Vec::new();
487        for state in command_states {
488            if let Some(message) = &state.error_string {
489                commands.push(ReceivePackCommandStatusV2::Ng {
490                    name: state.command.name.clone(),
491                    message: message.clone(),
492                });
493                continue;
494            }
495            commands.push(ReceivePackCommandStatusV2::Ok {
496                name: state.command.name.clone(),
497                options: ReceivePackCommandStatusV2Options::default(),
498            });
499            for (index, report) in state.reports.iter().enumerate() {
500                if index > 0 {
501                    commands.push(ReceivePackCommandStatusV2::Ok {
502                        name: state.command.name.clone(),
503                        options: ReceivePackCommandStatusV2Options::default(),
504                    });
505                }
506                if let Some(ReceivePackCommandStatusV2::Ok { options, .. }) = commands.last_mut() {
507                    options.refname = report.refname.clone();
508                    options.old_oid = report.old_oid.clone();
509                    options.new_oid = report.new_oid.clone();
510                    options.forced_update = report.forced_update;
511                }
512            }
513        }
514        Ok(ReceivePackServerReport::V2(ReceivePackReportStatusV2 {
515            unpack,
516            commands,
517        }))
518    } else {
519        let commands = command_states
520            .iter()
521            .map(|state| {
522                if let Some(message) = &state.error_string {
523                    ReceivePackCommandStatus::Ng {
524                        name: state.command.name.clone(),
525                        message: message.clone(),
526                    }
527                } else {
528                    ReceivePackCommandStatus::Ok {
529                        name: state.command.name.clone(),
530                    }
531                }
532            })
533            .collect();
534        Ok(ReceivePackServerReport::V1(ReceivePackReportStatus {
535            unpack,
536            commands,
537        }))
538    }
539}