Skip to main content

perl_dap_security/
lib.rs

1//! Security validation module for DAP Phase 3 (AC16)
2//!
3//! This crate provides enterprise-grade security features:
4//! - Path traversal prevention
5//! - Input validation for expressions and conditions
6//! - Resource limits enforcement
7//! - Secure defaults
8//!
9//! # Safety Guarantees
10//!
11//! - All file paths are validated against workspace boundaries
12//! - Expressions cannot contain newlines (protocol injection prevention)
13//! - Timeouts are capped at reasonable limits
14//! - Dangerous operations are blocked in safe evaluation mode
15
16use perl_path_security::{WorkspacePathError, validate_workspace_path};
17use std::path::{Path, PathBuf};
18
19/// Security validation errors
20#[derive(Debug, PartialEq, thiserror::Error)]
21pub enum SecurityError {
22    /// Path traversal attempt detected
23    #[error("Path traversal attempt detected: {0}")]
24    PathTraversalAttempt(String),
25
26    /// Path outside workspace boundary
27    #[error("Path outside workspace: {0}")]
28    PathOutsideWorkspace(String),
29
30    /// Symlink resolves outside workspace
31    #[error("Symlink resolves outside workspace: {0}")]
32    SymlinkOutsideWorkspace(String),
33
34    /// Invalid path characters (null bytes, control characters)
35    #[error("Invalid path characters detected")]
36    InvalidPathCharacters,
37
38    /// Expression contains newlines (protocol injection risk)
39    #[error("Expression cannot contain newlines")]
40    InvalidExpression,
41
42    /// Timeout exceeds maximum allowed value
43    #[error("Timeout exceeds maximum allowed value: {0}ms")]
44    ExcessiveTimeout(u32),
45}
46
47/// Maximum allowed timeout in milliseconds (5 minutes)
48pub const MAX_TIMEOUT_MS: u32 = 300_000;
49
50/// Default timeout in milliseconds (5 seconds)
51pub 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
67/// Validate that a path is within the workspace boundary
68pub fn validate_path(path: &Path, workspace_root: &Path) -> Result<PathBuf, SecurityError> {
69    validate_workspace_path(path, workspace_root).map_err(SecurityError::from)
70}
71
72/// Validate an expression for safe evaluation
73pub 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
81/// Validate a timeout value, returning an error if it exceeds the maximum allowed.
82pub 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
89/// Validate a breakpoint condition for security issues
90pub 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}