#![cfg(all(unix, feature = "client"))]
use std::io::{self, Read, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::mpsc::{Receiver, RecvTimeoutError, sync_channel};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::client::{Transport, TransportReader};
use crate::error::{Error, Result};
pub fn valid_hostname(host: &str) -> bool {
if host.is_empty() || host.starts_with('-') {
return false;
}
host.bytes().all(|b| {
b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b':' | b'[' | b']' | b'%')
})
}
pub fn valid_ruser(user: &str) -> bool {
if user.starts_with('-') {
return false;
}
!user.chars().any(|c| {
c.is_whitespace()
|| c.is_control()
|| matches!(
c,
'\'' | '`' | '\\' | '"' | ';' | '&' | '|' | '(' | ')' | '{' | '}' | '<' | '>' | '$'
)
})
}
pub fn expand_tokens_checked(s: &str, host: &str, port: u16, user: &str) -> Result<String> {
if uses_token(s, 'h') && !valid_hostname(host) {
return Err(Error::Config(
"ProxyCommand: hostname contains characters that are not permitted \
in a shell command",
));
}
if uses_token(s, 'r') && !valid_ruser(user) {
return Err(Error::Config(
"ProxyCommand: remote user name contains characters that are not \
permitted in a shell command",
));
}
Ok(expand_tokens(s, host, port, user))
}
fn uses_token(s: &str, tok: char) -> bool {
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '%' && chars.next() == Some(tok) {
return true;
}
}
false
}
pub fn expand_tokens(s: &str, host: &str, port: u16, user: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
match chars.next() {
Some('h') => out.push_str(host),
Some('p') => {
use core::fmt::Write as _;
let _ = write!(out, "{port}");
}
Some('r') => out.push_str(user),
Some('%') => out.push('%'),
Some(other) => {
out.push('%');
out.push(other);
}
None => out.push('%'),
}
}
out
}
pub struct ProcTransport {
child: Child,
stdin: ChildStdin,
stdout: Arc<Mutex<PipeReader>>,
read_timeout: Option<Duration>,
}
impl ProcTransport {
pub fn spawn(command: &str) -> io::Result<Self> {
let mut child = Command::new("/bin/sh")
.arg("-c")
.arg(format!("exec {command}"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()?;
let stdin = child
.stdin
.take()
.ok_or_else(|| io::Error::other("ProxyCommand: child stdin not captured"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| io::Error::other("ProxyCommand: child stdout not captured"))?;
Ok(Self {
child,
stdin,
stdout: Arc::new(Mutex::new(PipeReader::spawn(stdout)?)),
read_timeout: None,
})
}
}
struct PipeReader {
rx: Receiver<io::Result<Vec<u8>>>,
chunk: Vec<u8>,
pos: usize,
eof: bool,
}
const PIPE_READ_AHEAD: usize = 8;
impl PipeReader {
fn spawn(mut stdout: ChildStdout) -> io::Result<Self> {
let (tx, rx) = sync_channel(PIPE_READ_AHEAD);
std::thread::Builder::new()
.name("proxycommand-stdout".into())
.spawn(move || {
let mut buf = vec![0u8; 16 * 1024];
loop {
let r = match stdout.read(&mut buf) {
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
r => r.map(|n| buf[..n].to_vec()),
};
let last = !matches!(&r, Ok(chunk) if !chunk.is_empty());
if tx.send(r).is_err() || last {
return;
}
}
})?;
Ok(Self {
rx,
chunk: Vec::new(),
pos: 0,
eof: false,
})
}
fn read_within(&mut self, buf: &mut [u8], timeout: Option<Duration>) -> io::Result<usize> {
if self.pos == self.chunk.len() {
if self.eof {
return Ok(0);
}
let next = match timeout {
None => self.rx.recv().map_err(|_| RecvTimeoutError::Disconnected),
Some(t) => self.rx.recv_timeout(t),
};
match next {
Ok(Ok(chunk)) if !chunk.is_empty() => {
self.chunk = chunk;
self.pos = 0;
}
Ok(Ok(_)) | Err(RecvTimeoutError::Disconnected) => {
self.eof = true;
return Ok(0);
}
Ok(Err(e)) => {
self.eof = true;
return Err(e);
}
Err(RecvTimeoutError::Timeout) => {
return Err(io::Error::from(io::ErrorKind::WouldBlock));
}
}
}
let n = (self.chunk.len() - self.pos).min(buf.len());
buf[..n].copy_from_slice(&self.chunk[self.pos..self.pos + n]);
self.pos += n;
Ok(n)
}
}
struct ProcReader(Arc<Mutex<PipeReader>>);
impl TransportReader for ProcReader {
fn read_within(&mut self, buf: &mut [u8], timeout: Option<Duration>) -> io::Result<usize> {
lock_pipe(&self.0)?.read_within(buf, timeout)
}
}
fn lock_pipe(p: &Mutex<PipeReader>) -> io::Result<std::sync::MutexGuard<'_, PipeReader>> {
p.lock()
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "ProxyCommand reader poisoned"))
}
impl Read for ProcTransport {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
lock_pipe(&self.stdout)?.read_within(buf, self.read_timeout)
}
}
impl Write for ProcTransport {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.stdin.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.stdin.flush()
}
}
impl Transport for ProcTransport {
fn set_read_timeout(&mut self, t: Option<Duration>) -> io::Result<()> {
self.read_timeout = t;
Ok(())
}
fn split_reader(&self) -> Option<Box<dyn TransportReader>> {
Some(Box::new(ProcReader(self.stdout.clone())))
}
}
impl Drop for ProcTransport {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
#[test]
fn expand_basic_tokens() {
assert_eq!(
expand_tokens("nc %h %p", "example.com", 2222, "alice"),
"nc example.com 2222"
);
assert_eq!(expand_tokens("login=%r", "h", 22, "bob"), "login=bob");
assert_eq!(expand_tokens("100%%done", "h", 22, "u"), "100%done");
}
#[test]
fn valid_hostname_matches_openssh_rules() {
for ok in [
"example.com",
"10.0.0.1",
"[::1]",
"::1",
"fe80::1%eth0",
"host_1",
"a-b.c",
"LOCALHOST",
] {
assert!(valid_hostname(ok), "{ok:?} should be accepted");
}
for bad in [
"",
"`id`",
"$(id)",
"a;b",
"-oProxyCommand=x",
"-",
"a b",
"a|b",
"a&b",
"a'b",
"a\"b",
"a\\b",
"a/b",
"a\nb",
"a\tb",
"hôst",
"a>b",
"a<b",
"a(b)",
] {
assert!(!valid_hostname(bad), "{bad:?} should be rejected");
}
}
#[test]
fn valid_ruser_matches_openssh_rules() {
for ok in ["alice", "bob.smith", "user_1", "u-1", "", "李雷", "a@b"] {
assert!(valid_ruser(ok), "{ok:?} should be accepted");
}
for bad in [
"-bob", "a b", "a;b", "$(id)", "`id`", "a|b", "a&b", "a'b", "a\"b", "a\\b", "a(b",
"a)b", "a<b", "a>b", "a\nb", "a\x1bb",
] {
assert!(!valid_ruser(bad), "{bad:?} should be rejected");
}
}
#[test]
fn expand_tokens_checked_rejects_shell_metacharacters() {
for host in ["`id`", "$(id)", "a;b", "-oProxyCommand=x"] {
let err = expand_tokens_checked("nc %h %p", host, 22, "u")
.err()
.unwrap_or_else(|| panic!("{host:?} must be rejected"));
assert!(matches!(err, Error::Config(_)));
}
for user in ["-x", "a;b", "$(id)"] {
assert!(expand_tokens_checked("ssh -l %r jump", "h", 22, user).is_err());
}
for host in ["example.com", "10.0.0.1", "[::1]", "host_1"] {
assert_eq!(
expand_tokens_checked("nc %h %p", host, 2222, "u").unwrap(),
format!("nc {host} 2222")
);
}
assert_eq!(
expand_tokens_checked("nc proxy 3128", "`id`", 22, "$(id)").unwrap(),
"nc proxy 3128"
);
assert_eq!(
expand_tokens_checked("echo 100%%h", "`id`", 22, "u").unwrap(),
"echo 100%h"
);
}
#[test]
fn expand_unknown_token_passthrough() {
assert_eq!(expand_tokens("a%zb", "h", 22, "u"), "a%zb");
assert_eq!(expand_tokens("end%", "h", 22, "u"), "end%");
}
#[test]
fn spawn_failure_is_strict_error() {
let mut t = ProcTransport::spawn("true").expect("sh -c spawns");
let mut buf = [0u8; 16];
let n = t.read(&mut buf).expect("read after child exit");
assert_eq!(n, 0, "closed child stdout should read as EOF");
}
#[test]
fn round_trip_through_cat() {
let mut t = ProcTransport::spawn("cat").expect("spawn cat");
t.write_all(b"hello pipe").expect("write");
t.flush().expect("flush");
let mut buf = [0u8; 10];
t.read_exact(&mut buf).expect("read echo");
assert_eq!(&buf, b"hello pipe");
}
#[test]
fn read_timeout_ticks_as_wouldblock() {
let mut t = ProcTransport::spawn("sleep 5").expect("spawn sleep");
t.set_read_timeout(Some(Duration::from_millis(50)))
.expect("set timeout");
let mut buf = [0u8; 16];
let start = Instant::now();
let err = t.read(&mut buf).expect_err("should time out");
assert_eq!(err.kind(), io::ErrorKind::WouldBlock);
assert!(
start.elapsed() >= Duration::from_millis(40),
"the read should have waited out the deadline, took {:?}",
start.elapsed()
);
}
#[test]
fn read_timeout_still_delivers_data() {
let mut t = ProcTransport::spawn("sh -c 'printf abc; sleep 5'").expect("spawn");
t.set_read_timeout(Some(Duration::from_millis(500)))
.expect("set timeout");
let mut buf = [0u8; 3];
t.read_exact(&mut buf).expect("read the printf output");
assert_eq!(&buf, b"abc");
}
#[test]
fn blocking_read_after_clearing_timeout() {
let mut t = ProcTransport::spawn("sh -c 'sleep 0.1; printf ok'").expect("spawn");
t.set_read_timeout(None).expect("clear timeout");
let mut buf = [0u8; 2];
t.read_exact(&mut buf)
.expect("blocking read waits for data");
assert_eq!(&buf, b"ok");
}
}