1use std::io::{Cursor, Read, Write};
4use std::path::Path;
5
6use sley_config::GitConfig;
7use sley_core::{GitError, ObjectFormat, Result};
8use sley_odb::{FileObjectDatabase, repository_common_dir};
9use sley_protocol::{
10 ReceivePackCommand, ReceivePackCommandStatus, ReceivePackCommandStatusV2,
11 ReceivePackCommandStatusV2Options, ReceivePackPushRequest, ReceivePackPushRequestHeader,
12 ReceivePackReportStatus, ReceivePackReportStatusV2, ReceivePackUnpackStatus, SideBandChannel,
13 SideBandPacket, validate_receive_pack_push_request_features, write_receive_pack_report_status,
14 write_receive_pack_report_status_v2, write_sideband_packet,
15};
16use sley_refs::FileRefStore;
17
18use crate::local::{apply_receive_pack_ref_transaction, receive_pack_features};
19use crate::proc_receive::{
20 ProcReceiveHookInput, ReceivePackCommandState, apply_proc_receive_hook_failure,
21 mark_proc_receive_commands, parse_proc_receive_refs, run_proc_receive_hook,
22};
23use crate::receive_hooks::{
24 run_post_receive, run_post_update, run_pre_receive, run_push_to_checkout, run_update_hooks,
25};
26
27pub struct ReceivePackServerOptions<'a> {
28 pub quiet: bool,
29 pub remote_stderr: Option<&'a mut Vec<u8>>,
32 pub run_post_hooks: bool,
35}
36
37pub struct ReceivePackServerRequest<'a> {
38 pub git_dir: &'a Path,
39 pub format: ObjectFormat,
40 pub header: &'a ReceivePackPushRequestHeader,
41 pub pack_reader: &'a mut dyn Read,
42 pub config: &'a GitConfig,
43 pub validation: &'a sley_fsck::FsckPolicy,
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 let mut quarantine = None;
93 if needs_pack_data(&command_states) {
94 let incoming = sley_odb::IncomingPackQuarantine::new(request.git_dir, request.format)?;
95 let incoming_db = incoming.database();
96 match install_pack_from_reader(&incoming_db, request.pack_reader) {
97 Ok(installed) => {
98 let roots = command_states
99 .iter()
100 .filter(|state| !state.command.new_id.is_null())
101 .map(|state| state.command.new_id)
102 .collect::<Vec<_>>();
103 let validation = validate_receive_objects(
104 &incoming_db,
105 request.format,
106 &roots,
107 &installed,
108 request.validation,
109 remote_stderr,
110 );
111 if request.validation.enabled {
112 if validation.is_err() {
113 unpack_error = Some("unpacker error".to_string());
114 } else {
115 quarantine = Some(incoming);
116 }
117 } else if validation.is_err() {
118 for state in &mut command_states {
119 if state.error_string.is_none() && !state.command.new_id.is_null() {
120 state.error_string = Some("missing necessary objects".into());
121 }
122 }
123 } else {
124 quarantine = Some(incoming);
125 }
126 }
127 Err(err) => {
128 let message = err.to_string();
129 unpack_error = Some(message.clone());
130 for state in &mut command_states {
131 if state.error_string.is_none() {
132 state.error_string = Some(message.clone());
133 }
134 }
135 }
136 }
137 }
138
139 let ok_commands: Vec<ReceivePackCommand> = command_states
140 .iter()
141 .filter(|state| state.error_string.is_none())
142 .map(|state| state.command.clone())
143 .collect();
144
145 let quarantine_env = quarantine
146 .as_ref()
147 .map(|q| quarantine_hook_env(request.git_dir, q.object_dir()))
148 .unwrap_or_default();
149
150 if unpack_error.is_none() && !ok_commands.is_empty() {
151 if run_pre_receive(
152 request.git_dir,
153 &ok_commands,
154 push_options,
155 &quarantine_env,
156 remote_stderr,
157 capture_stderr,
158 )
159 .is_err()
160 {
161 for state in &mut command_states {
162 if state.error_string.is_none() {
163 state.error_string = Some("pre-receive hook declined".into());
164 }
165 }
166 } else if let Some(name) = run_update_hooks(
167 request.git_dir,
168 &ok_commands,
169 &quarantine_env,
170 remote_stderr,
171 capture_stderr,
172 )? {
173 for state in &mut command_states {
174 if state.error_string.is_none() {
175 if use_atomic {
176 state.error_string = Some("atomic push failure".into());
177 } else if state.command.name == name {
178 state.error_string = Some("hook declined".into());
179 }
180 }
181 }
182 }
183 }
184
185 if unpack_error.is_none() && run_proc_receive {
186 let output = run_proc_receive_hook(ProcReceiveHookInput {
187 git_dir: request.git_dir,
188 format: request.format,
189 commands: &command_states,
190 push_options,
191 use_atomic,
192 use_push_options: request_uses_push_options(request.header),
193 quarantine_env: &quarantine_env,
194 remote_stderr,
195 capture_stderr,
196 })?;
197 command_states = output.commands;
198 apply_proc_receive_hook_failure(&mut command_states, use_atomic, output.hook_failed);
199 }
200
201 let accepts_incoming_objects = command_states
202 .iter()
203 .any(|state| state.error_string.is_none() && !state.command.new_id.is_null());
204 if unpack_error.is_none()
205 && accepts_incoming_objects
206 && let Some(incoming) = quarantine.take()
207 && let Err(err) = incoming.promote()
208 {
209 let message = err.to_string();
210 unpack_error = Some(message.clone());
211 for state in &mut command_states {
212 if state.error_string.is_none() && !state.command.new_id.is_null() {
213 state.error_string = Some(message.clone());
214 }
215 }
216 }
217
218 if unpack_error.is_none()
219 && let Err(err) = apply_command_updates(request.git_dir, request.format, &command_states)
220 {
221 let message = err.to_string();
222 for state in &mut command_states {
223 if state.error_string.is_none() && !state.defer_ref_update() {
224 state.error_string = Some(message.clone());
225 }
226 }
227 }
228
229 if request.options.run_post_hooks {
230 run_receive_pack_post_hooks(
231 request.git_dir,
232 &command_states,
233 push_options,
234 remote_stderr,
235 capture_stderr,
236 );
237 }
238
239 let report = build_report(&command_states, unpack_error, use_report_v2)?;
240 Ok(ReceivePackServerOutcome {
241 report,
242 command_states,
243 })
244}
245
246pub fn run_receive_pack_post_hooks(
247 git_dir: &Path,
248 command_states: &[ReceivePackCommandState],
249 push_options: &[String],
250 remote_stderr: &mut Vec<u8>,
251 capture_stderr: bool,
252) {
253 let landed: Vec<ReceivePackCommandState> = command_states
254 .iter()
255 .filter(|state| {
256 state.error_string.is_none() && state.command.old_id != state.command.new_id
257 })
258 .cloned()
259 .collect();
260 if landed.is_empty() {
261 return;
262 }
263 let _ = run_post_receive(
264 git_dir,
265 &landed,
266 push_options,
267 remote_stderr,
268 capture_stderr,
269 );
270 let _ = run_post_update(git_dir, &landed, remote_stderr, capture_stderr);
271 let _ = run_push_to_checkout(git_dir, remote_stderr, capture_stderr);
272}
273
274pub fn receive_pack_server_report_v1(report: &ReceivePackServerReport) -> ReceivePackReportStatus {
275 match report {
276 ReceivePackServerReport::V1(status) => status.clone(),
277 ReceivePackServerReport::V2(status) => ReceivePackReportStatus {
278 unpack: status.unpack.clone(),
279 commands: status
280 .commands
281 .iter()
282 .map(|command| match command {
283 ReceivePackCommandStatusV2::Ok { name, .. } => {
284 ReceivePackCommandStatus::Ok { name: name.clone() }
285 }
286 ReceivePackCommandStatusV2::Ng { name, message } => {
287 ReceivePackCommandStatus::Ng {
288 name: name.clone(),
289 message: message.clone(),
290 }
291 }
292 })
293 .collect(),
294 },
295 }
296}
297
298pub fn write_receive_pack_sideband_stderr(writer: &mut impl Write, stderr: &[u8]) -> Result<()> {
300 if stderr.is_empty() {
301 return Ok(());
302 }
303 for chunk in stderr.chunks(SIDEBAND_PAYLOAD_CHUNK) {
304 write_sideband_packet(
305 writer,
306 &SideBandPacket {
307 channel: SideBandChannel::Progress,
308 data: chunk.to_vec(),
309 },
310 )?;
311 }
312 Ok(())
313}
314
315pub fn flush_receive_pack_sideband(writer: &mut impl Write) -> Result<()> {
317 writer.write_all(b"0000")?;
318 Ok(())
319}
320
321pub fn write_receive_pack_server_report(
322 writer: &mut impl Write,
323 report: &ReceivePackServerReport,
324 use_sideband: bool,
325 flush_stream: bool,
326) -> Result<()> {
327 match report {
328 ReceivePackServerReport::V1(status) => {
329 if use_sideband {
330 write_report_sideband(
331 writer,
332 |buf| write_receive_pack_report_status(buf, status),
333 flush_stream,
334 )
335 } else {
336 write_receive_pack_report_status(writer, status)
337 }
338 }
339 ReceivePackServerReport::V2(status) => {
340 if use_sideband {
341 write_report_sideband(
342 writer,
343 |buf| write_receive_pack_report_status_v2(buf, status),
344 flush_stream,
345 )
346 } else {
347 write_receive_pack_report_status_v2(writer, status)
348 }
349 }
350 }
351}
352
353pub fn request_uses_sideband(header: &ReceivePackPushRequestHeader) -> bool {
354 header
355 .commands
356 .capabilities
357 .iter()
358 .any(|cap| cap.name == "side-band-64k")
359}
360
361const SIDEBAND_PAYLOAD_CHUNK: usize = 65_515;
363
364fn write_report_sideband(
365 writer: &mut impl Write,
366 write_report: impl FnOnce(&mut Vec<u8>) -> Result<()>,
367 flush_stream: bool,
368) -> Result<()> {
369 let mut payload = Vec::new();
370 write_report(&mut payload)?;
371 for chunk in payload.chunks(SIDEBAND_PAYLOAD_CHUNK) {
372 write_sideband_packet(
373 writer,
374 &SideBandPacket {
375 channel: SideBandChannel::Data,
376 data: chunk.to_vec(),
377 },
378 )?;
379 }
380 if flush_stream {
381 writer.write_all(b"0000")?;
382 }
383 Ok(())
384}
385
386fn request_uses_atomic(header: &ReceivePackPushRequestHeader) -> bool {
387 header
388 .commands
389 .capabilities
390 .iter()
391 .any(|cap| cap.name == "atomic")
392}
393
394fn request_uses_report_status_v2(header: &ReceivePackPushRequestHeader) -> bool {
395 header
396 .commands
397 .capabilities
398 .iter()
399 .any(|cap| cap.name == "report-status-v2")
400}
401
402fn request_uses_push_options(header: &ReceivePackPushRequestHeader) -> bool {
403 header
404 .commands
405 .capabilities
406 .iter()
407 .any(|cap| cap.name == "push-options")
408}
409
410fn quarantine_hook_env(git_dir: &Path, object_dir: &Path) -> Vec<(String, String)> {
411 let alternate = repository_common_dir(git_dir)
412 .join("objects")
413 .to_string_lossy()
414 .into_owned();
415 let object_dir = object_dir.to_string_lossy().into_owned();
416 vec![
417 ("GIT_OBJECT_DIRECTORY".into(), object_dir.clone()),
418 ("GIT_ALTERNATE_OBJECT_DIRECTORIES".into(), alternate),
419 ("GIT_QUARANTINE_PATH".into(), object_dir),
420 ]
421}
422
423fn needs_pack_data(states: &[ReceivePackCommandState]) -> bool {
424 states
425 .iter()
426 .any(|state| state.error_string.is_none() && !state.command.new_id.is_null())
427}
428
429fn install_pack_from_reader(
430 db: &FileObjectDatabase,
431 reader: &mut dyn Read,
432) -> Result<Vec<sley_core::ObjectId>> {
433 let mut prefix = [0u8; 4];
434 match reader.read_exact(&mut prefix) {
435 Ok(()) => {}
436 Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(Vec::new()),
437 Err(err) => return Err(err.into()),
438 }
439 if &prefix != b"PACK" {
440 return Err(GitError::InvalidFormat(
441 "receive-pack packfile must start with PACK".into(),
442 ));
443 }
444 let mut stream = Cursor::new(prefix).chain(reader);
445 db.install_raw_pack_from_reader(&mut stream)
446 .map(|installed| installed.object_ids)
447}
448
449fn validate_receive_objects(
450 db: &FileObjectDatabase,
451 format: ObjectFormat,
452 roots: &[sley_core::ObjectId],
453 installed: &[sley_core::ObjectId],
454 policy: &sley_fsck::FsckPolicy,
455 remote_stderr: &mut Vec<u8>,
456) -> Result<()> {
457 let report = sley_fsck::fsck_objects_with_options(
458 db,
459 format,
460 roots.iter().copied(),
461 installed.iter().copied(),
462 policy.fsck_options(policy.enabled),
463 );
464 for diagnostic in &policy.diagnostics {
465 remote_stderr.extend_from_slice(diagnostic.as_bytes());
466 remote_stderr.push(b'\n');
467 }
468 for issue in &report.issues {
469 remote_stderr.extend_from_slice(issue.message.as_bytes());
470 remote_stderr.push(b'\n');
471 }
472 if report.is_ok() {
473 Ok(())
474 } else {
475 Err(GitError::Exit(1))
476 }
477}
478
479fn apply_command_updates(
480 git_dir: &Path,
481 format: ObjectFormat,
482 states: &[ReceivePackCommandState],
483) -> Result<()> {
484 let applicable: Vec<ReceivePackCommand> = states
485 .iter()
486 .filter(|state| state.error_string.is_none() && !state.defer_ref_update())
487 .map(|state| state.command.clone())
488 .collect();
489 if applicable.is_empty() {
490 return Ok(());
491 }
492
493 let store = FileRefStore::new(git_dir, format);
494 let db = FileObjectDatabase::from_git_dir(git_dir, format);
495
496 for command in applicable.iter().filter(|c| !c.new_id.is_null()) {
497 if !db.contains(&command.new_id)? {
498 return Err(GitError::InvalidObject(format!(
499 "receive-pack packfile did not provide {}",
500 command.new_id
501 )));
502 }
503 }
504
505 for command in applicable.iter().filter(|c| c.new_id.is_null()) {
506 let current = store.read_ref(&command.name)?;
507 if !command.old_id.is_null() {
508 let Some(sley_refs::RefTarget::Direct(oid)) = current else {
509 return Err(GitError::Transaction(format!(
510 "expected ref {} to match",
511 command.name
512 )));
513 };
514 if oid != command.old_id {
515 return Err(GitError::Transaction(format!(
516 "expected ref {} to match",
517 command.name
518 )));
519 }
520 }
521 }
522
523 let updates: Vec<ReceivePackCommand> = applicable
524 .iter()
525 .filter(|c| !c.new_id.is_null())
526 .cloned()
527 .collect();
528 apply_receive_pack_ref_transaction(git_dir, format, &store, &updates, &applicable)?;
529 Ok(())
530}
531
532fn build_report(
533 command_states: &[ReceivePackCommandState],
534 unpack_error: Option<String>,
535 use_report_v2: bool,
536) -> Result<ReceivePackServerReport> {
537 let unpack = match unpack_error {
538 None => ReceivePackUnpackStatus::Ok,
539 Some(message) => ReceivePackUnpackStatus::Error(message),
540 };
541 if use_report_v2 {
542 let mut commands = Vec::new();
543 for state in command_states {
544 if let Some(message) = &state.error_string {
545 commands.push(ReceivePackCommandStatusV2::Ng {
546 name: state.command.name.clone(),
547 message: message.clone(),
548 });
549 continue;
550 }
551 commands.push(ReceivePackCommandStatusV2::Ok {
552 name: state.command.name.clone(),
553 options: ReceivePackCommandStatusV2Options::default(),
554 });
555 for (index, report) in state.reports.iter().enumerate() {
556 if index > 0 {
557 commands.push(ReceivePackCommandStatusV2::Ok {
558 name: state.command.name.clone(),
559 options: ReceivePackCommandStatusV2Options::default(),
560 });
561 }
562 if let Some(ReceivePackCommandStatusV2::Ok { options, .. }) = commands.last_mut() {
563 options.refname = report.refname.clone();
564 options.old_oid = report.old_oid.clone();
565 options.new_oid = report.new_oid.clone();
566 options.forced_update = report.forced_update;
567 }
568 }
569 }
570 Ok(ReceivePackServerReport::V2(ReceivePackReportStatusV2 {
571 unpack,
572 commands,
573 }))
574 } else {
575 let commands = command_states
576 .iter()
577 .map(|state| {
578 if let Some(message) = &state.error_string {
579 ReceivePackCommandStatus::Ng {
580 name: state.command.name.clone(),
581 message: message.clone(),
582 }
583 } else {
584 ReceivePackCommandStatus::Ok {
585 name: state.command.name.clone(),
586 }
587 }
588 })
589 .collect();
590 Ok(ReceivePackServerReport::V1(ReceivePackReportStatus {
591 unpack,
592 commands,
593 }))
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use sley_object::{EncodedObject, ObjectType, Tree};
601 use sley_pack::PackFile;
602 use sley_refs::RefTarget;
603 use std::fs;
604 use std::time::{SystemTime, UNIX_EPOCH};
605
606 #[test]
607 fn receive_quarantine_discards_rejected_and_promotes_accepted_pack() {
608 let format = ObjectFormat::Sha1;
609 let rejected_repo = temp_repo("receive-quarantine-rejected");
610 let tree = EncodedObject::new(ObjectType::Tree, Tree { entries: vec![] }.write());
611 let tree_oid = tree.object_id(format).expect("tree oid");
612 let malformed = EncodedObject::new(
613 ObjectType::Commit,
614 format!(
615 "tree {tree_oid}\nauthor Bugs Bunny 1 +0000\ncommitter Bugs <bugs@example.invalid> 1 +0000\n\nbad\n"
616 )
617 .into_bytes(),
618 );
619 let malformed_oid = malformed.object_id(format).expect("malformed oid");
620 let rejected_pack =
621 PackFile::write_packed(&[&tree, &malformed], format).expect("rejected pack");
622 let strict_config =
623 GitConfig::parse(b"[receive]\n\tfsckObjects = true\n").expect("strict receive config");
624 let strict_policy = sley_fsck::FsckPolicy::from_config(
625 &strict_config,
626 sley_fsck::FsckConfigKind::Receive,
627 format,
628 &rejected_repo,
629 false,
630 )
631 .expect("strict receive policy");
632 let rejected = serve_test_pack(
633 &rejected_repo,
634 format,
635 malformed_oid,
636 rejected_pack.pack,
637 &strict_config,
638 &strict_policy,
639 );
640 assert!(matches!(
641 rejected.report,
642 ReceivePackServerReport::V1(ReceivePackReportStatus {
643 unpack: ReceivePackUnpackStatus::Error(_),
644 ..
645 })
646 ));
647 let rejected_db = FileObjectDatabase::from_git_dir(&rejected_repo, format);
648 assert!(
649 !rejected_db
650 .contains(&malformed_oid)
651 .expect("rejected object lookup")
652 );
653 assert_eq!(
654 FileRefStore::new(&rejected_repo, format)
655 .read_ref("refs/heads/main")
656 .expect("rejected ref lookup"),
657 None
658 );
659
660 let accepted_repo = temp_repo("receive-quarantine-accepted");
661 let valid = EncodedObject::new(
662 ObjectType::Commit,
663 format!(
664 "tree {tree_oid}\nauthor A <a@example.invalid> 1 +0000\ncommitter A <a@example.invalid> 1 +0000\n\nok\n"
665 )
666 .into_bytes(),
667 );
668 let valid_oid = valid.object_id(format).expect("valid oid");
669 let accepted_pack =
670 PackFile::write_packed(&[&tree, &valid], format).expect("accepted pack");
671 let accepted = serve_test_pack(
672 &accepted_repo,
673 format,
674 valid_oid,
675 accepted_pack.pack,
676 &strict_config,
677 &strict_policy,
678 );
679 assert!(matches!(
680 accepted.report,
681 ReceivePackServerReport::V1(ReceivePackReportStatus {
682 unpack: ReceivePackUnpackStatus::Ok,
683 ..
684 })
685 ));
686 let accepted_db = FileObjectDatabase::from_git_dir(&accepted_repo, format);
687 assert!(
688 accepted_db
689 .contains(&valid_oid)
690 .expect("accepted object lookup")
691 );
692 assert_eq!(
693 FileRefStore::new(&accepted_repo, format)
694 .read_ref("refs/heads/main")
695 .expect("accepted ref lookup"),
696 Some(RefTarget::Direct(valid_oid))
697 );
698 }
699
700 fn serve_test_pack(
701 git_dir: &Path,
702 format: ObjectFormat,
703 new_id: sley_core::ObjectId,
704 pack: Vec<u8>,
705 config: &GitConfig,
706 policy: &sley_fsck::FsckPolicy,
707 ) -> ReceivePackServerOutcome {
708 let header = ReceivePackPushRequestHeader {
709 commands: sley_protocol::ReceivePackRequest {
710 commands: vec![ReceivePackCommand {
711 old_id: sley_core::ObjectId::null(format),
712 new_id,
713 name: "refs/heads/main".into(),
714 }],
715 ..Default::default()
716 },
717 push_options: None,
718 };
719 let mut reader = Cursor::new(pack);
720 serve_receive_pack(ReceivePackServerRequest {
721 git_dir,
722 format,
723 header: &header,
724 pack_reader: &mut reader,
725 config,
726 validation: policy,
727 options: ReceivePackServerOptions {
728 quiet: true,
729 remote_stderr: None,
730 run_post_hooks: false,
731 },
732 })
733 .expect("serve receive pack")
734 }
735
736 fn temp_repo(name: &str) -> std::path::PathBuf {
737 let nonce = SystemTime::now()
738 .duration_since(UNIX_EPOCH)
739 .expect("clock")
740 .as_nanos();
741 let path = std::env::temp_dir().join(format!("sley-{name}-{}-{nonce}", std::process::id()));
742 fs::create_dir_all(path.join("objects/pack")).expect("repo object directory");
743 fs::create_dir_all(path.join("refs/heads")).expect("repo refs directory");
744 fs::write(path.join("HEAD"), b"ref: refs/heads/main\n").expect("repo HEAD");
745 path
746 }
747}