use std::collections::HashMap;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use async_trait::async_trait;
use bytes::Bytes;
use futures_core::Stream;
#[cfg(test)]
use tokio::sync::{mpsc, oneshot};
#[cfg(test)]
use tokio_stream::wrappers::ReceiverStream;
use crate::negotiation::{NegotiateRequest, TerminalParamsWire};
pub type BoxFuture<T> = futures::future::BoxFuture<'static, T>;
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum TtyError {
#[error("allocate failed: {message}")]
AllocFailed { message: String },
#[error("wait failed: {message}")]
WaitFailed { message: String },
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("backend-specific: {message}")]
Backend { message: String },
}
#[derive(Debug, Clone)]
pub struct TerminalParams {
pub term: Option<String>,
pub cols: u16,
pub rows: u16,
pub pixel_width: u16,
pub pixel_height: u16,
pub modes: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct TtyParams {
pub terminal: Option<TerminalParams>,
pub cmd: Vec<String>,
pub cwd: Option<PathBuf>,
pub env: HashMap<String, String>,
pub backend_params: serde_json::Map<String, serde_json::Value>,
}
impl From<TerminalParamsWire> for TerminalParams {
fn from(w: TerminalParamsWire) -> Self {
Self {
term: w.term,
cols: w.cols,
rows: w.rows,
pixel_width: w.pixel_width,
pixel_height: w.pixel_height,
modes: w.modes,
}
}
}
impl From<NegotiateRequest> for TtyParams {
fn from(req: NegotiateRequest) -> Self {
Self {
terminal: req.tty.map(TerminalParams::from),
cmd: req.cmd,
cwd: req.cwd,
env: req.env,
backend_params: req.backend_params,
}
}
}
pub struct TtyHandle {
pub stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
pub stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>>,
pub stderr: Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>>,
pub exit_code: BoxFuture<Result<i32, TtyError>>,
pub control: Option<TtyControlHandle>,
}
pub trait TtyControl: Send + Sync {
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16);
fn signal(&self, name: &str);
}
#[derive(Clone)]
pub struct TtyControlHandle(Arc<dyn TtyControl + Send + Sync>);
impl TtyControlHandle {
pub fn new(control: Arc<dyn TtyControl + Send + Sync>) -> Self {
Self(control)
}
pub fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) {
self.0.resize(cols, rows, pixel_width, pixel_height);
}
pub fn signal(&self, name: &str) {
self.0.signal(name);
}
}
#[async_trait]
pub trait TtyBackend: Send + Sync {
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError>;
fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> {
None
}
}
#[cfg(test)]
#[derive(Default)]
pub(crate) struct MockControl {
pub last_resize: std::sync::Mutex<Option<(u16, u16, u16, u16)>>,
pub last_signal: std::sync::Mutex<Option<String>>,
}
#[cfg(test)]
impl TtyControl for MockControl {
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) {
*self.last_resize.lock().expect("resize mutex poisoned") =
Some((cols, rows, pixel_width, pixel_height));
}
fn signal(&self, name: &str) {
*self.last_signal.lock().expect("signal mutex poisoned") = Some(name.to_string());
}
}
#[cfg(test)]
#[derive(Default)]
pub(crate) struct MockBackend {
pub exit_code: Option<i32>,
}
#[cfg(test)]
impl MockBackend {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn with_exit_code(exit_code: i32) -> Self {
Self {
exit_code: Some(exit_code),
}
}
}
#[cfg(test)]
#[async_trait]
impl TtyBackend for MockBackend {
async fn allocate(&self, _params: &TtyParams) -> Result<TtyHandle, TtyError> {
let (_stdout_tx, stdout_rx) = mpsc::channel::<Bytes>(8);
let (_stderr_tx, stderr_rx) = mpsc::channel::<Bytes>(8);
let (stdin_tx, _stdin_rx) = mpsc::channel::<Bytes>(8);
let (exit_tx, exit_rx) = oneshot::channel::<Result<i32, TtyError>>();
let code = self.exit_code.unwrap_or(0);
tokio::spawn(async move {
let _ = exit_tx.send(Ok(code));
});
let stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>> =
Box::pin(ReceiverStream::new(stdout_rx));
let stderr: Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>> =
Some(Box::pin(ReceiverStream::new(stderr_rx)));
let stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin> =
Box::new(MockStdinSink { tx: stdin_tx });
let control = Some(TtyControlHandle::new(Arc::new(MockControl::default())));
let exit_code: BoxFuture<Result<i32, TtyError>> = Box::pin(async move {
exit_rx
.await
.map_err(|_| TtyError::WaitFailed {
message: "exit_code sender dropped".to_string(),
})
.and_then(|r| r)
});
Ok(TtyHandle {
stdin,
stdout,
stderr,
exit_code,
control,
})
}
}
#[cfg(test)]
struct MockStdinSink {
tx: mpsc::Sender<Bytes>,
}
#[cfg(test)]
impl tokio::io::AsyncWrite for MockStdinSink {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<Result<usize, std::io::Error>> {
use std::task::Poll;
match self.tx.try_reserve() {
Ok(permit) => {
permit.send(Bytes::copy_from_slice(buf));
Poll::Ready(Ok(buf.len()))
}
Err(mpsc::error::TrySendError::Full(_)) => Poll::Pending,
Err(mpsc::error::TrySendError::Closed(_)) => Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"stdin channel closed",
))),
}
}
fn poll_flush(
self: Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), std::io::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), std::io::Error>> {
std::task::Poll::Ready(Ok(()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tty_control_handle_clone_delegates_resize_and_signal() {
let control = Arc::new(MockControl::default());
let handle = TtyControlHandle::new(control.clone());
let handle_clone = handle.clone();
handle.resize(80, 24, 0, 0);
handle_clone.signal("HUP");
let resize = control.last_resize.lock().expect("resize mutex poisoned");
assert_eq!(*resize, Some((80, 24, 0, 0)));
let signal = control.last_signal.lock().expect("signal mutex poisoned");
assert_eq!(*signal, Some("HUP".to_string()));
}
#[tokio::test]
async fn mock_backend_allocates_and_exits() {
let backend = MockBackend::with_exit_code(42);
let params = TtyParams {
terminal: None,
cmd: vec!["echo".to_string(), "hi".to_string()],
cwd: None,
env: HashMap::new(),
backend_params: serde_json::Map::new(),
};
let handle = backend.allocate(¶ms).await.expect("allocate");
assert!(handle.stderr.is_some());
assert!(handle.control.is_some());
let code = handle.exit_code.await.expect("exit_code");
assert_eq!(code, 42);
}
#[tokio::test]
async fn mock_backend_resource_id_default_none() {
let backend = MockBackend::new();
let params = TtyParams {
terminal: None,
cmd: vec!["true".to_string()],
cwd: None,
env: HashMap::new(),
backend_params: serde_json::Map::new(),
};
assert!(backend.resource_id(¶ms).is_none());
}
#[test]
fn tty_params_from_negotiate_request_maps_fields() {
let req = NegotiateRequest {
carriage: "raw".to_string(),
backend: "local".to_string(),
tty: Some(TerminalParamsWire {
term: Some("xterm-256color".to_string()),
cols: 80,
rows: 24,
pixel_width: 0,
pixel_height: 0,
modes: serde_json::Value::Null,
}),
cmd: vec!["bash".to_string()],
cwd: Some(PathBuf::from("/tmp")),
env: HashMap::from([("FOO".to_string(), "bar".to_string())]),
backend_params: {
let mut m = serde_json::Map::new();
m.insert(
"container".to_string(),
serde_json::Value::String("abc".to_string()),
);
m
},
};
let params = TtyParams::from(req);
let term = params.terminal.expect("terminal");
assert_eq!(term.term.as_deref(), Some("xterm-256color"));
assert_eq!(term.cols, 80);
assert_eq!(term.rows, 24);
assert_eq!(params.cmd, vec!["bash".to_string()]);
assert_eq!(params.cwd.as_deref(), Some(std::path::Path::new("/tmp")));
assert_eq!(params.env.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(
params
.backend_params
.get("container")
.and_then(|v| v.as_str()),
Some("abc"),
);
}
#[test]
fn tty_params_from_negotiate_request_pipe_mode() {
let req = NegotiateRequest {
carriage: "raw".to_string(),
backend: "local".to_string(),
tty: None,
cmd: vec!["true".to_string()],
cwd: None,
env: HashMap::new(),
backend_params: serde_json::Map::new(),
};
let params = TtyParams::from(req);
assert!(params.terminal.is_none());
}
}