polykit_core/
command_validator.rs

1//! Command validation for security.
2
3use crate::error::{Error, Result};
4
5/// Validates shell commands before execution to prevent injection attacks.
6///
7/// This validator checks for dangerous patterns that could allow command
8/// injection or arbitrary code execution.
9#[derive(Clone)]
10pub struct CommandValidator {
11    allow_shell: bool,
12}
13
14impl Default for CommandValidator {
15    fn default() -> Self {
16        Self { allow_shell: true }
17    }
18}
19
20impl CommandValidator {
21    /// Creates a new command validator.
22    pub fn new() -> Self {
23        Self::default()
24    }
25
26    /// Creates a validator that disallows shell features.
27    pub fn strict() -> Self {
28        Self { allow_shell: false }
29    }
30
31    /// Validates a command string before execution.
32    ///
33    /// # Errors
34    ///
35    /// Returns an error if the command contains dangerous patterns.
36    pub fn validate(&self, command: &str) -> Result<()> {
37        if command.trim().is_empty() {
38            return Err(Error::TaskExecution {
39                package: "unknown".to_string(),
40                task: "unknown".to_string(),
41                message: "Command cannot be empty".to_string(),
42            });
43        }
44
45        if command.len() > 10_000 {
46            return Err(Error::TaskExecution {
47                package: "unknown".to_string(),
48                task: "unknown".to_string(),
49                message: "Command exceeds maximum length of 10,000 characters".to_string(),
50            });
51        }
52
53        if command.contains('\0') {
54            return Err(Error::TaskExecution {
55                package: "unknown".to_string(),
56                task: "unknown".to_string(),
57                message: "Command contains null bytes".to_string(),
58            });
59        }
60
61        if command.contains("\r\n") || command.contains('\n') {
62            let trimmed = command.trim();
63            if trimmed.contains('\n') {
64                return Err(Error::TaskExecution {
65                    package: "unknown".to_string(),
66                    task: "unknown".to_string(),
67                    message: "Command contains embedded newlines".to_string(),
68                });
69            }
70        }
71
72        if !self.allow_shell
73            && (command.contains(';')
74                || command.contains("&&")
75                || command.contains("||")
76                || command.contains('|')
77                || command.contains('`')
78                || command.contains('$'))
79        {
80            return Err(Error::TaskExecution {
81                package: "unknown".to_string(),
82                task: "unknown".to_string(),
83                message: "Command contains shell features that are not allowed in strict mode"
84                    .to_string(),
85            });
86        }
87
88        Ok(())
89    }
90
91    /// Validates an identifier such as a package name or task name.
92    ///
93    /// Identifiers must be alphanumeric with hyphens, underscores, or dots only.
94    pub fn validate_identifier(identifier: &str, context: &str) -> Result<()> {
95        if identifier.is_empty() {
96            return Err(Error::InvalidPackageName(format!(
97                "{} cannot be empty",
98                context
99            )));
100        }
101
102        if identifier.len() > 255 {
103            return Err(Error::InvalidPackageName(format!(
104                "{} exceeds maximum length of 255 characters",
105                context
106            )));
107        }
108
109        if identifier.starts_with('.') || identifier.starts_with('-') {
110            return Err(Error::InvalidPackageName(format!(
111                "{} cannot start with '.' or '-'",
112                context
113            )));
114        }
115
116        if identifier.contains("..") {
117            return Err(Error::InvalidPackageName(format!(
118                "{} cannot contain '..'",
119                context
120            )));
121        }
122
123        if identifier.contains('/') || identifier.contains('\\') {
124            return Err(Error::InvalidPackageName(format!(
125                "{} cannot contain path separators",
126                context
127            )));
128        }
129
130        for ch in identifier.chars() {
131            if !ch.is_alphanumeric() && ch != '-' && ch != '_' && ch != '.' && ch != '@' {
132                return Err(Error::InvalidPackageName(format!(
133                    "{} contains invalid character: '{}'",
134                    context, ch
135                )));
136            }
137        }
138
139        Ok(())
140    }
141}