Skip to main content

rust_pipe/validation/
mod.rs

1#[derive(Debug, thiserror::Error)]
2pub enum ValidationError {
3    #[error(
4        "Invalid worker ID '{value}': must be alphanumeric with dots, hyphens, or underscores"
5    )]
6    InvalidWorkerId { value: String },
7    #[error("Invalid Docker image name '{value}'")]
8    InvalidImageName { value: String },
9    #[error("Invalid hostname '{value}'")]
10    InvalidHostname { value: String },
11    #[error("Invalid username '{value}'")]
12    InvalidUsername { value: String },
13    #[error("Dangerous value rejected '{value}': {reason}")]
14    DangerousValue { value: String, reason: String },
15}
16
17fn is_safe_identifier_char(c: char) -> bool {
18    c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_'
19}
20
21pub fn validate_worker_id(id: &str) -> Result<(), ValidationError> {
22    if id.is_empty()
23        || id.len() > 128
24        || !id.starts_with(|c: char| c.is_ascii_alphanumeric())
25        || !id.chars().all(is_safe_identifier_char)
26    {
27        return Err(ValidationError::InvalidWorkerId {
28            value: id.to_string(),
29        });
30    }
31    Ok(())
32}
33
34pub fn validate_docker_image(image: &str) -> Result<(), ValidationError> {
35    if image.is_empty() || image.len() > 256 {
36        return Err(ValidationError::InvalidImageName {
37            value: image.to_string(),
38        });
39    }
40    let valid = image.chars().all(|c| {
41        c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' || c == '/' || c == ':'
42    });
43    if !valid || !image.starts_with(|c: char| c.is_ascii_alphanumeric()) || image.contains("..") {
44        return Err(ValidationError::InvalidImageName {
45            value: image.to_string(),
46        });
47    }
48    Ok(())
49}
50
51pub fn validate_hostname(host: &str) -> Result<(), ValidationError> {
52    if host.is_empty()
53        || host.len() > 253
54        || !host.starts_with(|c: char| c.is_ascii_alphanumeric())
55        || !host
56            .chars()
57            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
58    {
59        return Err(ValidationError::InvalidHostname {
60            value: host.to_string(),
61        });
62    }
63    Ok(())
64}
65
66pub fn validate_username(user: &str) -> Result<(), ValidationError> {
67    if user.is_empty()
68        || user.len() > 64
69        || !user.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
70        || !user
71            .chars()
72            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
73    {
74        return Err(ValidationError::InvalidUsername {
75            value: user.to_string(),
76        });
77    }
78    Ok(())
79}
80
81pub fn validate_no_shell_metacharacters(
82    value: &str,
83    field_name: &str,
84) -> Result<(), ValidationError> {
85    const DANGEROUS: &[char] = &[
86        '`', '$', '(', ')', '{', '}', ';', '|', '&', '<', '>', '\n', '\r', '\0',
87    ];
88    if value.chars().any(|c| DANGEROUS.contains(&c)) {
89        return Err(ValidationError::DangerousValue {
90            value: value.to_string(),
91            reason: format!("'{}' contains shell metacharacters", field_name),
92        });
93    }
94    Ok(())
95}
96
97pub fn validate_file_path(path: &str, field_name: &str) -> Result<(), ValidationError> {
98    validate_no_shell_metacharacters(path, field_name)?;
99    if path.contains("..") {
100        return Err(ValidationError::DangerousValue {
101            value: path.to_string(),
102            reason: format!("'{}' contains path traversal", field_name),
103        });
104    }
105    Ok(())
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn test_valid_worker_ids() {
114        assert!(validate_worker_id("worker-1").is_ok());
115        assert!(validate_worker_id("my.worker_v2").is_ok());
116        assert!(validate_worker_id("abc123").is_ok());
117    }
118
119    #[test]
120    fn test_invalid_worker_ids() {
121        assert!(validate_worker_id("").is_err());
122        assert!(validate_worker_id("-bad").is_err());
123        assert!(validate_worker_id("has space").is_err());
124        assert!(validate_worker_id("has;semicolon").is_err());
125        assert!(validate_worker_id("$(inject)").is_err());
126    }
127
128    #[test]
129    fn test_valid_docker_images() {
130        assert!(validate_docker_image("nginx:latest").is_ok());
131        assert!(validate_docker_image("registry.io/org/image:v1.2.3").is_ok());
132        assert!(validate_docker_image("ubuntu").is_ok());
133    }
134
135    #[test]
136    fn test_invalid_docker_images() {
137        assert!(validate_docker_image("").is_err());
138        assert!(validate_docker_image("--privileged").is_err());
139        assert!(validate_docker_image("img;rm -rf /").is_err());
140        assert!(validate_docker_image("../escape").is_err());
141    }
142
143    #[test]
144    fn test_valid_hostnames() {
145        assert!(validate_hostname("example.com").is_ok());
146        assert!(validate_hostname("10.0.0.1").is_ok());
147        assert!(validate_hostname("my-host.internal").is_ok());
148    }
149
150    #[test]
151    fn test_invalid_hostnames() {
152        assert!(validate_hostname("").is_err());
153        assert!(validate_hostname("-bad.com").is_err());
154        assert!(validate_hostname("host;evil").is_err());
155    }
156
157    #[test]
158    fn test_valid_usernames() {
159        assert!(validate_username("root").is_ok());
160        assert!(validate_username("deploy_user").is_ok());
161        assert!(validate_username("_service").is_ok());
162    }
163
164    #[test]
165    fn test_invalid_usernames() {
166        assert!(validate_username("").is_err());
167        assert!(validate_username("-bad").is_err());
168        assert!(validate_username("user;evil").is_err());
169        assert!(validate_username("$(whoami)").is_err());
170    }
171
172    #[test]
173    fn test_shell_metacharacter_rejection() {
174        assert!(validate_no_shell_metacharacters("safe-value_123", "test").is_ok());
175        assert!(validate_no_shell_metacharacters("$(inject)", "test").is_err());
176        assert!(validate_no_shell_metacharacters("a;b", "test").is_err());
177        assert!(validate_no_shell_metacharacters("a|b", "test").is_err());
178        assert!(validate_no_shell_metacharacters("a`b`", "test").is_err());
179    }
180
181    #[test]
182    fn test_path_traversal_rejection() {
183        assert!(validate_file_path("/home/user/.ssh/id_rsa", "key").is_ok());
184        assert!(validate_file_path("../../etc/passwd", "key").is_err());
185        assert!(validate_file_path("/path/$(cmd)/file", "key").is_err());
186    }
187}