1use perl_path_security::{WorkspacePathError, validate_workspace_path};
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, PartialEq, thiserror::Error)]
21pub enum SecurityError {
22 #[error("Path traversal attempt detected: {0}")]
24 PathTraversalAttempt(String),
25
26 #[error("Path outside workspace: {0}")]
28 PathOutsideWorkspace(String),
29
30 #[error("Symlink resolves outside workspace: {0}")]
32 SymlinkOutsideWorkspace(String),
33
34 #[error("Invalid path characters detected")]
36 InvalidPathCharacters,
37
38 #[error("Expression cannot contain newlines")]
40 InvalidExpression,
41
42 #[error("Timeout exceeds maximum allowed value: {0}ms")]
44 ExcessiveTimeout(u32),
45}
46
47pub const MAX_TIMEOUT_MS: u32 = 300_000;
49
50pub const DEFAULT_TIMEOUT_MS: u32 = 5_000;
52
53impl From<WorkspacePathError> for SecurityError {
54 fn from(error: WorkspacePathError) -> Self {
55 match error {
56 WorkspacePathError::PathTraversalAttempt(message) => {
57 Self::PathTraversalAttempt(message)
58 }
59 WorkspacePathError::PathOutsideWorkspace(message) => {
60 Self::PathOutsideWorkspace(message)
61 }
62 WorkspacePathError::InvalidPathCharacters => Self::InvalidPathCharacters,
63 }
64 }
65}
66
67pub fn validate_path(path: &Path, workspace_root: &Path) -> Result<PathBuf, SecurityError> {
69 validate_workspace_path(path, workspace_root).map_err(SecurityError::from)
70}
71
72pub fn validate_expression(expression: &str) -> Result<(), SecurityError> {
74 if expression.contains('\n') || expression.contains('\r') {
75 return Err(SecurityError::InvalidExpression);
76 }
77
78 Ok(())
79}
80
81pub fn validate_timeout(timeout_ms: u32) -> Result<u32, SecurityError> {
83 if timeout_ms > MAX_TIMEOUT_MS {
84 return Err(SecurityError::ExcessiveTimeout(timeout_ms));
85 }
86 Ok(timeout_ms.max(1))
87}
88
89pub fn validate_condition(condition: &str) -> Result<(), SecurityError> {
91 validate_expression(condition)
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use anyhow::Result;
98 use std::fs;
99
100 #[test]
101 fn test_validate_path_within_workspace() -> Result<()> {
102 let tempdir = tempfile::tempdir()?;
103 let workspace = tempdir.path();
104
105 let safe_path = PathBuf::from("src/main.pl");
106 let result = validate_path(&safe_path, workspace);
107
108 assert!(result.is_ok(), "Path within workspace should be valid");
109 Ok(())
110 }
111
112 #[test]
113 fn test_validate_path_parent_traversal() -> Result<()> {
114 use perl_tdd_support::must;
115 let tempdir = must(tempfile::tempdir());
116 let workspace = tempdir.path();
117
118 let unsafe_path = PathBuf::from("../../../etc/passwd");
119 let result = validate_path(&unsafe_path, workspace);
120
121 assert!(result.is_err(), "Parent traversal should be rejected");
122
123 match result {
124 Err(SecurityError::PathTraversalAttempt(_))
125 | Err(SecurityError::PathOutsideWorkspace(_)) => {}
126 Err(e) => {
127 return Err(anyhow::anyhow!(
128 "Expected PathTraversalAttempt or PathOutsideWorkspace error, got: {:?}",
129 e
130 ));
131 }
132 Ok(_) => return Err(anyhow::anyhow!("Expected error, got Ok")),
133 }
134 Ok(())
135 }
136
137 #[test]
138 fn test_validate_path_absolute_outside() -> Result<()> {
139 use perl_tdd_support::{must, must_some};
140 let tempdir = must(tempfile::tempdir());
141 let workspace = tempdir.path();
142
143 let tempdir2 = must(tempfile::tempdir());
144 let outside_file = tempdir2.path().join("outside.pl");
145 must(fs::write(&outside_file, "print 'outside';"));
146
147 let result = validate_path(&outside_file, workspace);
148
149 assert!(result.is_err(), "Absolute path outside workspace should be rejected");
150
151 match result {
152 Err(SecurityError::PathOutsideWorkspace(_))
153 | Err(SecurityError::PathTraversalAttempt(_)) => {}
154 Err(e) => {
155 return Err(anyhow::anyhow!(
156 "Expected PathOutsideWorkspace or PathTraversalAttempt error, got: {:?}",
157 e
158 ));
159 }
160 Ok(_) => return Err(anyhow::anyhow!("Expected error, got Ok")),
161 }
162
163 let _ = must_some(outside_file.to_str());
164 Ok(())
165 }
166
167 #[test]
168 fn test_validate_path_with_null_byte() -> Result<()> {
169 let tempdir = tempfile::tempdir()?;
170 let workspace = tempdir.path();
171
172 let invalid_path = PathBuf::from("file\0name.pl");
173 let result = validate_path(&invalid_path, workspace);
174
175 assert!(result.is_err(), "Path with null byte should be rejected");
176 assert!(
177 matches!(result, Err(SecurityError::InvalidPathCharacters)),
178 "Expected InvalidPathCharacters error"
179 );
180 Ok(())
181 }
182
183 #[test]
184 fn test_validate_path_with_control_character() -> Result<()> {
185 let tempdir = tempfile::tempdir()?;
186 let workspace = tempdir.path();
187
188 let invalid_path = PathBuf::from("file\x1fname.pl");
189 let result = validate_path(&invalid_path, workspace);
190
191 assert!(result.is_err(), "Path with control character should be rejected");
192 assert!(
193 matches!(result, Err(SecurityError::InvalidPathCharacters)),
194 "Expected InvalidPathCharacters error"
195 );
196 Ok(())
197 }
198
199 #[test]
200 fn test_validate_path_relative_dotdot_within_workspace() -> Result<()> {
201 let tempdir = tempfile::tempdir()?;
202 let workspace = tempdir.path();
203
204 fs::create_dir_all(workspace.join("subdir"))?;
205 let safe_path = PathBuf::from("subdir/../main.pl");
206 let result = validate_path(&safe_path, workspace);
207
208 assert!(result.is_ok(), "Normalized path within workspace should be valid");
209 Ok(())
210 }
211
212 #[test]
213 fn test_validate_expression_valid() -> Result<()> {
214 validate_expression("$x + 1")?;
215 validate_expression("my_function()")?;
216 validate_expression("$hash{key}")?;
217 Ok(())
218 }
219
220 #[test]
221 fn test_validate_expression_newline() -> Result<()> {
222 let result = validate_expression("1\nprint 'hacked'");
223
224 assert!(result.is_err(), "Expression with newline should be rejected");
225 assert!(
226 matches!(result, Err(SecurityError::InvalidExpression)),
227 "Expected InvalidExpression error"
228 );
229 Ok(())
230 }
231
232 #[test]
233 fn test_validate_expression_carriage_return() {
234 let result = validate_expression("1\rprint 'hacked'");
235 assert!(result.is_err(), "Expression with carriage return should be rejected");
236 assert!(matches!(result, Err(SecurityError::InvalidExpression)));
237 }
238
239 #[test]
240 fn test_validate_timeout_within_bounds() -> Result<()> {
241 assert_eq!(validate_timeout(1000)?, 1000);
242 assert_eq!(validate_timeout(5000)?, 5000);
243 assert_eq!(validate_timeout(100_000)?, 100_000);
244 Ok(())
245 }
246
247 #[test]
248 fn test_validate_timeout_zero() -> Result<()> {
249 assert_eq!(validate_timeout(0)?, 1, "Zero timeout should be clamped to 1ms");
250 Ok(())
251 }
252
253 #[test]
254 fn test_validate_timeout_excessive() {
255 use perl_tdd_support::must_err;
256 let result = validate_timeout(500_000);
257 assert!(result.is_err(), "Excessive timeout should be an error");
258 assert_eq!(must_err(result), SecurityError::ExcessiveTimeout(500_000));
259 assert!(validate_timeout(1_000_000).is_err());
260 }
261
262 #[test]
263 fn test_validate_timeout_boundary_at_max_is_ok() -> Result<()> {
264 assert!(validate_timeout(MAX_TIMEOUT_MS).is_ok());
265 assert_eq!(validate_timeout(MAX_TIMEOUT_MS)?, MAX_TIMEOUT_MS);
266 Ok(())
267 }
268
269 #[test]
270 fn test_validate_timeout_one_over_max_is_error() {
271 use perl_tdd_support::must_err;
272 assert!(validate_timeout(MAX_TIMEOUT_MS + 1).is_err());
273 assert_eq!(
274 must_err(validate_timeout(MAX_TIMEOUT_MS + 1)),
275 SecurityError::ExcessiveTimeout(MAX_TIMEOUT_MS + 1)
276 );
277 }
278
279 #[test]
280 fn test_validate_condition_valid() -> Result<()> {
281 validate_condition("$x > 10")?;
282 validate_condition("defined($var)")?;
283 Ok(())
284 }
285
286 #[test]
287 fn test_validate_condition_newline() -> Result<()> {
288 let result = validate_condition("1\nprint 'pwned'");
289
290 assert!(result.is_err(), "Condition with newline should be rejected");
291 assert!(
292 matches!(result, Err(SecurityError::InvalidExpression)),
293 "Expected InvalidExpression error"
294 );
295 Ok(())
296 }
297
298 #[test]
299 fn test_validate_path_empty_string() -> Result<()> {
300 let tempdir = tempfile::tempdir()?;
301 let workspace = tempdir.path();
302
303 let empty_path = PathBuf::from("");
304 let result = validate_path(&empty_path, workspace);
305
306 assert!(result.is_ok(), "Empty path should resolve to workspace root");
307 Ok(())
308 }
309
310 #[test]
311 fn test_validate_expression_empty_string() -> Result<()> {
312 validate_expression("")?;
313 Ok(())
314 }
315
316 #[test]
317 fn test_validate_timeout_boundary_values() -> Result<()> {
318 assert_eq!(validate_timeout(1)?, 1);
319 assert_eq!(validate_timeout(MAX_TIMEOUT_MS)?, MAX_TIMEOUT_MS);
320 assert!(validate_timeout(MAX_TIMEOUT_MS + 1).is_err());
321 Ok(())
322 }
323
324 #[test]
325 fn test_security_error_display_messages() {
326 let path_error = SecurityError::PathTraversalAttempt("../../../etc/passwd".to_string());
327 assert!(format!("{}", path_error).contains("Path traversal attempt detected"));
328
329 let expr_error = SecurityError::InvalidExpression;
330 assert_eq!(format!("{}", expr_error), "Expression cannot contain newlines");
331
332 let timeout_error = SecurityError::ExcessiveTimeout(500_000);
333 assert!(format!("{}", timeout_error).contains("500000ms"));
334 }
335}