1use std::collections::BTreeMap;
4use std::fs::{File, OpenOptions, TryLockError};
5use std::future::Future;
6use std::io::Write as _;
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::{Duration, Instant};
11
12use anyhow::{Context, Result, anyhow, bail, ensure};
13use serde::{Deserialize, Serialize};
14use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
15use tokio::process::Command;
16use tokio_util::sync::CancellationToken;
17
18use hel::hel_local_git::canonical_repository;
19use hel::hel_targets::CommandSpec;
20
21const BRIDGE_MAGIC: &[u8] = b"HEL-GIT-BRIDGE-2\n";
22const MAX_FRAME: usize = 1024 * 1024;
23const MAX_OPEN: usize = 16 * 1024;
24const BRIDGE_EXIT_GRACE: Duration = Duration::from_secs(5);
27#[cfg(unix)]
34const HANDSHAKE_DEADLINE: Duration = Duration::from_secs(30);
35const EXCHANGE_IDLE_DEADLINE: Duration = Duration::from_secs(300);
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct GitBrokerSpec {
50 pub session_id: String,
51 pub bridge: CommandSpec,
52 pub repositories: BTreeMap<String, PathBuf>,
53 pub ready_path: PathBuf,
54 pub pid_path: PathBuf,
55}
56
57#[derive(Debug, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59struct GitOpen {
60 repository: String,
61 service: String,
62}
63
64impl GitBrokerSpec {
65 pub fn write(&self, path: &Path) -> Result<()> {
66 let parent = path.parent().unwrap_or_else(|| Path::new("."));
67 std::fs::create_dir_all(parent)?;
68 hel::hel_config::atomic_write(path, &serde_json::to_vec_pretty(self)?)
69 }
70
71 pub fn read(path: &Path) -> Result<Self> {
72 let bytes = std::fs::read(path)
73 .with_context(|| format!("read Git broker spec {}", path.display()))?;
74 serde_json::from_slice(&bytes)
75 .with_context(|| format!("parse Git broker spec {}", path.display()))
76 }
77}
78
79enum BrokerLock {
81 Held(Option<i32>),
84 Free,
86}
87
88fn broker_lock(pid_path: &Path) -> BrokerLock {
97 let Ok(file) = OpenOptions::new().read(true).write(true).open(pid_path) else {
98 return BrokerLock::Free;
99 };
100 match file.try_lock() {
101 Ok(()) => {
103 let _ = file.unlock();
104 BrokerLock::Free
105 }
106 Err(TryLockError::WouldBlock) => BrokerLock::Held(
107 std::fs::read_to_string(pid_path)
108 .ok()
109 .and_then(|pid| pid.trim().parse().ok()),
110 ),
111 Err(TryLockError::Error(error)) => {
112 tracing::warn!(
113 path = %pid_path.display(),
114 error = %error,
115 "could not test the Git broker lock; treating the broker as gone"
116 );
117 BrokerLock::Free
118 }
119 }
120}
121
122pub fn broker_is_alive(pid_path: &Path) -> bool {
124 matches!(broker_lock(pid_path), BrokerLock::Held(_))
125}
126
127pub fn running_broker_pid(pid_path: &Path) -> Option<i32> {
132 match broker_lock(pid_path) {
133 BrokerLock::Held(pid) => pid,
134 BrokerLock::Free => None,
135 }
136}
137
138pub(crate) fn claim_broker_pid_file(pid_path: &Path) -> Result<File> {
143 let mut options = OpenOptions::new();
144 options.create(true).read(true).write(true);
145 #[cfg(unix)]
146 {
147 use std::os::unix::fs::OpenOptionsExt;
148 options.mode(0o600);
149 }
150 let mut file = options
151 .open(pid_path)
152 .with_context(|| format!("open Git broker lock {}", pid_path.display()))?;
153 match file.try_lock() {
154 Ok(()) => {}
155 Err(TryLockError::WouldBlock) => bail!(
156 "another local Git broker already owns {}",
157 pid_path.display()
158 ),
159 Err(TryLockError::Error(error)) => {
160 return Err(error)
161 .with_context(|| format!("lock Git broker file {}", pid_path.display()));
162 }
163 }
164 file.set_len(0)?;
165 file.write_all(std::process::id().to_string().as_bytes())?;
166 file.flush()?;
167 Ok(file)
168}
169
170pub async fn run_broker(spec_path: &Path) -> Result<()> {
171 let spec = GitBrokerSpec::read(spec_path)?;
172 let repositories = spec
173 .repositories
174 .iter()
175 .map(|(id, path)| Ok((id.clone(), canonical_repository(path)?)))
176 .collect::<Result<BTreeMap<_, _>>>()?;
177 if let Some(parent) = spec.ready_path.parent() {
178 std::fs::create_dir_all(parent)?;
179 }
180 let pid_file = claim_broker_pid_file(&spec.pid_path)?;
182 let result = run_bridge_process(&spec, repositories).await;
183 for path in [&spec.ready_path, &spec.pid_path] {
184 if let Err(error) = std::fs::remove_file(path)
185 && error.kind() != std::io::ErrorKind::NotFound
186 {
187 tracing::warn!(
188 path = %path.display(),
189 %error,
190 "could not remove Git broker lifecycle marker"
191 );
192 }
193 }
194 drop(pid_file);
195 result
196}
197
198async fn run_bridge_process(
199 spec: &GitBrokerSpec,
200 repositories: BTreeMap<String, PathBuf>,
201) -> Result<()> {
202 let mut child = Command::new(&spec.bridge.program)
203 .args(&spec.bridge.args)
204 .envs(&spec.bridge.env)
205 .stdin(Stdio::piped())
206 .stdout(Stdio::piped())
207 .stderr(Stdio::inherit())
208 .spawn()
209 .with_context(|| format!("start {}", spec.bridge.purpose))?;
210 let mut input = child.stdin.take().context("Git bridge stdin is missing")?;
211 let mut output = child
212 .stdout
213 .take()
214 .context("Git bridge stdout is missing")?;
215 let mut magic = vec![0; BRIDGE_MAGIC.len()];
216 output
217 .read_exact(&mut magic)
218 .await
219 .context("read Git bridge greeting")?;
220 ensure!(magic == BRIDGE_MAGIC, "target Git bridge version mismatch");
221 hel::hel_config::atomic_write(&spec.ready_path, b"ready\n")?;
222
223 let outcome = serve_bridge(
224 &mut input,
225 &mut output,
226 &repositories,
227 &spec.session_id,
228 EXCHANGE_IDLE_DEADLINE,
229 )
230 .await;
231 drop(input);
234 drop(output);
235 let status = match tokio::time::timeout(BRIDGE_EXIT_GRACE, child.wait()).await {
236 Ok(status) => Some(status.context("wait for target Git bridge")?),
237 Err(_) => {
238 if let Err(error) = child.kill().await
239 && error.kind() != std::io::ErrorKind::NotFound
240 {
241 tracing::warn!(
242 session_id = %spec.session_id,
243 %error,
244 "could not terminate a timed-out target Git bridge"
245 );
246 }
247 None
248 }
249 };
250 outcome?;
251 let status = status.context("target Git bridge did not exit after its stream closed")?;
252 ensure!(status.success(), "target Git bridge exited with {status}");
253 Ok(())
254}
255
256#[must_use]
263enum Exchange {
264 Completed,
265 Failed(anyhow::Error),
266}
267
268struct ExchangeWatch {
275 idle: Duration,
276 start: Instant,
277 progress: AtomicU64,
279 abort: CancellationToken,
280}
281
282impl ExchangeWatch {
283 fn new(idle: Duration) -> Self {
284 Self {
285 idle,
286 start: Instant::now(),
287 progress: AtomicU64::new(0),
288 abort: CancellationToken::new(),
289 }
290 }
291
292 fn mark(&self) {
294 let elapsed = self.start.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
295 self.progress.store(elapsed, Ordering::Relaxed);
296 }
297
298 async fn stalled(&self) {
300 loop {
301 let progress = self.progress.load(Ordering::Relaxed);
302 let deadline = self.start + Duration::from_millis(progress) + self.idle;
303 tokio::time::sleep_until(deadline.into()).await;
304 if self.progress.load(Ordering::Relaxed) == progress {
305 return;
306 }
307 }
308 }
309
310 fn abort(&self) {
314 self.mark();
315 self.abort.cancel();
316 }
317
318 fn is_aborted(&self) -> bool {
319 self.abort.is_cancelled()
320 }
321
322 async fn aborted(&self) {
324 self.abort.cancelled().await;
325 }
326
327 fn stall(&self) -> anyhow::Error {
328 anyhow!(
329 "a bridged Git exchange moved nothing for {} seconds",
330 self.idle.as_secs_f64()
331 )
332 }
333}
334
335async fn watch_exchange<T>(
348 watch: &ExchangeWatch,
349 transfer: impl Future<Output = Result<T>>,
350) -> Result<(T, Option<anyhow::Error>)> {
351 tokio::pin!(transfer);
352 tokio::select! {
353 result = &mut transfer => Ok((result?, None)),
354 () = watch.stalled() => {
355 watch.abort();
356 tokio::select! {
357 result = &mut transfer => Ok((result?, Some(watch.stall()))),
358 () = watch.stalled() => Err(watch
359 .stall()
360 .context("a stalled Git bridge exchange was never ended by its peer")),
361 }
362 }
363 }
364}
365
366async fn serve_bridge(
368 input: &mut (impl AsyncWrite + Unpin),
369 output: &mut (impl AsyncRead + Unpin),
370 repositories: &BTreeMap<String, PathBuf>,
371 session_id: &str,
372 idle: Duration,
373) -> Result<()> {
374 loop {
375 let open = match read_frame(output, MAX_OPEN).await? {
376 Frame::Data(open) => open,
377 Frame::Closed => return Ok(()),
379 Frame::End => {
380 tracing::warn!(
381 session_id,
382 "target Git bridge ended an exchange that was not open"
383 );
384 continue;
385 }
386 };
387 match serve_exchange(input, output, repositories, &open, idle).await? {
388 Exchange::Completed => {}
389 Exchange::Failed(error) => tracing::warn!(
390 session_id,
391 error = format!("{error:#}"),
392 "bridged Git exchange failed"
393 ),
394 }
395 }
396}
397
398async fn serve_exchange(
399 input: &mut (impl AsyncWrite + Unpin),
400 output: &mut (impl AsyncRead + Unpin),
401 repositories: &BTreeMap<String, PathBuf>,
402 open: &[u8],
403 idle: Duration,
404) -> Result<Exchange> {
405 let request: GitOpen = match serde_json::from_slice(open) {
406 Ok(request) => request,
407 Err(error) => {
408 let error = anyhow!(error).context("decode Git bridge request");
409 return refuse_exchange(input, output, error).await;
410 }
411 };
412 let Some(repository) = repositories.get(&request.repository) else {
413 let error = anyhow!(
414 "Git bridge requested unknown repository {:?}",
415 request.repository
416 );
417 return refuse_exchange(input, output, error).await;
418 };
419 let command = match git_service(&request.service) {
420 Ok(command) => command,
421 Err(error) => return refuse_exchange(input, output, error).await,
422 };
423 serve_git(input, output, repository, command, idle).await
424}
425
426fn git_service(service: &str) -> Result<&'static str> {
427 match service {
428 "git-upload-pack" => Ok("upload-pack"),
429 "git-receive-pack" => Ok("receive-pack"),
430 _ => bail!("unsupported Git service {service:?}"),
431 }
432}
433
434async fn refuse_exchange(
437 input: &mut (impl AsyncWrite + Unpin),
438 output: &mut (impl AsyncRead + Unpin),
439 error: anyhow::Error,
440) -> Result<Exchange> {
441 write_frame(input, &[]).await?;
442 drain_exchange(output).await?;
443 Ok(Exchange::Failed(error))
444}
445
446async fn drain_exchange(output: &mut (impl AsyncRead + Unpin)) -> Result<()> {
448 loop {
449 match read_frame(output, MAX_FRAME).await? {
450 Frame::Data(_) => {}
451 Frame::End => return Ok(()),
452 Frame::Closed => bail!("the target Git bridge closed during an exchange"),
453 }
454 }
455}
456
457async fn serve_git<W, R>(
458 bridge_input: &mut W,
459 bridge_output: &mut R,
460 repository: &Path,
461 command: &str,
462 idle: Duration,
463) -> Result<Exchange>
464where
465 W: AsyncWrite + Unpin,
466 R: AsyncRead + Unpin,
467{
468 let mut git = Command::new("git");
469 git.args([
470 "-c",
471 "core.hooksPath=/dev/null",
472 "-c",
473 "receive.denyCurrentBranch=updateInstead",
474 "-c",
475 "receive.denyNonFastForwards=true",
476 "-c",
477 "receive.denyDeletes=true",
478 command,
479 ]);
480 git.arg(repository)
481 .stdin(Stdio::piped())
482 .stdout(Stdio::piped())
483 .stderr(Stdio::inherit());
484 let mut git = match git.spawn().with_context(|| format!("start git {command}")) {
485 Ok(git) => git,
486 Err(error) => return refuse_exchange(bridge_input, bridge_output, error).await,
487 };
488 let (Some(mut git_input), Some(mut git_output)) = (git.stdin.take(), git.stdout.take()) else {
489 if let Err(kill_error) = git.kill().await {
490 tracing::warn!(%kill_error, "could not stop Git service with missing pipes");
491 }
492 let error = anyhow!("git {command} was started without both pipes");
493 return refuse_exchange(bridge_input, bridge_output, error).await;
494 };
495
496 let watch = ExchangeWatch::new(idle);
497 let watch = &watch;
498 let to_target = async {
499 let mut buffer = vec![0; 64 * 1024];
500 let mut failure = None;
501 loop {
502 let count = tokio::select! {
503 biased;
504 () = watch.aborted() => break,
507 result = git_output.read(&mut buffer) => match result {
510 Ok(0) => break,
511 Ok(count) => count,
512 Err(error) => {
513 failure = Some(anyhow!(error).context(format!("read git {command} output")));
514 break;
515 }
516 },
517 };
518 watch.mark();
519 write_frame(bridge_input, &buffer[..count]).await?;
520 }
521 write_frame(bridge_input, &[]).await?;
524 Ok::<_, anyhow::Error>(failure)
525 };
526 let from_target = async move {
527 loop {
528 match read_frame(bridge_output, MAX_FRAME).await? {
529 Frame::Data(frame) => {
535 watch.mark();
536 if !watch.is_aborted() {
537 tokio::select! {
538 biased;
539 () = watch.aborted() => {}
540 result = git_input.write_all(&frame) => {
541 let _ = result;
542 }
543 }
544 }
545 }
546 Frame::End => break,
547 Frame::Closed => bail!("the target Git bridge closed mid-exchange"),
548 }
549 }
550 drop(git_input);
554 Ok::<_, anyhow::Error>(())
555 };
556 let ((service_failure, ()), stalled) =
559 watch_exchange(watch, async { tokio::try_join!(to_target, from_target) }).await?;
560 if let Some(stalled) = stalled {
561 if let Err(kill_error) = git.kill().await {
564 tracing::warn!(
565 service = command,
566 %kill_error,
567 "could not stop stalled Git service"
568 );
569 }
570 return Ok(Exchange::Failed(
571 stalled.context(format!("git {command} stalled")),
572 ));
573 }
574 if let Some(failure) = service_failure {
575 if let Err(kill_error) = git.kill().await {
576 tracing::warn!(
577 service = command,
578 %kill_error,
579 "could not stop failed Git service"
580 );
581 }
582 return Ok(Exchange::Failed(failure));
583 }
584 match git.wait().await {
585 Ok(status) if status.success() => Ok(Exchange::Completed),
586 Ok(status) => Ok(Exchange::Failed(anyhow!(
587 "git {command} exited with {status}"
588 ))),
589 Err(error) => Ok(Exchange::Failed(
590 anyhow!(error).context("wait for Git service"),
591 )),
592 }
593}
594
595async fn write_frame(writer: &mut (impl AsyncWrite + Unpin), data: &[u8]) -> Result<()> {
596 ensure!(data.len() <= MAX_FRAME, "Git bridge frame is too large");
597 writer.write_u32(data.len() as u32).await?;
598 writer.write_all(data).await?;
599 writer.flush().await?;
600 Ok(())
601}
602
603enum Frame {
605 Data(Vec<u8>),
607 End,
609 Closed,
611}
612
613async fn read_frame(reader: &mut (impl AsyncRead + Unpin), maximum: usize) -> Result<Frame> {
614 let length = match reader.read_u32().await {
615 Ok(length) => length as usize,
616 Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
617 return Ok(Frame::Closed);
618 }
619 Err(error) => return Err(error.into()),
620 };
621 if length == 0 {
622 return Ok(Frame::End);
623 }
624 ensure!(length <= maximum, "Git bridge frame is too large");
625 let mut data = vec![0; length];
626 reader.read_exact(&mut data).await?;
627 Ok(Frame::Data(data))
628}
629
630#[cfg(unix)]
631pub async fn run_worker_bridge(root: &Path) -> Result<()> {
632 run_worker_bridge_over(
633 root,
634 tokio::io::stdin(),
635 tokio::io::stdout(),
636 HANDSHAKE_DEADLINE,
637 EXCHANGE_IDLE_DEADLINE,
638 )
639 .await
640}
641
642#[cfg(unix)]
643async fn run_worker_bridge_over(
644 root: &Path,
645 mut broker_input: impl AsyncRead + Unpin,
646 mut broker_output: impl AsyncWrite + Unpin,
647 handshake: Duration,
648 idle: Duration,
649) -> Result<()> {
650 use tokio::net::UnixListener;
651
652 std::fs::create_dir_all(root)?;
653 let socket = root.join("git.sock");
654 if socket.exists() {
655 std::fs::remove_file(&socket)
656 .with_context(|| format!("remove stale Git proxy socket {}", socket.display()))?;
657 }
658 let listener = UnixListener::bind(&socket)
659 .with_context(|| format!("bind Git proxy socket {}", socket.display()))?;
660 use std::os::unix::fs::PermissionsExt;
661 std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o600))?;
662 broker_output.write_all(BRIDGE_MAGIC).await?;
663 broker_output.flush().await?;
664 loop {
665 let stream = tokio::select! {
666 accepted = listener.accept() => accepted.context("accept Git proxy client")?.0,
667 frame = read_frame(&mut broker_input, MAX_FRAME) => match frame? {
671 Frame::Closed => return Ok(()),
672 _ => bail!("Git broker sent a frame between exchanges"),
673 },
674 };
675 match serve_client(
676 stream,
677 &mut broker_input,
678 &mut broker_output,
679 handshake,
680 idle,
681 )
682 .await?
683 {
684 Exchange::Completed => {}
685 Exchange::Failed(error) => {
686 tracing::warn!(error = format!("{error:#}"), "bridged Git client failed")
687 }
688 }
689 }
690}
691
692#[cfg(unix)]
694async fn serve_client(
695 stream: tokio::net::UnixStream,
696 broker_input: &mut (impl AsyncRead + Unpin),
697 broker_output: &mut (impl AsyncWrite + Unpin),
698 handshake: Duration,
699 idle: Duration,
700) -> Result<Exchange> {
701 let (mut read, mut write) = stream.into_split();
702 let open = match tokio::time::timeout(handshake, read_handshake(&mut read)).await {
705 Ok(Ok(open)) => open,
706 Ok(Err(error)) => return Ok(Exchange::Failed(error)),
707 Err(_) => {
708 return Ok(Exchange::Failed(anyhow!(
709 "a Git proxy client sent no handshake within {} seconds",
710 handshake.as_secs_f64()
711 )));
712 }
713 };
714 write_frame(broker_output, &open).await?;
715
716 let watch = ExchangeWatch::new(idle);
717 let watch = &watch;
718 let served = tokio::sync::Notify::new();
719 let to_broker = async {
720 let mut buffer = vec![0; 64 * 1024];
721 let mut failure = None;
722 loop {
723 let count = tokio::select! {
724 biased;
725 () = served.notified() => break,
728 () = watch.aborted() => break,
731 result = read.read(&mut buffer) => match result {
734 Ok(0) => break,
735 Ok(count) => count,
736 Err(error) => {
737 failure = Some(anyhow!(error).context("read Git proxy client"));
738 break;
739 }
740 },
741 };
742 watch.mark();
743 write_frame(broker_output, &buffer[..count]).await?;
744 }
745 write_frame(broker_output, &[]).await?;
747 Ok::<_, anyhow::Error>(failure)
748 };
749 let from_broker = async {
750 loop {
751 match read_frame(broker_input, MAX_FRAME).await? {
752 Frame::Data(frame) => {
758 watch.mark();
759 if !watch.is_aborted() {
760 tokio::select! {
761 biased;
762 () = watch.aborted() => {}
763 result = write.write_all(&frame) => {
764 let _ = result;
765 }
766 }
767 }
768 }
769 Frame::End => break,
770 Frame::Closed => bail!("the Git broker closed the bridge"),
771 }
772 }
773 if let Err(error) = write.shutdown().await {
774 tracing::debug!(%error, "Git proxy client closed before its response was flushed");
775 }
776 served.notify_one();
777 Ok::<_, anyhow::Error>(())
778 };
779 let (client_failure, stalled) = watch_exchange(watch, async {
780 let (failure, ()) = tokio::try_join!(to_broker, from_broker)?;
781 Ok(failure)
782 })
783 .await?;
784 Ok(match stalled.or(client_failure) {
785 Some(error) => Exchange::Failed(error),
786 None => Exchange::Completed,
787 })
788}
789
790#[cfg(unix)]
791pub async fn run_worker_proxy(root: &Path, repository: &str, service: &str) -> Result<()> {
792 use tokio::net::UnixStream;
793
794 hel::hel_config::validate_id("repository", repository)?;
795 ensure!(
796 matches!(service, "git-upload-pack" | "git-receive-pack"),
797 "unsupported Git service"
798 );
799 let mut socket = UnixStream::connect(root.join("git.sock"))
800 .await
801 .with_context(|| format!("connect Git bridge at {}", root.display()))?;
802 let open = serde_json::to_vec(&GitOpen {
803 repository: repository.into(),
804 service: service.into(),
805 })?;
806 socket.write_all(&open).await?;
807 socket.write_all(b"\n").await?;
808 let (mut socket_read, mut socket_write) = socket.into_split();
809 let mut stdin = tokio::io::stdin();
810 let mut stdout = tokio::io::stdout();
811 let from_git = async {
812 tokio::io::copy(&mut stdin, &mut socket_write).await?;
813 socket_write.shutdown().await
814 };
815 let to_git = async {
816 tokio::io::copy(&mut socket_read, &mut stdout).await?;
817 stdout.shutdown().await
818 };
819 tokio::pin!(from_git);
820 tokio::pin!(to_git);
821 tokio::select! {
822 result = &mut to_git => {
823 result?;
824 }
825 result = &mut from_git => {
826 result?;
827 to_git.await?;
828 }
829 }
830 Ok(())
831}
832
833#[cfg(unix)]
834async fn read_handshake(reader: &mut (impl AsyncRead + Unpin)) -> Result<Vec<u8>> {
835 let mut data = Vec::new();
836 loop {
837 ensure!(data.len() < MAX_OPEN, "Git proxy handshake is too large");
838 let byte = reader.read_u8().await?;
839 if byte == b'\n' {
840 break;
841 }
842 data.push(byte);
843 }
844 Ok(data)
845}
846
847#[cfg(not(unix))]
848pub async fn run_worker_bridge(_root: &Path) -> Result<()> {
849 bail!("Git proxy workers require Unix")
850}
851
852#[cfg(not(unix))]
853pub async fn run_worker_proxy(_root: &Path, _repository: &str, _service: &str) -> Result<()> {
854 bail!("Git proxy workers require Unix")
855}
856
857#[cfg(test)]
858mod tests {
859 use super::*;
860
861 async fn read_exchange(reader: &mut (impl AsyncRead + Unpin)) -> Vec<u8> {
863 let mut received = Vec::new();
864 loop {
865 match read_frame(reader, MAX_FRAME).await.unwrap() {
866 Frame::Data(frame) => received.extend_from_slice(&frame),
867 Frame::End => return received,
868 Frame::Closed => panic!("the bridge stream closed mid-exchange"),
869 }
870 }
871 }
872
873 async fn read_data_frame(reader: &mut (impl AsyncRead + Unpin)) -> Vec<u8> {
874 match read_frame(reader, MAX_FRAME).await.unwrap() {
875 Frame::Data(frame) => frame,
876 Frame::End => panic!("expected a data frame, not the end of an exchange"),
877 Frame::Closed => panic!("expected a data frame, not a closed stream"),
878 }
879 }
880
881 #[cfg(unix)]
882 async fn write_all_framed(writer: &mut (impl AsyncWrite + Unpin), data: &[u8]) {
883 for chunk in data.chunks(64 * 1024) {
884 write_frame(writer, chunk).await.unwrap();
885 }
886 write_frame(writer, &[]).await.unwrap();
887 }
888
889 #[cfg(unix)]
891 fn handshake_line(repository: &str) -> Vec<u8> {
892 let mut line = open_frame(repository);
893 line.push(b'\n');
894 line
895 }
896
897 fn open_frame(repository: &str) -> Vec<u8> {
898 serde_json::to_vec(&GitOpen {
899 repository: repository.into(),
900 service: "git-upload-pack".into(),
901 })
902 .unwrap()
903 }
904
905 fn git(directory: &Path, args: &[&str]) {
906 let status = std::process::Command::new("git")
907 .args(args)
908 .current_dir(directory)
909 .status()
910 .unwrap();
911 assert!(status.success(), "git {args:?} failed");
912 }
913
914 fn repository_with_large_advertisement(root: &Path) -> PathBuf {
917 let repository = root.join("main");
918 std::fs::create_dir_all(&repository).unwrap();
919 git(&repository, &["init", "-q", "-b", "main"]);
920 git(&repository, &["config", "user.name", "Hel Test"]);
921 git(&repository, &["config", "user.email", "hel@example.test"]);
922 std::fs::write(repository.join("tracked"), "content").unwrap();
923 git(&repository, &["add", "."]);
924 git(&repository, &["commit", "-qm", "base"]);
925 let head = std::process::Command::new("git")
926 .args(["rev-parse", "HEAD"])
927 .current_dir(&repository)
928 .output()
929 .unwrap();
930 let head = String::from_utf8(head.stdout).unwrap().trim().to_owned();
931 let mut packed = String::from("# pack-refs with: peeled fully-peeled sorted \n");
932 packed.push_str(&format!("{head} refs/heads/main\n"));
933 for index in 0..3000 {
934 packed.push_str(&format!("{head} refs/tags/advertised-{index:04}\n"));
935 }
936 std::fs::write(repository.join(".git/packed-refs"), packed).unwrap();
937 repository
938 }
939
940 #[tokio::test]
944 async fn the_broker_serves_later_exchanges_after_one_fails_mid_transfer() {
945 let directory = tempfile::tempdir().unwrap();
946 let repository = repository_with_large_advertisement(directory.path());
947 let not_a_repository = directory.path().join("not-a-repository");
948 std::fs::create_dir_all(¬_a_repository).unwrap();
949 let repositories = BTreeMap::from([
950 ("main".to_owned(), repository),
951 ("broken".to_owned(), not_a_repository),
952 ]);
953
954 let (broker_end, target_end) = tokio::io::duplex(16 * 1024);
955 let (mut broker_read, mut broker_write) = tokio::io::split(broker_end);
956 let (mut target_read, mut target_write) = tokio::io::split(target_end);
957 let serving = tokio::spawn(async move {
958 serve_bridge(
959 &mut broker_write,
960 &mut broker_read,
961 &repositories,
962 "test",
963 EXCHANGE_IDLE_DEADLINE,
964 )
965 .await
966 });
967
968 write_frame(&mut target_write, &open_frame("absent"))
970 .await
971 .unwrap();
972 write_frame(&mut target_write, &[]).await.unwrap();
973 assert!(read_exchange(&mut target_read).await.is_empty());
974
975 write_frame(&mut target_write, &open_frame("broken"))
977 .await
978 .unwrap();
979 write_frame(&mut target_write, &[]).await.unwrap();
980 assert!(read_exchange(&mut target_read).await.is_empty());
981
982 write_frame(&mut target_write, &open_frame("main"))
985 .await
986 .unwrap();
987 let mut abandoned = read_data_frame(&mut target_read).await.len();
988 while abandoned <= 64 * 1024 {
989 abandoned += read_data_frame(&mut target_read).await.len();
990 }
991 write_frame(&mut target_write, &[]).await.unwrap();
992 let remainder = read_exchange(&mut target_read).await;
993 assert!(abandoned + remainder.len() > 64 * 1024);
994
995 write_frame(&mut target_write, &open_frame("main"))
997 .await
998 .unwrap();
999 write_frame(&mut target_write, b"0000").await.unwrap();
1001 write_frame(&mut target_write, &[]).await.unwrap();
1002 let advertisement = read_exchange(&mut target_read).await;
1003 assert!(
1004 advertisement.len() > 64 * 1024,
1005 "advertisement was {} bytes",
1006 advertisement.len()
1007 );
1008 assert!(String::from_utf8_lossy(&advertisement).contains("refs/heads/main"));
1009
1010 drop((target_read, target_write));
1012 serving.await.unwrap().unwrap();
1013 }
1014
1015 #[cfg(unix)]
1017 #[tokio::test]
1018 async fn the_worker_bridge_serves_the_next_client_after_one_dies_mid_transfer() {
1019 let directory = tempfile::tempdir().unwrap();
1020 let root = directory.path().to_path_buf();
1021 let (worker_end, broker_end) = tokio::io::duplex(16 * 1024);
1022 let (worker_read, worker_write) = tokio::io::split(worker_end);
1023 let (mut broker_read, mut broker_write) = tokio::io::split(broker_end);
1024 let serving = tokio::spawn(async move {
1025 run_worker_bridge_over(
1026 &root,
1027 worker_read,
1028 worker_write,
1029 HANDSHAKE_DEADLINE,
1030 EXCHANGE_IDLE_DEADLINE,
1031 )
1032 .await
1033 });
1034
1035 let mut magic = vec![0; BRIDGE_MAGIC.len()];
1036 broker_read.read_exact(&mut magic).await.unwrap();
1037 assert_eq!(magic, BRIDGE_MAGIC);
1038 let socket = directory.path().join("git.sock");
1039 let request = vec![b'q'; 256 * 1024];
1040 let reply = vec![b'r'; 256 * 1024];
1041
1042 let mut client = tokio::net::UnixStream::connect(&socket).await.unwrap();
1045 client
1046 .write_all(b"{\"repository\":\"main\",\"service\":\"git-upload-pack\"}\n")
1047 .await
1048 .unwrap();
1049 let open = read_data_frame(&mut broker_read).await;
1050 assert!(String::from_utf8_lossy(&open).contains("git-upload-pack"));
1051 let abandoning = tokio::spawn({
1052 let request = request.clone();
1053 async move {
1054 let _ = client.write_all(&request).await;
1055 drop(client);
1056 }
1057 });
1058 let abandoned = read_exchange(&mut broker_read).await;
1059 abandoning.await.unwrap();
1060 assert!(!abandoned.is_empty());
1061 write_all_framed(&mut broker_write, &reply).await;
1062
1063 let (mut client_read, mut client_write) = tokio::net::UnixStream::connect(&socket)
1065 .await
1066 .unwrap()
1067 .into_split();
1068 client_write
1069 .write_all(b"{\"repository\":\"main\",\"service\":\"git-upload-pack\"}\n")
1070 .await
1071 .unwrap();
1072 let open = read_data_frame(&mut broker_read).await;
1073 assert!(String::from_utf8_lossy(&open).contains("main"));
1074 let sending = tokio::spawn(async move {
1075 client_write.write_all(&request).await.unwrap();
1076 client_write.shutdown().await.unwrap();
1077 });
1078 let receiving = tokio::spawn(async move {
1079 let mut received = Vec::new();
1080 client_read.read_to_end(&mut received).await.unwrap();
1081 received
1082 });
1083 let received_request = read_exchange(&mut broker_read).await;
1084 sending.await.unwrap();
1085 write_all_framed(&mut broker_write, &reply).await;
1086 let received_reply = receiving.await.unwrap();
1087
1088 assert_eq!(received_request.len(), 256 * 1024);
1089 assert_eq!(received_reply.len(), 256 * 1024);
1090
1091 drop((broker_read, broker_write));
1094 serving.await.unwrap().unwrap();
1095 }
1096
1097 #[cfg(unix)]
1100 #[tokio::test]
1101 async fn a_client_that_never_finishes_its_handshake_is_timed_out() {
1102 let directory = tempfile::tempdir().unwrap();
1103 let root = directory.path().to_path_buf();
1104 let handshake = Duration::from_millis(500);
1105 let (worker_end, broker_end) = tokio::io::duplex(16 * 1024);
1106 let (worker_read, worker_write) = tokio::io::split(worker_end);
1107 let (mut broker_read, mut broker_write) = tokio::io::split(broker_end);
1108 let serving = tokio::spawn(async move {
1109 run_worker_bridge_over(
1110 &root,
1111 worker_read,
1112 worker_write,
1113 handshake,
1114 EXCHANGE_IDLE_DEADLINE,
1115 )
1116 .await
1117 });
1118 let mut magic = vec![0; BRIDGE_MAGIC.len()];
1119 broker_read.read_exact(&mut magic).await.unwrap();
1120 let socket = directory.path().join("git.sock");
1121
1122 let mut silent = tokio::net::UnixStream::connect(&socket).await.unwrap();
1124 let line = handshake_line("silent");
1125 silent.write_all(&line[..line.len() - 1]).await.unwrap();
1126
1127 let (mut client_read, mut client_write) = tokio::net::UnixStream::connect(&socket)
1130 .await
1131 .unwrap()
1132 .into_split();
1133 client_write
1134 .write_all(&handshake_line("served"))
1135 .await
1136 .unwrap();
1137 let open = tokio::time::timeout(Duration::from_secs(30), read_data_frame(&mut broker_read))
1138 .await
1139 .expect("a silent client held the bridge");
1140 assert!(
1141 String::from_utf8_lossy(&open).contains("served"),
1142 "first framed request was {}",
1143 String::from_utf8_lossy(&open)
1144 );
1145
1146 let mut byte = [0u8; 1];
1148 assert_eq!(
1149 tokio::time::timeout(Duration::from_secs(30), silent.read(&mut byte))
1150 .await
1151 .expect("a timed-out client was left connected")
1152 .unwrap(),
1153 0
1154 );
1155
1156 let reply = vec![b'r'; 256 * 1024];
1160 let receiving = tokio::spawn(async move {
1161 let mut received = Vec::new();
1162 client_read.read_to_end(&mut received).await.unwrap();
1163 received
1164 });
1165 write_all_framed(&mut broker_write, &reply).await;
1166 assert!(read_exchange(&mut broker_read).await.is_empty());
1167 assert_eq!(receiving.await.unwrap().len(), 256 * 1024);
1168 drop(client_write);
1169
1170 drop((broker_read, broker_write));
1171 serving.await.unwrap().unwrap();
1172 }
1173
1174 #[cfg(unix)]
1177 #[tokio::test]
1178 async fn a_client_that_stalls_mid_transfer_loses_only_its_own_exchange() {
1179 let directory = tempfile::tempdir().unwrap();
1180 let root = directory.path().to_path_buf();
1181 let idle = Duration::from_millis(500);
1182 let (worker_end, broker_end) = tokio::io::duplex(16 * 1024);
1183 let (worker_read, worker_write) = tokio::io::split(worker_end);
1184 let (mut broker_read, mut broker_write) = tokio::io::split(broker_end);
1185 let serving = tokio::spawn(async move {
1186 run_worker_bridge_over(&root, worker_read, worker_write, HANDSHAKE_DEADLINE, idle).await
1187 });
1188 let mut magic = vec![0; BRIDGE_MAGIC.len()];
1189 broker_read.read_exact(&mut magic).await.unwrap();
1190 let socket = directory.path().join("git.sock");
1191 let request = vec![b'q'; 256 * 1024];
1192 let reply = vec![b'r'; 256 * 1024];
1193
1194 let mut wedged = tokio::net::UnixStream::connect(&socket).await.unwrap();
1197 wedged.write_all(&handshake_line("wedged")).await.unwrap();
1198 let open = read_data_frame(&mut broker_read).await;
1199 assert!(String::from_utf8_lossy(&open).contains("wedged"));
1200 let started = Instant::now();
1201 let draining = tokio::spawn(async move {
1202 let received =
1203 tokio::time::timeout(Duration::from_secs(30), read_exchange(&mut broker_read))
1204 .await
1205 .expect("a wedged client held the bridge");
1206 (broker_read, received)
1207 });
1208 wedged.write_all(&request).await.unwrap();
1209 let (mut broker_read, received) = draining.await.unwrap();
1210 assert_eq!(received.len(), 256 * 1024);
1211 assert!(
1212 started.elapsed() >= idle,
1213 "the exchange ended after {:?}, before its idle window",
1214 started.elapsed()
1215 );
1216
1217 write_all_framed(&mut broker_write, &reply).await;
1220 let mut byte = [0u8; 1];
1221 assert_eq!(
1222 tokio::time::timeout(Duration::from_secs(30), wedged.read(&mut byte))
1223 .await
1224 .expect("an abandoned client was left connected")
1225 .unwrap(),
1226 0
1227 );
1228
1229 let (mut client_read, mut client_write) = tokio::net::UnixStream::connect(&socket)
1231 .await
1232 .unwrap()
1233 .into_split();
1234 client_write
1235 .write_all(&handshake_line("main"))
1236 .await
1237 .unwrap();
1238 let open = read_data_frame(&mut broker_read).await;
1239 assert!(String::from_utf8_lossy(&open).contains("main"));
1240 let sending = tokio::spawn(async move {
1241 client_write.write_all(&request).await.unwrap();
1242 client_write.shutdown().await.unwrap();
1243 });
1244 let receiving = tokio::spawn(async move {
1245 let mut received = Vec::new();
1246 client_read.read_to_end(&mut received).await.unwrap();
1247 received
1248 });
1249 let received_request = read_exchange(&mut broker_read).await;
1250 sending.await.unwrap();
1251 write_all_framed(&mut broker_write, &reply).await;
1252 assert_eq!(received_request.len(), 256 * 1024);
1253 assert_eq!(receiving.await.unwrap().len(), 256 * 1024);
1254
1255 drop((broker_read, broker_write));
1256 serving.await.unwrap().unwrap();
1257 }
1258
1259 #[tokio::test]
1261 async fn the_broker_gives_up_on_a_service_that_stops_moving() {
1262 let directory = tempfile::tempdir().unwrap();
1263 let repository = repository_with_large_advertisement(directory.path());
1264 let repositories = BTreeMap::from([("main".to_owned(), repository)]);
1265 let idle = Duration::from_secs(1);
1266 let (broker_end, target_end) = tokio::io::duplex(16 * 1024);
1267 let (mut broker_read, mut broker_write) = tokio::io::split(broker_end);
1268 let (mut target_read, mut target_write) = tokio::io::split(target_end);
1269 let serving = tokio::spawn(async move {
1270 serve_bridge(
1271 &mut broker_write,
1272 &mut broker_read,
1273 &repositories,
1274 "test",
1275 idle,
1276 )
1277 .await
1278 });
1279
1280 write_frame(&mut target_write, &open_frame("main"))
1283 .await
1284 .unwrap();
1285 let started = Instant::now();
1286 let advertisement =
1287 tokio::time::timeout(Duration::from_secs(60), read_exchange(&mut target_read))
1288 .await
1289 .expect("a stalled Git service held the broker");
1290 assert!(
1291 advertisement.len() > 64 * 1024,
1292 "advertisement was {} bytes",
1293 advertisement.len()
1294 );
1295 assert!(
1296 started.elapsed() >= idle,
1297 "the exchange ended after {:?}, before its idle window",
1298 started.elapsed()
1299 );
1300 write_frame(&mut target_write, &[]).await.unwrap();
1302
1303 write_frame(&mut target_write, &open_frame("main"))
1305 .await
1306 .unwrap();
1307 write_frame(&mut target_write, b"0000").await.unwrap();
1308 write_frame(&mut target_write, &[]).await.unwrap();
1309 let advertisement = read_exchange(&mut target_read).await;
1310 assert!(advertisement.len() > 64 * 1024);
1311 assert!(String::from_utf8_lossy(&advertisement).contains("refs/heads/main"));
1312
1313 drop((target_read, target_write));
1314 serving.await.unwrap().unwrap();
1315 }
1316
1317 #[test]
1320 fn broker_liveness_follows_the_lock_and_not_the_written_pid() {
1321 let directory = tempfile::tempdir().unwrap();
1322 let pid_path = directory.path().join("session.pid");
1323
1324 assert!(!broker_is_alive(&pid_path));
1325 assert_eq!(running_broker_pid(&pid_path), None);
1326
1327 std::fs::write(&pid_path, std::process::id().to_string()).unwrap();
1330 assert!(!broker_is_alive(&pid_path));
1331 assert_eq!(running_broker_pid(&pid_path), None);
1332
1333 let claimed = claim_broker_pid_file(&pid_path).unwrap();
1334 assert!(broker_is_alive(&pid_path));
1335 assert_eq!(
1336 running_broker_pid(&pid_path),
1337 Some(std::process::id() as i32)
1338 );
1339 assert!(claim_broker_pid_file(&pid_path).is_err());
1340 let written = std::fs::read_to_string(&pid_path).unwrap();
1341 assert_eq!(written.trim(), std::process::id().to_string());
1342
1343 drop(claimed);
1344 let released = std::time::Instant::now();
1351 while broker_is_alive(&pid_path) {
1352 assert!(
1353 released.elapsed() < Duration::from_secs(5),
1354 "a released broker lock was never observed free"
1355 );
1356 std::thread::sleep(Duration::from_millis(1));
1357 }
1358 assert_eq!(running_broker_pid(&pid_path), None);
1359 }
1360}