Skip to main content

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}