1use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum SecurityError {
10 PathTraversal { path: String },
12 InvalidPath { path: String, reason: String },
14 InvalidWorkdir { path: String, reason: String },
16 AuthenticationFailed { reason: String },
18 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 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
47pub type SecurityResult<T> = Result<T, SecurityError>;
49
50pub fn validate_path(path: &str, workdir: &Path) -> SecurityResult<PathBuf> {
76 let requested = PathBuf::from(path);
77
78 let absolute = if requested.is_absolute() {
80 requested
81 } else {
82 workdir.join(&requested)
83 };
84
85 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 let canonical = absolute
95 .canonicalize()
96 .map_err(|e| SecurityError::InvalidPath {
97 path: path.to_string(),
98 reason: e.to_string(),
99 })?;
100
101 if !canonical.starts_with(&workdir_canonical) {
103 return Err(SecurityError::PathTraversal {
104 path: path.to_string(),
105 });
106 }
107
108 Ok(canonical)
109}
110
111pub 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
130pub fn is_suspicious_path(path: &str) -> bool {
142 path.contains("..")
144 || path.contains("//")
145 || path.starts_with('/')
146 || path.contains('\0')
147 || path.contains('~')
148}
149
150pub 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
168pub 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#[cfg(test)]
190mod tests {
191 use std::fs;
192
193 use tempfile::TempDir;
194
195 use super::*;
196
197 #[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 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 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 let result = validate_path("../escape.txt", &workdir);
241 assert!(result.is_err());
242
243 if let Err(SecurityError::PathTraversal { .. }) = result {
244 } else if let Err(SecurityError::InvalidPath { .. }) = result {
246 } 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 let outside_file = temp_dir.path().join("outside.txt");
260 fs::write(&outside_file, "outside").expect("Failed to write");
261
262 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 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 let result = validate_path("a/b/../b/file.txt", workdir);
313 assert!(result.is_ok());
314 }
315
316 #[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 #[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 #[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 #[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 assert_ne!(id1, id2);
443 }
444
445 #[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 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}