rust_pipe/validation/
mod.rs1#[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}