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