gitway_lib/relay.rs
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3//! Bidirectional stdin/stdout relay over an SSH exec channel (FR-14 through FR-17).
4//!
5//! The relay spawns a background task that copies `tokio::io::stdin()` into the
6//! channel's write half, and runs a read loop on the current task that copies
7//! channel data to `tokio::io::stdout()` and `tokio::io::stderr()`.
8//!
9//! When the remote process exits, its exit code is returned. Exit-via-signal
10//! is translated to `128 + signal_number`, matching OpenSSH convention (FR-17).
11
12use tokio::io::AsyncWriteExt as _;
13
14use russh::client::Msg;
15use russh::{Channel, ChannelMsg, Sig};
16
17use crate::error::GitwayError;
18
19// ── Public entry point ────────────────────────────────────────────────────────
20
21/// Runs a full bidirectional relay between the local process stdio and the
22/// given open SSH channel until the remote command exits.
23///
24/// # Returns
25///
26/// The remote exit code (0–255), or `128 + signal_number` if the remote
27/// process was killed by a signal.
28///
29/// # Errors
30///
31/// Returns an error on SSH protocol failures or local I/O errors.
32pub async fn relay_channel(channel: Channel<Msg>) -> Result<u32, GitwayError> {
33 let (mut read_half, write_half) = channel.split();
34
35 // Spawn the stdin → channel task.
36 // The writer is `'static` (it owns its internal channel sender), so it can
37 // be moved freely into a separate task without borrowing `write_half`.
38 let mut channel_writer = write_half.make_writer();
39 let stdin_task = tokio::spawn(async move {
40 let mut stdin = tokio::io::stdin();
41 // Copy until stdin closes (git shuts its write end when done).
42 tokio::io::copy(&mut stdin, &mut channel_writer).await?;
43 // Signal EOF so the remote side knows no more data is coming.
44 channel_writer.shutdown().await?;
45 Ok::<_, std::io::Error>(())
46 });
47
48 // Main relay loop: channel → local stdout / stderr.
49 let mut stdout = tokio::io::stdout();
50 let mut stderr = tokio::io::stderr();
51 let mut exit_code: Option<u32> = None;
52
53 loop {
54 let Some(msg) = read_half.wait().await else {
55 // Channel closed by server.
56 break;
57 };
58
59 match msg {
60 ChannelMsg::Data { ref data } => {
61 stdout.write_all(data).await?;
62 // Flush immediately — Git reads output line-by-line.
63 stdout.flush().await?;
64 }
65 ChannelMsg::ExtendedData { ref data, ext: 1 } => {
66 // ext == 1 → SSH_EXTENDED_DATA_STDERR
67 stderr.write_all(data).await?;
68 stderr.flush().await?;
69 }
70 ChannelMsg::ExitStatus { exit_status } => {
71 log::debug!("relay: remote process exited with code {exit_status}");
72 exit_code = Some(exit_status);
73 // Do not break here; further Data / Eof messages may follow.
74 }
75 ChannelMsg::ExitSignal {
76 signal_name,
77 core_dumped,
78 ..
79 } => {
80 let sig_num = signal_number(&signal_name);
81 // 128 + signal_number matches OpenSSH convention (FR-17).
82 let code = 128_u32.saturating_add(sig_num);
83 log::debug!(
84 "relay: remote process killed by signal {signal_name:?} \
85 (core_dumped={core_dumped}), exit code {code}"
86 );
87 exit_code = Some(code);
88 }
89 ChannelMsg::Close => break,
90 // Eof, window adjustments, and any other messages are ignored:
91 // keep looping to drain buffered data and await ExitStatus.
92 _ => {}
93 }
94 }
95
96 // Cancel the stdin task — if git already closed its pipe this is a no-op.
97 stdin_task.abort();
98
99 Ok(exit_code.unwrap_or(0))
100}
101
102// ── Signal → number mapping ───────────────────────────────────────────────────
103
104/// Maps a russh [`Sig`] to its POSIX signal number.
105///
106/// Numbers follow POSIX 1003.1; `Custom` signals map to 0.
107fn signal_number(sig: &Sig) -> u32 {
108 // POSIX signal numbers used to compute OpenSSH-compatible exit codes.
109 // A custom/unknown signal maps to 0 (no meaningful number available).
110 match sig {
111 Sig::HUP => 1,
112 Sig::INT => 2,
113 Sig::QUIT => 3,
114 Sig::ILL => 4,
115 Sig::ABRT => 6,
116 Sig::FPE => 8,
117 Sig::KILL => 9,
118 Sig::SEGV => 11,
119 Sig::PIPE => 13,
120 Sig::ALRM => 14,
121 Sig::TERM => 15,
122 Sig::USR1 => 10,
123 Sig::Custom(_) => 0,
124 }
125}