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 std::fs;
192
193    use tempfile::TempDir;
194
195    use super::*;
196
197    // -------------------------------------------------------------------------
198    // validate_path tests
199    // -------------------------------------------------------------------------
200
201    #[test]
202    fn test_validate_path_simple_relative() {
203        let temp_dir = TempDir::new().expect("Failed to create temp dir");
204        let workdir = temp_dir.path();
205
206        // Create a file to validate
207        let test_file = workdir.join("test.txt");
208        fs::write(&test_file, "test content").expect("Failed to write test file");
209
210        let result = validate_path("test.txt", workdir);
211        assert!(result.is_ok());
212        assert_eq!(
213            result.expect("should succeed"),
214            test_file.canonicalize().expect("should canonicalize")
215        );
216    }
217
218    #[test]
219    fn test_validate_path_nested_relative() {
220        let temp_dir = TempDir::new().expect("Failed to create temp dir");
221        let workdir = temp_dir.path();
222
223        // Create nested directory and file
224        let subdir = workdir.join("subdir");
225        fs::create_dir(&subdir).expect("Failed to create subdir");
226        let test_file = subdir.join("nested.txt");
227        fs::write(&test_file, "nested content").expect("Failed to write test file");
228
229        let result = validate_path("subdir/nested.txt", workdir);
230        assert!(result.is_ok());
231    }
232
233    #[test]
234    fn test_validate_path_traversal_blocked() {
235        let temp_dir = TempDir::new().expect("Failed to create temp dir");
236        let workdir = temp_dir.path().join("subdir");
237        fs::create_dir(&workdir).expect("Failed to create workdir");
238
239        // Try to escape with ..
240        let result = validate_path("../escape.txt", &workdir);
241        assert!(result.is_err());
242
243        if let Err(SecurityError::PathTraversal { .. }) = result {
244            // Expected
245        } else if let Err(SecurityError::InvalidPath { .. }) = result {
246            // Also acceptable - file doesn't exist
247        } else {
248            panic!("Expected PathTraversal or InvalidPath error");
249        }
250    }
251
252    #[test]
253    fn test_validate_path_absolute_outside_workdir() {
254        let temp_dir = TempDir::new().expect("Failed to create temp dir");
255        let workdir = temp_dir.path().join("allowed");
256        fs::create_dir(&workdir).expect("Failed to create workdir");
257
258        // Create file outside workdir
259        let outside_file = temp_dir.path().join("outside.txt");
260        fs::write(&outside_file, "outside").expect("Failed to write");
261
262        // Try to access with absolute path
263        let result = validate_path(outside_file.to_str().expect("should convert"), &workdir);
264        assert!(result.is_err());
265
266        match result {
267            Err(SecurityError::PathTraversal { .. }) => {}
268            _ => panic!("Expected PathTraversal error"),
269        }
270    }
271
272    #[test]
273    fn test_validate_path_nonexistent_file() {
274        let temp_dir = TempDir::new().expect("Failed to create temp dir");
275        let workdir = temp_dir.path();
276
277        let result = validate_path("nonexistent.txt", workdir);
278        assert!(result.is_err());
279
280        match result {
281            Err(SecurityError::InvalidPath { reason, .. }) => {
282                assert!(reason.contains("No such file") || reason.contains("cannot find"));
283            }
284            _ => panic!("Expected InvalidPath error"),
285        }
286    }
287
288    #[test]
289    fn test_validate_path_invalid_workdir() {
290        let result = validate_path("test.txt", Path::new("/nonexistent/workdir/xyz123"));
291        assert!(result.is_err());
292
293        match result {
294            Err(SecurityError::InvalidWorkdir { .. }) => {}
295            _ => panic!("Expected InvalidWorkdir error"),
296        }
297    }
298
299    #[test]
300    fn test_validate_path_dot_dot_in_middle() {
301        let temp_dir = TempDir::new().expect("Failed to create temp dir");
302        let workdir = temp_dir.path();
303
304        // Create structure: workdir/a/b/file.txt
305        let dir_a = workdir.join("a");
306        let dir_b = dir_a.join("b");
307        fs::create_dir_all(&dir_b).expect("Failed to create dirs");
308        let file = dir_b.join("file.txt");
309        fs::write(&file, "content").expect("Failed to write");
310
311        // Path that goes down then up but stays in workdir: a/b/../b/file.txt
312        let result = validate_path("a/b/../b/file.txt", workdir);
313        assert!(result.is_ok());
314    }
315
316    // -------------------------------------------------------------------------
317    // validate_workdir tests
318    // -------------------------------------------------------------------------
319
320    #[test]
321    fn test_validate_workdir_none_uses_current() {
322        let result = validate_workdir(None);
323        assert!(result.is_ok());
324    }
325
326    #[test]
327    fn test_validate_workdir_valid_path() {
328        let temp_dir = TempDir::new().expect("Failed to create temp dir");
329        let result = validate_workdir(Some(temp_dir.path().to_path_buf()));
330        assert!(result.is_ok());
331    }
332
333    #[test]
334    fn test_validate_workdir_invalid_path() {
335        let result = validate_workdir(Some(PathBuf::from("/nonexistent/path/xyz123")));
336        assert!(result.is_err());
337
338        match result {
339            Err(SecurityError::InvalidWorkdir { .. }) => {}
340            _ => panic!("Expected InvalidWorkdir error"),
341        }
342    }
343
344    // -------------------------------------------------------------------------
345    // is_suspicious_path tests
346    // -------------------------------------------------------------------------
347
348    #[test]
349    fn test_is_suspicious_path_double_dot() {
350        assert!(is_suspicious_path("../etc/passwd"));
351        assert!(is_suspicious_path("foo/../bar"));
352        assert!(is_suspicious_path("foo/bar/.."));
353    }
354
355    #[test]
356    fn test_is_suspicious_path_double_slash() {
357        assert!(is_suspicious_path("foo//bar"));
358        assert!(is_suspicious_path("//etc/passwd"));
359    }
360
361    #[test]
362    fn test_is_suspicious_path_absolute() {
363        assert!(is_suspicious_path("/etc/passwd"));
364        assert!(is_suspicious_path("/home/user/file"));
365    }
366
367    #[test]
368    fn test_is_suspicious_path_null_byte() {
369        assert!(is_suspicious_path("file.txt\0.jpg"));
370    }
371
372    #[test]
373    fn test_is_suspicious_path_tilde() {
374        assert!(is_suspicious_path("~/secrets"));
375        assert!(is_suspicious_path("~user/file"));
376    }
377
378    #[test]
379    fn test_is_suspicious_path_safe_paths() {
380        assert!(!is_suspicious_path("file.txt"));
381        assert!(!is_suspicious_path("dir/file.txt"));
382        assert!(!is_suspicious_path("a/b/c/d.txt"));
383        assert!(!is_suspicious_path("my-file_name.rs"));
384    }
385
386    // -------------------------------------------------------------------------
387    // sanitize_filename tests
388    // -------------------------------------------------------------------------
389
390    #[test]
391    fn test_sanitize_filename_safe() {
392        assert_eq!(sanitize_filename("file.txt"), "file.txt");
393        assert_eq!(sanitize_filename("my_file-2.rs"), "my_file-2.rs");
394    }
395
396    #[test]
397    fn test_sanitize_filename_removes_slashes() {
398        assert_eq!(sanitize_filename("../etc/passwd"), "etcpasswd");
399        assert_eq!(sanitize_filename("foo/bar"), "foobar");
400    }
401
402    #[test]
403    fn test_sanitize_filename_removes_special_chars() {
404        assert_eq!(sanitize_filename("file<>:\"|?*.txt"), "file.txt");
405        assert_eq!(sanitize_filename("hello\0world"), "helloworld");
406    }
407
408    #[test]
409    fn test_sanitize_filename_removes_leading_dots() {
410        assert_eq!(sanitize_filename(".htaccess"), "htaccess");
411        assert_eq!(sanitize_filename("...test"), "test");
412    }
413
414    #[test]
415    fn test_sanitize_filename_preserves_internal_dots() {
416        assert_eq!(sanitize_filename("file.name.txt"), "file.name.txt");
417    }
418
419    // -------------------------------------------------------------------------
420    // generate_request_id tests
421    // -------------------------------------------------------------------------
422
423    #[test]
424    fn test_generate_request_id_not_empty() {
425        let id = generate_request_id();
426        assert!(!id.is_empty());
427    }
428
429    #[test]
430    fn test_generate_request_id_is_hex() {
431        let id = generate_request_id();
432        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
433    }
434
435    #[test]
436    fn test_generate_request_id_unique() {
437        let id1 = generate_request_id();
438        std::thread::sleep(std::time::Duration::from_millis(1));
439        let id2 = generate_request_id();
440        // Not guaranteed unique but very likely different
441        // In practice, subsec_nanos should differ
442        assert_ne!(id1, id2);
443    }
444
445    // -------------------------------------------------------------------------
446    // SecurityError Display tests
447    // -------------------------------------------------------------------------
448
449    #[test]
450    fn test_security_error_display_path_traversal() {
451        let err = SecurityError::PathTraversal {
452            path: "../etc/passwd".to_string(),
453        };
454        let msg = format!("{err}");
455        // Should NOT reveal the actual path
456        assert!(!msg.contains("passwd"));
457        assert!(msg.contains("Access denied"));
458    }
459
460    #[test]
461    fn test_security_error_display_invalid_path() {
462        let err = SecurityError::InvalidPath {
463            path: "test.txt".to_string(),
464            reason: "file not found".to_string(),
465        };
466        let msg = format!("{err}");
467        assert!(msg.contains("Invalid path"));
468        assert!(msg.contains("file not found"));
469    }
470
471    #[test]
472    fn test_security_error_display_auth_failed() {
473        let err = SecurityError::AuthenticationFailed {
474            reason: "invalid token".to_string(),
475        };
476        let msg = format!("{err}");
477        assert!(msg.contains("Authentication failed"));
478    }
479}