rust_expect/
validation.rs

1//! Command and argument validation.
2//!
3//! This module provides validation functions for commands and arguments
4//! to prevent security issues such as command injection.
5
6use crate::error::{ExpectError, SpawnError};
7
8/// Characters that are potentially dangerous in shell contexts.
9///
10/// These characters can be used for command injection or have special meanings
11/// in shell environments. While the library uses `execve` directly (not through a shell),
12/// validating these helps prevent issues when commands are logged, displayed, or
13/// when users accidentally pass shell-interpreted strings.
14pub const SHELL_METACHARACTERS: &[char] = &[
15    ';', '&', '|', '`', '$', '(', ')', '{', '}', '[', ']', '<', '>', '!', '*', '?', '#', '~', '\\',
16    '"', '\'', '\n', '\r',
17];
18
19/// Validation options for command arguments.
20#[derive(Debug, Clone, Default)]
21pub struct ValidationOptions {
22    /// Whether to reject null bytes.
23    pub reject_null_bytes: bool,
24    /// Whether to reject shell metacharacters.
25    pub reject_shell_metacharacters: bool,
26    /// Whether to reject empty strings.
27    pub reject_empty: bool,
28}
29
30impl ValidationOptions {
31    /// Create strict validation options (rejects null bytes and empty strings).
32    #[must_use]
33    pub const fn strict() -> Self {
34        Self {
35            reject_null_bytes: true,
36            reject_shell_metacharacters: false,
37            reject_empty: true,
38        }
39    }
40
41    /// Create paranoid validation options (rejects all potentially dangerous characters).
42    #[must_use]
43    pub const fn paranoid() -> Self {
44        Self {
45            reject_null_bytes: true,
46            reject_shell_metacharacters: true,
47            reject_empty: true,
48        }
49    }
50
51    /// Create permissive validation options (only rejects null bytes).
52    #[must_use]
53    pub const fn permissive() -> Self {
54        Self {
55            reject_null_bytes: true,
56            reject_shell_metacharacters: false,
57            reject_empty: false,
58        }
59    }
60}
61
62/// Check if a string contains null bytes.
63#[must_use]
64pub fn contains_null_byte(s: &str) -> bool {
65    s.contains('\0')
66}
67
68/// Check if a string contains shell metacharacters.
69#[must_use]
70pub fn contains_shell_metachar(s: &str) -> bool {
71    s.chars().any(|c| SHELL_METACHARACTERS.contains(&c))
72}
73
74/// Find the first shell metacharacter in a string.
75#[must_use]
76pub fn find_shell_metachar(s: &str) -> Option<char> {
77    s.chars().find(|c| SHELL_METACHARACTERS.contains(c))
78}
79
80/// Validate a command string.
81///
82/// # Arguments
83///
84/// * `command` - The command to validate
85/// * `options` - Validation options
86///
87/// # Returns
88///
89/// Returns `Ok(())` if the command is valid, or an error describing why it's invalid.
90pub fn validate_command(command: &str, options: &ValidationOptions) -> crate::error::Result<()> {
91    if options.reject_empty && command.is_empty() {
92        return Err(ExpectError::Spawn(SpawnError::InvalidArgument {
93            kind: "command".to_string(),
94            value: String::new(),
95            reason: "command cannot be empty".to_string(),
96        }));
97    }
98
99    if options.reject_null_bytes && contains_null_byte(command) {
100        return Err(ExpectError::Spawn(SpawnError::InvalidArgument {
101            kind: "command".to_string(),
102            value: command.to_string(),
103            reason: "command contains null byte".to_string(),
104        }));
105    }
106
107    if options.reject_shell_metacharacters
108        && let Some(c) = find_shell_metachar(command)
109    {
110        return Err(ExpectError::Spawn(SpawnError::InvalidArgument {
111            kind: "command".to_string(),
112            value: command.to_string(),
113            reason: format!("command contains shell metacharacter '{c}'"),
114        }));
115    }
116
117    Ok(())
118}
119
120/// Validate a command argument.
121///
122/// # Arguments
123///
124/// * `arg` - The argument to validate
125/// * `options` - Validation options
126///
127/// # Returns
128///
129/// Returns `Ok(())` if the argument is valid, or an error describing why it's invalid.
130pub fn validate_argument(arg: &str, options: &ValidationOptions) -> crate::error::Result<()> {
131    if options.reject_null_bytes && contains_null_byte(arg) {
132        return Err(ExpectError::Spawn(SpawnError::InvalidArgument {
133            kind: "argument".to_string(),
134            value: arg.to_string(),
135            reason: "argument contains null byte".to_string(),
136        }));
137    }
138
139    if options.reject_shell_metacharacters
140        && let Some(c) = find_shell_metachar(arg)
141    {
142        return Err(ExpectError::Spawn(SpawnError::InvalidArgument {
143            kind: "argument".to_string(),
144            value: arg.to_string(),
145            reason: format!("argument contains shell metacharacter '{c}'"),
146        }));
147    }
148
149    Ok(())
150}
151
152/// Validate a command and all its arguments.
153///
154/// # Arguments
155///
156/// * `command` - The command to validate
157/// * `args` - The arguments to validate
158/// * `options` - Validation options
159///
160/// # Returns
161///
162/// Returns `Ok(())` if all inputs are valid, or an error describing the first invalid input.
163pub fn validate_command_with_args<I, S>(
164    command: &str,
165    args: I,
166    options: &ValidationOptions,
167) -> crate::error::Result<()>
168where
169    I: IntoIterator<Item = S>,
170    S: AsRef<str>,
171{
172    validate_command(command, options)?;
173
174    for arg in args {
175        validate_argument(arg.as_ref(), options)?;
176    }
177
178    Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_null_byte_detection() {
187        assert!(contains_null_byte("hello\0world"));
188        assert!(!contains_null_byte("hello world"));
189    }
190
191    #[test]
192    fn test_shell_metachar_detection() {
193        assert!(contains_shell_metachar("echo; rm -rf"));
194        assert!(contains_shell_metachar("$(whoami)"));
195        assert!(contains_shell_metachar("hello | world"));
196        assert!(!contains_shell_metachar("hello_world"));
197        assert!(!contains_shell_metachar("/usr/bin/test"));
198    }
199
200    #[test]
201    fn test_validate_command_null_byte() {
202        let opts = ValidationOptions::strict();
203        assert!(validate_command("test\0cmd", &opts).is_err());
204        assert!(validate_command("test_cmd", &opts).is_ok());
205    }
206
207    #[test]
208    fn test_validate_command_empty() {
209        let strict = ValidationOptions::strict();
210        let permissive = ValidationOptions::permissive();
211
212        assert!(validate_command("", &strict).is_err());
213        assert!(validate_command("", &permissive).is_ok());
214    }
215
216    #[test]
217    fn test_validate_command_metachar() {
218        let paranoid = ValidationOptions::paranoid();
219        let strict = ValidationOptions::strict();
220
221        assert!(validate_command("echo; rm", &paranoid).is_err());
222        assert!(validate_command("echo; rm", &strict).is_ok());
223    }
224
225    #[test]
226    fn test_validate_argument() {
227        let opts = ValidationOptions::strict();
228        assert!(validate_argument("normal_arg", &opts).is_ok());
229        assert!(validate_argument("--flag", &opts).is_ok());
230        assert!(validate_argument("arg\0value", &opts).is_err());
231    }
232
233    #[test]
234    fn test_validate_command_with_args() {
235        let opts = ValidationOptions::strict();
236
237        assert!(validate_command_with_args("/bin/echo", ["hello", "world"], &opts).is_ok());
238        assert!(validate_command_with_args("/bin/echo", ["hello\0world"], &opts).is_err());
239    }
240}