shell-tunnel 0.21.0

Ultra-lightweight remote shell gateway with a REST/WebSocket API
Documentation
//! Command building and representation.

use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;

/// A command to be executed in a shell session.
#[derive(Debug, Clone)]
pub struct Command {
    /// The command line to execute.
    pub command_line: String,
    /// Working directory override (if any).
    pub working_dir: Option<PathBuf>,
    /// Environment variables to set.
    pub env: HashMap<String, String>,
    /// Maximum execution time.
    pub timeout: Option<Duration>,
    /// Whether to capture output.
    pub capture_output: bool,
    /// Cap on the output the result keeps, in bytes.
    ///
    /// `None` means [`super::executor::DEFAULT_MAX_OUTPUT_BYTES`]. There is no
    /// value meaning "unbounded" — see that constant.
    pub max_output_bytes: Option<u64>,
}

impl Command {
    /// Create a new command with the given command line.
    pub fn new(command_line: impl Into<String>) -> Self {
        Self {
            command_line: command_line.into(),
            working_dir: None,
            env: HashMap::new(),
            timeout: None,
            capture_output: true,
            max_output_bytes: None,
        }
    }

    /// Set the working directory.
    pub fn working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
        self.working_dir = Some(dir.into());
        self
    }

    /// Add an environment variable.
    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.env.insert(key.into(), value.into());
        self
    }

    /// Add multiple environment variables.
    pub fn envs<I, K, V>(mut self, vars: I) -> Self
    where
        I: IntoIterator<Item = (K, V)>,
        K: Into<String>,
        V: Into<String>,
    {
        for (k, v) in vars {
            self.env.insert(k.into(), v.into());
        }
        self
    }

    /// Set the execution timeout.
    pub fn timeout(mut self, duration: Duration) -> Self {
        self.timeout = Some(duration);
        self
    }

    /// Set whether to capture output.
    pub fn capture_output(mut self, capture: bool) -> Self {
        self.capture_output = capture;
        self
    }

    /// Cap the output the result keeps.
    pub fn max_output_bytes(mut self, bytes: u64) -> Self {
        self.max_output_bytes = Some(bytes);
        self
    }

    /// The deadline this command will actually run under.
    ///
    /// [`timeout`](Self::timeout) records what the caller *asked for*; this is
    /// what they get. Absent, it is [`DEFAULT_TIMEOUT`]; present, it is bounded
    /// by [`MIN_TIMEOUT`] and [`MAX_TIMEOUT`] — the range `docs/openapi.json`
    /// has published all along without anything enforcing it.
    ///
    /// **This is deliberately the only place the deadline is computed.** It used
    /// to be worked out twice — once in the blocking core to time the command
    /// out, and once in `execute_async` to decide when a stalled streaming
    /// consumer stops being waited on. Two copies of one rule is a bug waiting
    /// for the first edit that reaches only one of them, and clamping was
    /// exactly such an edit: applied to the first alone, a command would have
    /// been killed at the ceiling while the stream went on being fed to a
    /// consumer for the hours the caller originally named.
    pub fn effective_timeout(&self) -> Duration {
        self.timeout
            .unwrap_or(super::executor::DEFAULT_TIMEOUT)
            .clamp(super::executor::MIN_TIMEOUT, super::executor::MAX_TIMEOUT)
    }
}

impl Default for Command {
    fn default() -> Self {
        Self::new("")
    }
}

/// Builder for creating commands with fluent API.
#[derive(Debug, Default)]
pub struct CommandBuilder {
    command_line: Option<String>,
    working_dir: Option<PathBuf>,
    env: HashMap<String, String>,
    timeout: Option<Duration>,
    capture_output: bool,
    max_output_bytes: Option<u64>,
}

impl CommandBuilder {
    /// Create a new command builder.
    pub fn new() -> Self {
        Self {
            capture_output: true,
            ..Default::default()
        }
    }

    /// Set the command line.
    pub fn command_line(mut self, cmd: impl Into<String>) -> Self {
        self.command_line = Some(cmd.into());
        self
    }

    /// Set the working directory.
    pub fn working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
        self.working_dir = Some(dir.into());
        self
    }

    /// Add an environment variable.
    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.env.insert(key.into(), value.into());
        self
    }

    /// Set the execution timeout.
    pub fn timeout(mut self, duration: Duration) -> Self {
        self.timeout = Some(duration);
        self
    }

    /// Set whether to capture output.
    pub fn capture_output(mut self, capture: bool) -> Self {
        self.capture_output = capture;
        self
    }

    /// Build the command.
    ///
    /// Returns `None` if no command line was specified.
    pub fn build(self) -> Option<Command> {
        self.command_line.map(|cmd| Command {
            command_line: cmd,
            working_dir: self.working_dir,
            env: self.env,
            timeout: self.timeout,
            capture_output: self.capture_output,
            max_output_bytes: self.max_output_bytes,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::execution::{DEFAULT_TIMEOUT, MAX_TIMEOUT, MIN_TIMEOUT};

    /// Asking for nothing gets the default, and the default is inside the range.
    #[test]
    fn an_unset_timeout_is_the_default() {
        assert_eq!(Command::new("echo hi").effective_timeout(), DEFAULT_TIMEOUT);
        assert!(
            DEFAULT_TIMEOUT >= MIN_TIMEOUT && DEFAULT_TIMEOUT <= MAX_TIMEOUT,
            "the default must itself be a value a caller could have asked for"
        );
    }

    /// A value inside the published range is honoured exactly.
    #[test]
    fn a_timeout_within_the_range_is_taken_as_asked() {
        let asked = Duration::from_secs(45);
        assert_eq!(
            Command::new("echo hi").timeout(asked).effective_timeout(),
            asked
        );
    }

    /// Above the ceiling is clamped, not refused — the same shape
    /// `max_output_bytes` uses, and the figure `docs/openapi.json` publishes.
    ///
    /// Nothing enforced this before: `timeout_secs: 999999999` was accepted and
    /// honoured, so one caller could hold a blocking thread for decades while
    /// the published reference said the maximum was 300.
    #[test]
    fn a_timeout_above_the_ceiling_is_clamped() {
        let absurd = Duration::from_secs(999_999_999);
        assert_eq!(
            Command::new("echo hi").timeout(absurd).effective_timeout(),
            MAX_TIMEOUT
        );
    }

    /// Zero is raised to the floor rather than taken literally.
    ///
    /// Taken literally it is a deadline that has already passed, so the control
    /// loop killed every such command on its first pass having run nothing —
    /// while `docs/openapi.json` said `"minimum": 1`.
    #[test]
    fn a_zero_timeout_is_raised_to_the_floor() {
        assert_eq!(
            Command::new("echo hi")
                .timeout(Duration::from_secs(0))
                .effective_timeout(),
            MIN_TIMEOUT
        );
    }

    #[test]
    fn test_command_new() {
        let cmd = Command::new("ls -la");
        assert_eq!(cmd.command_line, "ls -la");
        assert!(cmd.working_dir.is_none());
        assert!(cmd.env.is_empty());
        assert!(cmd.timeout.is_none());
        assert!(cmd.capture_output);
    }

    #[test]
    fn test_command_builder_chain() {
        let cmd = Command::new("cargo build")
            .working_dir("/project")
            .env("RUST_LOG", "debug")
            .timeout(Duration::from_secs(60))
            .capture_output(true);

        assert_eq!(cmd.command_line, "cargo build");
        assert_eq!(cmd.working_dir, Some(PathBuf::from("/project")));
        assert_eq!(cmd.env.get("RUST_LOG"), Some(&"debug".to_string()));
        assert_eq!(cmd.timeout, Some(Duration::from_secs(60)));
    }

    #[test]
    fn test_command_envs() {
        let vars = [("KEY1", "val1"), ("KEY2", "val2")];
        let cmd = Command::new("echo").envs(vars);

        assert_eq!(cmd.env.len(), 2);
        assert_eq!(cmd.env.get("KEY1"), Some(&"val1".to_string()));
        assert_eq!(cmd.env.get("KEY2"), Some(&"val2".to_string()));
    }

    #[test]
    fn test_command_builder_build() {
        let cmd = CommandBuilder::new()
            .command_line("pwd")
            .working_dir("/tmp")
            .build();

        assert!(cmd.is_some());
        let cmd = cmd.unwrap();
        assert_eq!(cmd.command_line, "pwd");
        assert_eq!(cmd.working_dir, Some(PathBuf::from("/tmp")));
    }

    #[test]
    fn test_command_builder_empty() {
        let cmd = CommandBuilder::new().build();
        assert!(cmd.is_none());
    }
}