use perl_path_security::{WorkspacePathError, validate_workspace_path};
use std::path::{Path, PathBuf};
#[derive(Debug, PartialEq, thiserror::Error)]
pub enum SecurityError {
#[error("Path traversal attempt detected: {0}")]
PathTraversalAttempt(String),
#[error("Path outside workspace: {0}")]
PathOutsideWorkspace(String),
#[error("Symlink resolves outside workspace: {0}")]
SymlinkOutsideWorkspace(String),
#[error("Invalid path characters detected")]
InvalidPathCharacters,
#[error("Expression cannot contain newlines")]
InvalidExpression,
#[error("Timeout exceeds maximum allowed value: {0}ms")]
ExcessiveTimeout(u32),
}
pub const MAX_TIMEOUT_MS: u32 = 300_000;
pub const DEFAULT_TIMEOUT_MS: u32 = 5_000;
impl From<WorkspacePathError> for SecurityError {
fn from(error: WorkspacePathError) -> Self {
match error {
WorkspacePathError::PathTraversalAttempt(message) => {
Self::PathTraversalAttempt(message)
}
WorkspacePathError::PathOutsideWorkspace(message) => {
Self::PathOutsideWorkspace(message)
}
WorkspacePathError::InvalidPathCharacters => Self::InvalidPathCharacters,
}
}
}
pub fn validate_path(path: &Path, workspace_root: &Path) -> Result<PathBuf, SecurityError> {
validate_workspace_path(path, workspace_root).map_err(SecurityError::from)
}
pub fn validate_expression(expression: &str) -> Result<(), SecurityError> {
if expression.contains('\n') || expression.contains('\r') {
return Err(SecurityError::InvalidExpression);
}
Ok(())
}
pub fn validate_timeout(timeout_ms: u32) -> Result<u32, SecurityError> {
if timeout_ms > MAX_TIMEOUT_MS {
return Err(SecurityError::ExcessiveTimeout(timeout_ms));
}
Ok(timeout_ms.max(1))
}
pub fn validate_condition(condition: &str) -> Result<(), SecurityError> {
validate_expression(condition)
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use std::fs;
#[test]
fn test_validate_path_within_workspace() -> Result<()> {
let tempdir = tempfile::tempdir()?;
let workspace = tempdir.path();
let safe_path = PathBuf::from("src/main.pl");
let result = validate_path(&safe_path, workspace);
assert!(result.is_ok(), "Path within workspace should be valid");
Ok(())
}
#[test]
fn test_validate_path_parent_traversal() -> Result<()> {
use perl_tdd_support::must;
let tempdir = must(tempfile::tempdir());
let workspace = tempdir.path();
let unsafe_path = PathBuf::from("../../../etc/passwd");
let result = validate_path(&unsafe_path, workspace);
assert!(result.is_err(), "Parent traversal should be rejected");
match result {
Err(SecurityError::PathTraversalAttempt(_))
| Err(SecurityError::PathOutsideWorkspace(_)) => {}
Err(e) => {
return Err(anyhow::anyhow!(
"Expected PathTraversalAttempt or PathOutsideWorkspace error, got: {:?}",
e
));
}
Ok(_) => return Err(anyhow::anyhow!("Expected error, got Ok")),
}
Ok(())
}
#[test]
fn test_validate_path_absolute_outside() -> Result<()> {
use perl_tdd_support::{must, must_some};
let tempdir = must(tempfile::tempdir());
let workspace = tempdir.path();
let tempdir2 = must(tempfile::tempdir());
let outside_file = tempdir2.path().join("outside.pl");
must(fs::write(&outside_file, "print 'outside';"));
let result = validate_path(&outside_file, workspace);
assert!(result.is_err(), "Absolute path outside workspace should be rejected");
match result {
Err(SecurityError::PathOutsideWorkspace(_))
| Err(SecurityError::PathTraversalAttempt(_)) => {}
Err(e) => {
return Err(anyhow::anyhow!(
"Expected PathOutsideWorkspace or PathTraversalAttempt error, got: {:?}",
e
));
}
Ok(_) => return Err(anyhow::anyhow!("Expected error, got Ok")),
}
let _ = must_some(outside_file.to_str());
Ok(())
}
#[test]
fn test_validate_path_with_null_byte() -> Result<()> {
let tempdir = tempfile::tempdir()?;
let workspace = tempdir.path();
let invalid_path = PathBuf::from("file\0name.pl");
let result = validate_path(&invalid_path, workspace);
assert!(result.is_err(), "Path with null byte should be rejected");
assert!(
matches!(result, Err(SecurityError::InvalidPathCharacters)),
"Expected InvalidPathCharacters error"
);
Ok(())
}
#[test]
fn test_validate_path_with_control_character() -> Result<()> {
let tempdir = tempfile::tempdir()?;
let workspace = tempdir.path();
let invalid_path = PathBuf::from("file\x1fname.pl");
let result = validate_path(&invalid_path, workspace);
assert!(result.is_err(), "Path with control character should be rejected");
assert!(
matches!(result, Err(SecurityError::InvalidPathCharacters)),
"Expected InvalidPathCharacters error"
);
Ok(())
}
#[test]
fn test_validate_path_relative_dotdot_within_workspace() -> Result<()> {
let tempdir = tempfile::tempdir()?;
let workspace = tempdir.path();
fs::create_dir_all(workspace.join("subdir"))?;
let safe_path = PathBuf::from("subdir/../main.pl");
let result = validate_path(&safe_path, workspace);
assert!(result.is_ok(), "Normalized path within workspace should be valid");
Ok(())
}
#[test]
fn test_validate_expression_valid() -> Result<()> {
validate_expression("$x + 1")?;
validate_expression("my_function()")?;
validate_expression("$hash{key}")?;
Ok(())
}
#[test]
fn test_validate_expression_newline() -> Result<()> {
let result = validate_expression("1\nprint 'hacked'");
assert!(result.is_err(), "Expression with newline should be rejected");
assert!(
matches!(result, Err(SecurityError::InvalidExpression)),
"Expected InvalidExpression error"
);
Ok(())
}
#[test]
fn test_validate_expression_carriage_return() {
let result = validate_expression("1\rprint 'hacked'");
assert!(result.is_err(), "Expression with carriage return should be rejected");
assert!(matches!(result, Err(SecurityError::InvalidExpression)));
}
#[test]
fn test_validate_timeout_within_bounds() {
assert_eq!(validate_timeout(1000).unwrap(), 1000);
assert_eq!(validate_timeout(5000).unwrap(), 5000);
assert_eq!(validate_timeout(100_000).unwrap(), 100_000);
}
#[test]
fn test_validate_timeout_zero() {
assert_eq!(validate_timeout(0).unwrap(), 1, "Zero timeout should be clamped to 1ms");
}
#[test]
fn test_validate_timeout_excessive() {
let result = validate_timeout(500_000);
assert!(result.is_err(), "Excessive timeout should be an error");
assert_eq!(result.unwrap_err(), SecurityError::ExcessiveTimeout(500_000));
assert!(validate_timeout(1_000_000).is_err());
}
#[test]
fn test_validate_timeout_boundary_at_max_is_ok() {
assert!(validate_timeout(MAX_TIMEOUT_MS).is_ok());
assert_eq!(validate_timeout(MAX_TIMEOUT_MS).unwrap(), MAX_TIMEOUT_MS);
}
#[test]
fn test_validate_timeout_one_over_max_is_error() {
assert!(validate_timeout(MAX_TIMEOUT_MS + 1).is_err());
assert_eq!(
validate_timeout(MAX_TIMEOUT_MS + 1).unwrap_err(),
SecurityError::ExcessiveTimeout(MAX_TIMEOUT_MS + 1)
);
}
#[test]
fn test_validate_condition_valid() -> Result<()> {
validate_condition("$x > 10")?;
validate_condition("defined($var)")?;
Ok(())
}
#[test]
fn test_validate_condition_newline() -> Result<()> {
let result = validate_condition("1\nprint 'pwned'");
assert!(result.is_err(), "Condition with newline should be rejected");
assert!(
matches!(result, Err(SecurityError::InvalidExpression)),
"Expected InvalidExpression error"
);
Ok(())
}
#[test]
fn test_validate_path_empty_string() -> Result<()> {
let tempdir = tempfile::tempdir()?;
let workspace = tempdir.path();
let empty_path = PathBuf::from("");
let result = validate_path(&empty_path, workspace);
assert!(result.is_ok(), "Empty path should resolve to workspace root");
Ok(())
}
#[test]
fn test_validate_expression_empty_string() -> Result<()> {
validate_expression("")?;
Ok(())
}
#[test]
fn test_validate_timeout_boundary_values() {
assert_eq!(validate_timeout(1).unwrap(), 1);
assert_eq!(validate_timeout(MAX_TIMEOUT_MS).unwrap(), MAX_TIMEOUT_MS);
assert!(validate_timeout(MAX_TIMEOUT_MS + 1).is_err());
}
#[test]
fn test_security_error_display_messages() {
let path_error = SecurityError::PathTraversalAttempt("../../../etc/passwd".to_string());
assert!(format!("{}", path_error).contains("Path traversal attempt detected"));
let expr_error = SecurityError::InvalidExpression;
assert_eq!(format!("{}", expr_error), "Expression cannot contain newlines");
let timeout_error = SecurityError::ExcessiveTimeout(500_000);
assert!(format!("{}", timeout_error).contains("500000ms"));
}
}