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 super::*;
192 use std::fs;
193 use tempfile::TempDir;
194
195 #[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 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 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 let result = validate_path("../escape.txt", &workdir);
239 assert!(result.is_err());
240
241 if let Err(SecurityError::PathTraversal { .. }) = result {
242 } else if let Err(SecurityError::InvalidPath { .. }) = result {
244 } 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 let outside_file = temp_dir.path().join("outside.txt");
258 fs::write(&outside_file, "outside").expect("Failed to write");
259
260 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 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 let result = validate_path("a/b/../b/file.txt", workdir);
311 assert!(result.is_ok());
312 }
313
314 #[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 #[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 #[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 #[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 assert_ne!(id1, id2);
441 }
442
443 #[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 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}