Skip to main content

standout_pipe/
pipe.rs

1use crate::shell::{run_piped, ShellError};
2use std::time::Duration;
3
4#[derive(Debug, thiserror::Error)]
5pub enum PipeError {
6    #[error("Shell error: {0}")]
7    Shell(#[from] ShellError),
8}
9
10/// A target that can receive piped output
11pub trait PipeTarget: Send + Sync {
12    /// Pipe the input to the target and return the resulting output.
13    ///
14    /// If the target is configured to 'capture', the returned string is the command's stdout.
15    /// If the target is 'passthrough', the returned string is the original input.
16    /// If the target is 'consume', the returned string is empty (or filtered out by caller).
17    fn pipe(&self, input: &str) -> Result<String, PipeError>;
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PipeMode {
22    /// Pipe to command, but ignore its output and return the original input.
23    /// Used for side-effects like logging or clipboard where we still want to see the output.
24    Passthrough,
25    /// Pipe to command and use its output as the new result.
26    /// Used for filters like `jq` or `sort`.
27    Capture,
28    /// Pipe to command and suppress further output.
29    /// Used when the pipe destination is the final consumer (e.g. strict clipboard only).
30    Consume,
31}
32
33/// A simple pipe that executes a shell command with input on stdin.
34///
35/// # Security Warning
36///
37/// The command string is passed directly to the shell (`sh -c` on Unix, `cmd /C` on Windows).
38/// If you construct the command from untrusted input, you risk shell injection attacks.
39///
40/// ```ignore
41/// // DANGEROUS if `user_input` is untrusted:
42/// SimplePipe::new(format!("grep {}", user_input))
43///
44/// // SAFE alternatives:
45/// // 1. Use a fixed command string
46/// SimplePipe::new("grep pattern")
47///
48/// // 2. Validate/sanitize user input before interpolation
49/// let sanitized = sanitize_for_shell(user_input);
50/// SimplePipe::new(format!("grep {}", sanitized))
51/// ```
52pub struct SimplePipe {
53    command: String,
54    mode: PipeMode,
55    timeout: Duration,
56}
57
58impl SimplePipe {
59    /// Create a new pipe that executes the given shell command.
60    ///
61    /// The default mode is [`PipeMode::Passthrough`] with a 30-second timeout.
62    ///
63    /// # Security
64    ///
65    /// See the struct-level documentation for shell injection warnings.
66    pub fn new(command: impl Into<String>) -> Self {
67        Self {
68            command: command.into(),
69            mode: PipeMode::Passthrough,
70            timeout: Duration::from_secs(30),
71        }
72    }
73
74    /// Use the command's stdout as the new output.
75    pub fn capture(mut self) -> Self {
76        self.mode = PipeMode::Capture;
77        self
78    }
79
80    /// Don't print anything to the terminal after piping.
81    pub fn consume(mut self) -> Self {
82        self.mode = PipeMode::Consume;
83        self
84    }
85
86    pub fn with_timeout(mut self, timeout: Duration) -> Self {
87        self.timeout = timeout;
88        self
89    }
90}
91
92impl PipeTarget for SimplePipe {
93    fn pipe(&self, input: &str) -> Result<String, PipeError> {
94        let cmd_output = run_piped(&self.command, input, Some(self.timeout))?;
95
96        match self.mode {
97            PipeMode::Passthrough => Ok(input.to_string()),
98            PipeMode::Capture => Ok(cmd_output),
99            PipeMode::Consume => Ok(String::new()),
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn test_simple_pipe_passthrough() {
110        let pipe = SimplePipe::new(if cfg!(windows) {
111            "findstr foo"
112        } else {
113            "grep foo"
114        });
115        // Passthrough should return ORIGINAL input, but the command is executed.
116        let input = "foo\nbar";
117        let output = pipe.pipe(input).unwrap();
118        assert_eq!(output, "foo\nbar");
119    }
120
121    #[test]
122    fn test_simple_pipe_capture() {
123        let pipe = SimplePipe::new(if cfg!(windows) {
124            "findstr foo"
125        } else {
126            "grep foo"
127        })
128        .capture();
129        let input = "foo\nbar";
130        let output = pipe.pipe(input).unwrap();
131        assert_eq!(output.trim(), "foo");
132    }
133
134    #[test]
135    fn test_simple_pipe_consume() {
136        let pipe = SimplePipe::new(if cfg!(windows) {
137            "findstr foo"
138        } else {
139            "grep foo"
140        })
141        .consume();
142        let input = "foo\nbar";
143        let output = pipe.pipe(input).unwrap();
144        assert_eq!(output, "");
145    }
146}