Skip to main content

varpulis_cli/
security.rs

1//! Security module for Varpulis CLI
2//!
3//! Provides path validation, authentication helpers, and security utilities.
4
5use std::path::{Path, PathBuf};
6
7/// Error types for security operations
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum SecurityError {
10    /// Path is outside the allowed working directory
11    PathTraversal { path: String },
12    /// Path does not exist or is inaccessible
13    InvalidPath { path: String, reason: String },
14    /// Working directory is invalid
15    InvalidWorkdir { path: String, reason: String },
16    /// Authentication failed
17    AuthenticationFailed { reason: String },
18    /// Rate limit exceeded
19    RateLimitExceeded { ip: String },
20}
21
22impl std::fmt::Display for SecurityError {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        match self {
25            Self::PathTraversal { .. } => {
26                // Generic message to avoid information disclosure
27                write!(f, "Access denied: path outside allowed directory")
28            }
29            Self::InvalidPath { reason, .. } => {
30                write!(f, "Invalid path: {reason}")
31            }
32            Self::InvalidWorkdir { path, reason } => {
33                write!(f, "Invalid workdir '{path}': {reason}")
34            }
35            Self::AuthenticationFailed { reason } => {
36                write!(f, "Authentication failed: {reason}")
37            }
38            Self::RateLimitExceeded { ip } => {
39                write!(f, "Rate limit exceeded for IP: {ip}")
40            }
41        }
42    }
43}
44
45impl std::error::Error for SecurityError {}
46
47/// Result type for security operations
48pub type SecurityResult<T> = Result<T, SecurityError>;
49
50/// Validate that a path is within the allowed working directory.
51///
52/// This function prevents path traversal attacks by:
53/// 1. Converting the path to absolute (relative to workdir if not absolute)
54/// 2. Canonicalizing to resolve `..`, `.`, and symlinks
55/// 3. Verifying the canonical path starts with the canonical workdir
56///
57/// # Arguments
58/// * `path` - The path to validate (can be relative or absolute)
59/// * `workdir` - The allowed working directory
60///
61/// # Returns
62/// * `Ok(PathBuf)` - The canonical, validated path
63/// * `Err(SecurityError)` - If the path is invalid or outside workdir
64///
65/// # Examples
66/// ```
67/// use std::path::PathBuf;
68/// use varpulis_cli::security::validate_path;
69///
70/// let workdir = std::env::current_dir().unwrap();
71/// // Valid path within workdir
72/// let result = validate_path("src/main.rs", &workdir);
73/// // Note: Result depends on whether the file exists
74/// ```
75pub fn validate_path(path: &str, workdir: &Path) -> SecurityResult<PathBuf> {
76    let requested = PathBuf::from(path);
77
78    // Resolve to absolute path
79    let absolute = if requested.is_absolute() {
80        requested
81    } else {
82        workdir.join(&requested)
83    };
84
85    // Canonicalize workdir first to ensure it's valid
86    let workdir_canonical = workdir
87        .canonicalize()
88        .map_err(|e| SecurityError::InvalidWorkdir {
89            path: workdir.display().to_string(),
90            reason: e.to_string(),
91        })?;
92
93    // Canonicalize to resolve .. and symlinks
94    let canonical = absolute
95        .canonicalize()
96        .map_err(|e| SecurityError::InvalidPath {
97            path: path.to_string(),
98            reason: e.to_string(),
99        })?;
100
101    // Ensure the canonical path starts with workdir
102    if !canonical.starts_with(&workdir_canonical) {
103        return Err(SecurityError::PathTraversal {
104            path: path.to_string(),
105        });
106    }
107
108    Ok(canonical)
109}
110
111/// Validate and canonicalize a workdir path.
112///
113/// # Arguments
114/// * `workdir` - Optional workdir path, defaults to current directory
115///
116/// # Returns
117/// * `Ok(PathBuf)` - The canonical workdir path
118/// * `Err(SecurityError)` - If the workdir is invalid
119pub fn validate_workdir(workdir: Option<PathBuf>) -> SecurityResult<PathBuf> {
120    let path =
121        workdir.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
122
123    path.canonicalize()
124        .map_err(|e| SecurityError::InvalidWorkdir {
125            path: path.display().to_string(),
126            reason: e.to_string(),
127        })
128}
129
130/// Check if a path contains potentially dangerous patterns.
131///
132/// This is a fast pre-check before canonicalization.
133/// It detects obvious path traversal attempts.
134///
135/// # Arguments
136/// * `path` - The path string to check
137///
138/// # Returns
139/// * `true` if the path looks suspicious
140/// * `false` if the path looks safe (but still needs full validation)
141pub fn is_suspicious_path(path: &str) -> bool {
142    // Check for obvious path traversal patterns
143    path.contains("..")
144        || path.contains("//")
145        || path.starts_with('/')
146        || path.contains('\0')
147        || path.contains('~')
148}
149
150/// Sanitize a filename by removing dangerous characters.
151///
152/// This is useful for user-provided filenames in file creation.
153///
154/// # Arguments
155/// * `filename` - The filename to sanitize
156///
157/// # Returns
158/// * Sanitized filename safe for use in file operations
159pub fn sanitize_filename(filename: &str) -> String {
160    filename
161        .chars()
162        .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '_' || *c == '-')
163        .collect::<String>()
164        .trim_start_matches('.')
165        .to_string()
166}
167
168/// Generate a simple UUID-like identifier.
169///
170/// Uses timestamp-based generation for simplicity.
171/// Not cryptographically secure, suitable for request IDs.
172///
173/// # Returns
174/// * A hex string identifier
175pub fn generate_request_id() -> String {
176    use std::time::{SystemTime, UNIX_EPOCH};
177
178    let duration = SystemTime::now()
179        .duration_since(UNIX_EPOCH)
180        .unwrap_or_else(|_| std::time::Duration::from_secs(0));
181
182    format!("{:x}{:x}", duration.as_secs(), duration.subsec_nanos())
183}
184
185// =============================================================================
186// Tests - TDD approach: tests written first!
187// =============================================================================
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::fs;
193    use tempfile::TempDir;
194
195    // -------------------------------------------------------------------------
196    // validate_path tests
197    // -------------------------------------------------------------------------
198
199    #[test]
200    fn test_validate_path_simple_relative() {
201        let temp_dir = TempDir::new().expect("Failed to create temp dir");
202        let workdir = temp_dir.path();
203
204        // Create a file to validate
205        let test_file = workdir.join("test.txt");
206        fs::write(&test_file, "test content").expect("Failed to write test file");
207
208        let result = validate_path("test.txt", workdir);
209        assert!(result.is_ok());
210        assert_eq!(
211            result.expect("should succeed"),
212            test_file.canonicalize().expect("should canonicalize")
213        );
214    }
215
216    #[test]
217    fn test_validate_path_nested_relative() {
218        let temp_dir = TempDir::new().expect("Failed to create temp dir");
219        let workdir = temp_dir.path();
220
221        // Create nested directory and file
222        let subdir = workdir.join("subdir");
223        fs::create_dir(&subdir).expect("Failed to create subdir");
224        let test_file = subdir.join("nested.txt");
225        fs::write(&test_file, "nested content").expect("Failed to write test file");
226
227        let result = validate_path("subdir/nested.txt", workdir);
228        assert!(result.is_ok());
229    }
230
231    #[test]
232    fn test_validate_path_traversal_blocked() {
233        let temp_dir = TempDir::new().expect("Failed to create temp dir");
234        let workdir = temp_dir.path().join("subdir");
235        fs::create_dir(&workdir).expect("Failed to create workdir");
236
237        // Try to escape with ..
238        let result = validate_path("../escape.txt", &workdir);
239        assert!(result.is_err());
240
241        if let Err(SecurityError::PathTraversal { .. }) = result {
242            // Expected
243        } else if let Err(SecurityError::InvalidPath { .. }) = result {
244            // Also acceptable - file doesn't exist
245        } else {
246            panic!("Expected PathTraversal or InvalidPath error");
247        }
248    }
249
250    #[test]
251    fn test_validate_path_absolute_outside_workdir() {
252        let temp_dir = TempDir::new().expect("Failed to create temp dir");
253        let workdir = temp_dir.path().join("allowed");
254        fs::create_dir(&workdir).expect("Failed to create workdir");
255
256        // Create file outside workdir
257        let outside_file = temp_dir.path().join("outside.txt");
258        fs::write(&outside_file, "outside").expect("Failed to write");
259
260        // Try to access with absolute path
261        let result = validate_path(outside_file.to_str().expect("should convert"), &workdir);
262        assert!(result.is_err());
263
264        match result {
265            Err(SecurityError::PathTraversal { .. }) => {}
266            _ => panic!("Expected PathTraversal error"),
267        }
268    }
269
270    #[test]
271    fn test_validate_path_nonexistent_file() {
272        let temp_dir = TempDir::new().expect("Failed to create temp dir");
273        let workdir = temp_dir.path();
274
275        let result = validate_path("nonexistent.txt", workdir);
276        assert!(result.is_err());
277
278        match result {
279            Err(SecurityError::InvalidPath { reason, .. }) => {
280                assert!(reason.contains("No such file") || reason.contains("cannot find"));
281            }
282            _ => panic!("Expected InvalidPath error"),
283        }
284    }
285
286    #[test]
287    fn test_validate_path_invalid_workdir() {
288        let result = validate_path("test.txt", Path::new("/nonexistent/workdir/xyz123"));
289        assert!(result.is_err());
290
291        match result {
292            Err(SecurityError::InvalidWorkdir { .. }) => {}
293            _ => panic!("Expected InvalidWorkdir error"),
294        }
295    }
296
297    #[test]
298    fn test_validate_path_dot_dot_in_middle() {
299        let temp_dir = TempDir::new().expect("Failed to create temp dir");
300        let workdir = temp_dir.path();
301
302        // Create structure: workdir/a/b/file.txt
303        let dir_a = workdir.join("a");
304        let dir_b = dir_a.join("b");
305        fs::create_dir_all(&dir_b).expect("Failed to create dirs");
306        let file = dir_b.join("file.txt");
307        fs::write(&file, "content").expect("Failed to write");
308
309        // Path that goes down then up but stays in workdir: a/b/../b/file.txt
310        let result = validate_path("a/b/../b/file.txt", workdir);
311        assert!(result.is_ok());
312    }
313
314    // -------------------------------------------------------------------------
315    // validate_workdir tests
316    // -------------------------------------------------------------------------
317
318    #[test]
319    fn test_validate_workdir_none_uses_current() {
320        let result = validate_workdir(None);
321        assert!(result.is_ok());
322    }
323
324    #[test]
325    fn test_validate_workdir_valid_path() {
326        let temp_dir = TempDir::new().expect("Failed to create temp dir");
327        let result = validate_workdir(Some(temp_dir.path().to_path_buf()));
328        assert!(result.is_ok());
329    }
330
331    #[test]
332    fn test_validate_workdir_invalid_path() {
333        let result = validate_workdir(Some(PathBuf::from("/nonexistent/path/xyz123")));
334        assert!(result.is_err());
335
336        match result {
337            Err(SecurityError::InvalidWorkdir { .. }) => {}
338            _ => panic!("Expected InvalidWorkdir error"),
339        }
340    }
341
342    // -------------------------------------------------------------------------
343    // is_suspicious_path tests
344    // -------------------------------------------------------------------------
345
346    #[test]
347    fn test_is_suspicious_path_double_dot() {
348        assert!(is_suspicious_path("../etc/passwd"));
349        assert!(is_suspicious_path("foo/../bar"));
350        assert!(is_suspicious_path("foo/bar/.."));
351    }
352
353    #[test]
354    fn test_is_suspicious_path_double_slash() {
355        assert!(is_suspicious_path("foo//bar"));
356        assert!(is_suspicious_path("//etc/passwd"));
357    }
358
359    #[test]
360    fn test_is_suspicious_path_absolute() {
361        assert!(is_suspicious_path("/etc/passwd"));
362        assert!(is_suspicious_path("/home/user/file"));
363    }
364
365    #[test]
366    fn test_is_suspicious_path_null_byte() {
367        assert!(is_suspicious_path("file.txt\0.jpg"));
368    }
369
370    #[test]
371    fn test_is_suspicious_path_tilde() {
372        assert!(is_suspicious_path("~/secrets"));
373        assert!(is_suspicious_path("~user/file"));
374    }
375
376    #[test]
377    fn test_is_suspicious_path_safe_paths() {
378        assert!(!is_suspicious_path("file.txt"));
379        assert!(!is_suspicious_path("dir/file.txt"));
380        assert!(!is_suspicious_path("a/b/c/d.txt"));
381        assert!(!is_suspicious_path("my-file_name.rs"));
382    }
383
384    // -------------------------------------------------------------------------
385    // sanitize_filename tests
386    // -------------------------------------------------------------------------
387
388    #[test]
389    fn test_sanitize_filename_safe() {
390        assert_eq!(sanitize_filename("file.txt"), "file.txt");
391        assert_eq!(sanitize_filename("my_file-2.rs"), "my_file-2.rs");
392    }
393
394    #[test]
395    fn test_sanitize_filename_removes_slashes() {
396        assert_eq!(sanitize_filename("../etc/passwd"), "etcpasswd");
397        assert_eq!(sanitize_filename("foo/bar"), "foobar");
398    }
399
400    #[test]
401    fn test_sanitize_filename_removes_special_chars() {
402        assert_eq!(sanitize_filename("file<>:\"|?*.txt"), "file.txt");
403        assert_eq!(sanitize_filename("hello\0world"), "helloworld");
404    }
405
406    #[test]
407    fn test_sanitize_filename_removes_leading_dots() {
408        assert_eq!(sanitize_filename(".htaccess"), "htaccess");
409        assert_eq!(sanitize_filename("...test"), "test");
410    }
411
412    #[test]
413    fn test_sanitize_filename_preserves_internal_dots() {
414        assert_eq!(sanitize_filename("file.name.txt"), "file.name.txt");
415    }
416
417    // -------------------------------------------------------------------------
418    // generate_request_id tests
419    // -------------------------------------------------------------------------
420
421    #[test]
422    fn test_generate_request_id_not_empty() {
423        let id = generate_request_id();
424        assert!(!id.is_empty());
425    }
426
427    #[test]
428    fn test_generate_request_id_is_hex() {
429        let id = generate_request_id();
430        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
431    }
432
433    #[test]
434    fn test_generate_request_id_unique() {
435        let id1 = generate_request_id();
436        std::thread::sleep(std::time::Duration::from_millis(1));
437        let id2 = generate_request_id();
438        // Not guaranteed unique but very likely different
439        // In practice, subsec_nanos should differ
440        assert_ne!(id1, id2);
441    }
442
443    // -------------------------------------------------------------------------
444    // SecurityError Display tests
445    // -------------------------------------------------------------------------
446
447    #[test]
448    fn test_security_error_display_path_traversal() {
449        let err = SecurityError::PathTraversal {
450            path: "../etc/passwd".to_string(),
451        };
452        let msg = format!("{err}");
453        // Should NOT reveal the actual path
454        assert!(!msg.contains("passwd"));
455        assert!(msg.contains("Access denied"));
456    }
457
458    #[test]
459    fn test_security_error_display_invalid_path() {
460        let err = SecurityError::InvalidPath {
461            path: "test.txt".to_string(),
462            reason: "file not found".to_string(),
463        };
464        let msg = format!("{err}");
465        assert!(msg.contains("Invalid path"));
466        assert!(msg.contains("file not found"));
467    }
468
469    #[test]
470    fn test_security_error_display_auth_failed() {
471        let err = SecurityError::AuthenticationFailed {
472            reason: "invalid token".to_string(),
473        };
474        let msg = format!("{err}");
475        assert!(msg.contains("Authentication failed"));
476    }
477}